diff --git a/doc/.gitignore b/doc/.gitignore
deleted file mode 100644
index 208d1dced07ec6fe895b65fbb03c282390b031cf..0000000000000000000000000000000000000000
--- a/doc/.gitignore
+++ /dev/null
@@ -1,4 +0,0 @@
-# exclude working directories
-
-build
-static
diff --git a/doc/conf.py b/doc/conf.py
index 1de522769fc6818c5779d259ceee00873a609628..e81cb603bed70a431153222676f2c871f60822c0 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -21,6 +21,8 @@
 # import sys
 # sys.path.insert(0, os.path.abspath('.'))
 
+def setup(app):
+    app.add_stylesheet('custom.css')
 
 # -- General configuration ------------------------------------------------
 
diff --git a/doc/index.rst b/doc/index.rst
index 269ff4b65fc57e8b556dc57a3605be64d0a03da0..32b94e403c57de44338c32dc599400f2ce335f72 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -25,4 +25,5 @@ Arbor is a high-performance library for computationa neurscience simulations.
    :maxdepth: 1
 
    library
+   sampling_api
 
diff --git a/doc/sampling_api.rst b/doc/sampling_api.rst
new file mode 100644
index 0000000000000000000000000000000000000000..79fa620dc27b7e428a48c64f0c1abeb874c3f5e4
--- /dev/null
+++ b/doc/sampling_api.rst
@@ -0,0 +1,275 @@
+Sampling API
+============
+
+The new API replaces the flexible but irreducibly inefficient scheme
+where the next sample time for a sampling was determined by the
+return value of the sampler callback.
+
+
+Definitions
+-----------
+
+probe
+    A location or component of a cell that is available for monitoring.
+
+sample
+    A record of data corresponding to the value at a specific *probe* at a specific time.
+
+sampler
+    A function or function object that receives a sequence of *sample* records.
+
+schedule
+    A function or function object that, given a time interval, returns a list of sample times within that interval.
+
+
+
+Probes
+------
+
+Probes are specified in the recipe objects that are used to initialize a
+model; the specification of the item or value that is subjected to a
+probe will be specific to a particular cell type.
+
+.. container:: api-code
+
+   .. code-block:: cpp
+
+           using probe_tag = int;
+
+           struct probe_info {
+               cell_member_type id;   // cell gid, index of probe
+               probe_tag tag;         // opaque key, returned in sample record
+               any address;           // cell-type specific location info
+           };
+
+           probe_info recipe::get_probe(cell_member_type probe_id);
+
+
+The ``id`` field in the ``probe_info`` struct will be the same value as
+the ``probe_id`` used in the ``get_probe`` call.
+
+The ``get_probe()`` method is intended for use by cell group
+implementations to set up sampling data structures ahead of time and for
+putting in place any structures or information in the concrete cell
+implementations to allow monitoring.
+
+The ``tag`` field has no semantics for the engine. It is provided merely
+as a way of passing additional metadata about a probe to any sampler
+that polls it, with a view to samplers that handle multiple probes,
+possibly with different value types.
+
+Probe addresses are now decoupled from the cell descriptions themselves —
+this allows a recipe implementation to construct probes independently
+of the cells themselves. It is the responsibility of a cell group implementation
+to parse the probe address objects wrapped in the ``any address`` field.
+
+
+Samplers and sample records
+---------------------------
+
+Data collected from probes (according to a schedule described below)
+will be passed to a sampler function or function object:
+
+.. container:: api-code
+
+    .. code-block:: cpp
+
+            using sampler_function =
+                std::function<void (cell_member_type, probe_tag, size_t, const sample_record*)>;
+
+where the parameters are respectively the probe id, the tag, the number
+of samples and a pointer to the sequence of sample records.
+
+The ``probe_tag`` is the key given in the ``probe_info`` returned by
+the recipe.
+
+One ``sample_record`` struct contains one sample of the probe data at a
+given simulation time point:
+
+.. container:: api-code
+
+    .. code-block:: cpp
+
+            struct sample_record {
+                time_type time;    // simulation time of sample
+                any_ptr data;      // sample data
+            };
+
+The ``data`` field points to the sample data, wrapped in ``any_ptr`` for
+type-checked access. The exact representation will depend on the nature of
+the object that is being probed, but it should depend only on the cell type and
+probe address.
+
+The data pointed to by ``data``, and the sample records themselves, are
+only guaranteed to be valid for the duration of the call to the sampler
+function. A simple sampler implementation for ``double`` data might be:
+
+.. container:: example-code
+
+    .. code-block:: cpp
+
+            using sample_data = std::map<cell_member_type, std::vector<std::pair<double, double>>>;
+
+            struct scalar_sampler {
+                sample_data& samples;
+
+                explicit scalar_sample(sample_data& samples): samples(samples) {}
+
+                void operator()(cell_member_type id, probe_tag, size_t n, const sample_record* records) {
+                    for (size_t i=0; i<n; ++i) {
+                        const auto& rec = records[i];
+
+                        const double* data = any_cast<const double*>(rec.data);
+                        assert(data);
+                        samples[id].emplace_back(rec.time, *data);
+                    }
+                }
+            };
+
+The use of ``any_ptr`` allows type-checked access to the sample data, which
+may differ in type from probe to probe.
+
+
+Model and cell group interface
+------------------------------
+
+Polling rates, policies and sampler functions are set through the
+``model`` interface, after construction from a recipe.
+
+.. container:: api-code
+
+    .. code-block:: cpp
+
+            using sampler_association_handle = std::size_t;
+            using cell_member_predicate = std::function<bool (cell_member_type)>;
+
+            sampler_association_handle model::add_sampler(
+                cell_member_predicate probe_ids,
+                schedule sched,
+                sampler_function fn,
+                sampling_policy policy = sampling_policy::lax);
+
+            void model::remove_sampler(sampler_association_handle);
+
+            void model::remove_all_samplers();
+
+Multiple samplers can then be associated with the same probe locations.
+The handle returned is only used for managing the lifetime of the
+association. The ``cell_member_predicate`` parameter defines the
+set of probe ids in terms of a membership test.
+
+Two helper functions are provided for making ``cell_member_predicate`` objects:
+
+.. container:: api-code
+
+   .. code-block:: cpp
+
+           // Match all probe ids.
+           cell_member_predicate all_probes = [](cell_member_type pid) { return true; };
+
+           // Match just one probe id.
+           cell_member_predicate one_probe(cell_member_type pid) {
+               return [pid](cell_member_type x) { return pid==x; };
+           }
+
+
+The ``sampling_policy`` policy is used to modify sampling behaviour: by
+default, the ``lax`` policy is to perform a best-effort sampling that
+minimizes sampling overhead and which will not change the numerical
+behaviour of the simulation. Other policies may be implemented in the
+future, e.g. ``interpolated`` or ``exact``.
+
+The model object will pass on the sampler setting request to the cell
+group that owns the given probe id. The ``cell_group`` interface will be
+correspondingly extended:
+
+.. container:: api-code
+
+   .. code-block:: cpp
+
+           void cell_group::add_sampler(sampler_association_handle h, cell_member_predicate probe_ids, sample_schedule sched, sampler_function fn, sampling_policy policy);
+
+           void cell_group::remove_sampler(sampler_association_handle);
+
+           void cell_group::remove_all_samplers();
+
+Cell groups will invoke the corresponding sampler function directly, and
+may aggregate multiple samples with the same probe id in one call to the
+sampler. Calls to the sampler are synchronous, in the sense that
+processing of the cell group state does not proceed while the sampler
+function is being executed, but the times of the samples given to the
+sampler will typically precede the time corresponding to the current
+state of the cell group. It should be expected that this difference in
+time should be no greater the the duration of the integration period
+(i.e. ``mindelay/2``).
+
+If a cell group does not support a given ``sampling_policy``, it should
+raise an exception. All cell groups should support the ``lax`` policy,
+if they support probes at all.
+
+
+Schedules
+---------
+
+Schedules represent a non-negative, monotonically increasing sequence
+of time points, and are used to specify the sampling schedule in any
+given association of a sampler function to a set of probes.
+
+A ``schedule`` object has two methods:
+
+.. container:: api-code
+
+   .. code-block:: cpp
+
+       void schedule::reset();
+
+       std::vector<time_type> events(time_type t0, time_type t1)
+
+The ``events(t0, t1)`` method returns a vector of monotonically
+increasing time values in the half-open interval ``[t0, t1)``.
+Successive calls to ``events`` — without an intervening call to ``reset()``
+—  must request strictly subsequent intervals.
+
+The ``reset()`` method resets the state such that events can be retrieved
+from again from time zero. A schedule that is reset must then produce
+the same sequence of time points, that is, it must exhibit repeatable
+and deterministic behaviour.
+
+The ``schedule`` object itself uses type-erasure to wrap any schedule
+implementation class, which can be any copy--constructible class that
+provides the methods ``reset()`` and ``events(t0, t1)`` above. Three
+schedule implementations are provided by the engine:
+
+.. container:: api-code
+
+   .. code-block:: cpp
+
+
+           // Schedule at integer multiples of dt:
+           schedule regular_schedule(time_type dt);
+
+           // Schedule at a predetermined (sorted) sequence of times:
+           template <typename Seq>
+           schedule explicit_schedule(const Seq& seq);
+
+           // Schedule according to Poisson process with lambda = 1/mean_dt
+           template <typename RandomNumberEngine>
+           schedule poisson_schedule(time_type mean_dt, const RandomNumberEngine& rng);
+
+The ``schedule`` class and its implementations are found in ``schedule.hpp``.
+
+
+Helper classes for probe/sampler management
+-------------------------------------------
+
+The ``model`` and ``mc_cell_group`` classes use classes defined in ``scheduler_map.hpp`` to simplify
+the management of sampler--probe associations and probe metdata.
+
+``sampler_association_map`` wraps an ``unordered_map`` between sampler assocation
+handles and tuples (*schedule*, *sampler*, *probe set*), with thread-safe
+accessors.
+
+``probe_assocation_map<Handle>`` is a type alias for an unordered map between
+probe ids and tuples (*probe handle*, *probe tag*), where the *probe handle*
+is a cell-group specific accessor that allows efficient polling.
+
diff --git a/doc/static/custom.css b/doc/static/custom.css
new file mode 100644
index 0000000000000000000000000000000000000000..005eac316b8d6274be5c4749129a7b8780a692fe
--- /dev/null
+++ b/doc/static/custom.css
@@ -0,0 +1,22 @@
+.example-code>div[class^='highlight']:before {
+    font-weight: bold;
+    display: block;
+    padding-bottom: 2pt;
+    padding-left: 3pt;
+    content: "Example";
+    background: #6ab0de;
+    color: #ffffff
+}
+
+.api-code>div[class^='highlight']:before {
+    font-weight: bold;
+    display: block;
+    padding-bottom: 2pt;
+    padding-left: 3pt;
+    content: "API";
+    background: #e0e0e0
+}
+
+.api-code>div[class^='highlight'] {
+    background: #f4f4f4
+}
diff --git a/miniapp/CMakeLists.txt b/miniapp/CMakeLists.txt
index c41f7c5137147ccc7724f05bdcfed29a75fccaef..95c42b1f15536202fb3ef6b2ffcc049d296e2d69 100644
--- a/miniapp/CMakeLists.txt
+++ b/miniapp/CMakeLists.txt
@@ -5,12 +5,14 @@ set(MINIAPP_SOURCES
     io.cpp
     miniapp_recipes.cpp
     morphology_pool.cpp
+    trace.cpp
 )
 set(MINIAPP_SOURCES_CUDA
     miniapp.cu
     io.cpp
     miniapp_recipes.cpp
     morphology_pool.cpp
+    trace.cpp
 )
 
 add_executable(miniapp.exe ${MINIAPP_SOURCES} ${HEADERS})
diff --git a/miniapp/miniapp.cpp b/miniapp/miniapp.cpp
index 05b1682b431208f8472911da0f255eb772f86e45..66b0ff2eaf3f8259aa43968bcada4fa5373ccea2 100644
--- a/miniapp/miniapp.cpp
+++ b/miniapp/miniapp.cpp
@@ -19,7 +19,10 @@
 #include <model.hpp>
 #include <profiling/profiler.hpp>
 #include <profiling/meter_manager.hpp>
+#include <sampling.hpp>
+#include <schedule.hpp>
 #include <threading/threading.hpp>
+#include <util/any.hpp>
 #include <util/config.hpp>
 #include <util/debug.hpp>
 #include <util/ioutil.hpp>
@@ -27,27 +30,28 @@
 
 #include "io.hpp"
 #include "miniapp_recipes.hpp"
-#include "trace_sampler.hpp"
+#include "trace.hpp"
 
 using namespace nest::mc;
 
+using util::any_cast;
+using util::make_span;
+
 using global_policy = communication::global_policy;
-using sample_trace_type = sample_trace<time_type, double>;
 using file_export_type = io::exporter_spike_file<global_policy>;
+using communicator_type = communication::communicator<communication::global_policy>;
+
 void banner(hw::node_info);
 std::unique_ptr<recipe> make_recipe(const io::cl_options&, const probe_distribution&);
-std::unique_ptr<sample_trace_type> make_trace(probe_record probe);
-using communicator_type = communication::communicator<communication::global_policy>;
+sample_trace make_trace(const probe_info& probe);
 
-void write_trace_json(const sample_trace_type& trace, const std::string& prefix = "trace_");
-void write_trace_csv(const sample_trace_type& trace, const std::string& prefix = "trace_");
 void report_compartment_stats(const recipe&);
 
 int main(int argc, char** argv) {
-    nest::mc::communication::global_policy_guard global_guard(argc, argv);
+    communication::global_policy_guard global_guard(argc, argv);
 
     try {
-        nest::mc::util::meter_manager meters;
+        util::meter_manager meters;
         meters.start();
 
         std::cout << util::mask_stream(global_policy::id()==0);
@@ -85,6 +89,9 @@ int main(int argc, char** argv) {
         pdist.all_segments = !options.probe_soma_only;
 
         auto recipe = make_recipe(options, pdist);
+        if (options.report_compartments) {
+            report_compartment_stats(*recipe);
+        }
 
         auto register_exporter = [] (const io::cl_options& options) {
             return
@@ -94,11 +101,28 @@ int main(int argc, char** argv) {
         };
 
         auto decomp = partition_load_balance(*recipe, nd);
-
         model m(*recipe, decomp);
 
-        if (options.report_compartments) {
-            report_compartment_stats(*recipe);
+        // Set up samplers for probes on local cable cells, as requested
+        // by command line options.
+        std::vector<sample_trace> sample_traces;
+        for (const auto& g: decomp.groups) {
+            if (g.kind==cable1d_neuron) {
+                for (auto gid: g.gids) {
+                    if (options.trace_max_gid && gid>*options.trace_max_gid) {
+                        continue;
+                    }
+
+                    for (cell_lid_type j: make_span(0, recipe->num_probes(gid))) {
+                        sample_traces.push_back(make_trace(recipe->get_probe({gid, j})));
+                    }
+                }
+            }
+        }
+
+        auto ssched = regular_schedule(options.sample_dt);
+        for (auto& trace: sample_traces) {
+            m.add_sampler(one_probe(trace.probe_id), ssched, make_simple_sampler(trace.samples));
         }
 
         // Specify event binning/coalescing.
@@ -109,18 +133,6 @@ int main(int argc, char** argv) {
 
         m.set_binning_policy(binning_policy, options.bin_dt);
 
-        // Attach samplers to all probes
-        std::vector<std::unique_ptr<sample_trace_type>> traces;
-        const time_type sample_dt = options.sample_dt;
-        for (auto probe: m.probes()) {
-            if (options.trace_max_gid && probe.id.gid>*options.trace_max_gid) {
-                continue;
-            }
-
-            traces.push_back(make_trace(probe));
-            m.attach_sampler(probe.id, make_trace_sampler(traces.back().get(), sample_dt));
-        }
-
         // Initialize the spike exporting interface
         std::unique_ptr<file_export_type> file_exporter;
         if (options.spike_file_output) {
@@ -153,8 +165,8 @@ int main(int argc, char** argv) {
 
         // save traces
         auto write_trace = options.trace_format=="json"? write_trace_json: write_trace_csv;
-        for (const auto& trace: traces) {
-            write_trace(*trace.get(), options.trace_prefix);
+        for (const auto& trace: sample_traces) {
+            write_trace(trace, options.trace_prefix);
         }
 
         auto report = util::make_meter_report(meters);
@@ -223,59 +235,25 @@ std::unique_ptr<recipe> make_recipe(const io::cl_options& options, const probe_d
     }
 }
 
-std::unique_ptr<sample_trace_type> make_trace(probe_record probe) {
+sample_trace make_trace(const probe_info& probe) {
     std::string name = "";
     std::string units = "";
 
-    switch (probe.kind) {
-    case probeKind::membrane_voltage:
+    auto addr = any_cast<cell_probe_address>(probe.address);
+    switch (addr.kind) {
+    case cell_probe_address::membrane_voltage:
         name = "v";
         units = "mV";
         break;
-    case probeKind::membrane_current:
+    case cell_probe_address::membrane_current:
         name = "i";
         units = "mA/cm²";
         break;
     default: ;
     }
-    name += probe.location.segment? "dend" : "soma";
-
-    return util::make_unique<sample_trace_type>(probe.id, name, units);
-}
-
-void write_trace_csv(const sample_trace_type& trace, const std::string& prefix) {
-    auto path = prefix + std::to_string(trace.probe_id.gid) +
-                "." + std::to_string(trace.probe_id.index) + "_" + trace.name + ".csv";
-
-    std::ofstream file(path);
-    file << "# cell: " << trace.probe_id.gid << "\n";
-    file << "# probe: " << trace.probe_id.index << "\n";
-    file << "time_ms, " << trace.name << "_" << trace.units << "\n";
-
-    for (const auto& sample: trace.samples) {
-        file << util::strprintf("% 20.15f, % 20.15f\n", sample.time, sample.value);
-    }
-}
-
-void write_trace_json(const sample_trace_type& trace, const std::string& prefix) {
-    auto path = prefix + std::to_string(trace.probe_id.gid) +
-                "." + std::to_string(trace.probe_id.index) + "_" + trace.name + ".json";
+    name += addr.location.segment? "dend" : "soma";
 
-    nlohmann::json jrep;
-    jrep["name"] = trace.name;
-    jrep["units"] = trace.units;
-    jrep["cell"] = trace.probe_id.gid;
-    jrep["probe"] = trace.probe_id.index;
-
-    auto& jt = jrep["data"]["time"];
-    auto& jy = jrep["data"][trace.name];
-
-    for (const auto& sample: trace.samples) {
-        jt.push_back(sample.time);
-        jy.push_back(sample.value);
-    }
-    std::ofstream file(path);
-    file << std::setw(1) << jrep << std::endl;
+    return sample_trace{probe.id, name, units};
 }
 
 void report_compartment_stats(const recipe& rec) {
@@ -287,7 +265,7 @@ void report_compartment_stats(const recipe& rec) {
     for (std::size_t i = 0; i<ncell; ++i) {
         std::size_t ncomp = 0;
         auto c = rec.get_cell_description(i);
-        if (auto ptr = util::any_cast<cell>(&c)) {
+        if (auto ptr = any_cast<cell>(&c)) {
             ncomp = ptr->num_compartments();
         }
         ncomp_total += ncomp;
diff --git a/miniapp/miniapp_recipes.cpp b/miniapp/miniapp_recipes.cpp
index 6fe6896d97a05d2ea9d4f3e39e150605e2471c53..a2039a7e19e65308e23a54a7167c1d1634cb4adc 100644
--- a/miniapp/miniapp_recipes.cpp
+++ b/miniapp/miniapp_recipes.cpp
@@ -17,7 +17,7 @@
 namespace nest {
 namespace mc {
 
-// TODO: split cell description into separate morphology, stimulus, probes, mechanisms etc.
+// TODO: split cell description into separate morphology, stimulus, mechanisms etc.
 // description for greater data reuse.
 
 template <typename RNG>
@@ -95,41 +95,56 @@ public:
                 return util::unique_any(dss_cell_description(spike_times));
             }
 
-            return util::unique_any(rss_cell::rss_cell_description(0.0, 0.1, 0.1));
+            return util::unique_any(rss_cell{0.0, 0.1, 0.1});
         }
 
         auto gen = std::mt19937(i); // TODO: replace this with hashing generator...
 
-        auto cc = get_cell_count_info(i);
         const auto& morph = get_morphology(i);
         unsigned cell_segments = morph.components();
 
-        auto cell = make_basic_cell(morph, param_.num_compartments, cc.num_targets,
+        auto cell = make_basic_cell(morph, param_.num_compartments, param_.num_synapses,
                         param_.synapse_type, gen);
 
         EXPECTS(cell.num_segments()==cell_segments);
-        EXPECTS(cell.probes().size()==0);
-        EXPECTS(cell.synapses().size()==cc.num_targets);
-        EXPECTS(cell.detectors().size()==cc.num_sources);
-
-        // add probes
-        if (cc.num_probes) {
-            unsigned n_probe_segs = pdist_.all_segments? cell_segments: 1u;
-            for (unsigned i = 0; i<n_probe_segs; ++i) {
-                if (pdist_.membrane_voltage) {
-                    cell.add_probe({{i, i? 0.5: 0.0}, mc::probeKind::membrane_voltage});
-                }
-                if (pdist_.membrane_current) {
-                    cell.add_probe({{i, i? 0.5: 0.0}, mc::probeKind::membrane_current});
-                }
-            }
-        }
-        EXPECTS(cell.probes().size()==cc.num_probes);
+        EXPECTS(cell.synapses().size()==num_targets(i));
+        EXPECTS(cell.detectors().size()==num_sources(i));
 
         return util::unique_any(std::move(cell));
     }
 
-    cell_kind get_cell_kind(cell_gid_type i ) const override {
+    probe_info get_probe(cell_member_type probe_id) const override {
+        if (probe_id.index>=num_probes(probe_id.gid)) {
+            throw invalid_recipe_error("invalid probe id");
+        }
+
+        // if we have both voltage and current probes, then order them
+        // voltage compartment 0, current compartment 0, voltage compartment 1, ...
+
+        cell_probe_address::probe_kind kind;
+
+        int stride = pdist_.membrane_voltage+pdist_.membrane_current;
+
+        if (stride==1) {
+            // Just one kind of probe.
+            kind = pdist_.membrane_voltage?
+                cell_probe_address::membrane_voltage: cell_probe_address::membrane_current;
+        }
+        else {
+            EXPECTS(stride==2);
+            // Both kinds available.
+            kind = (probe_id.index%stride==0)?
+                cell_probe_address::membrane_voltage: cell_probe_address::membrane_current;
+        }
+
+        cell_lid_type compartment = probe_id.index/stride;
+        segment_location loc{compartment, compartment? 0.5: 0.0};
+
+        // Use probe kind as the token to be passed to a sampler.
+        return {probe_id, (int)kind, cell_probe_address{loc, kind}};
+    }
+
+    cell_kind get_cell_kind(cell_gid_type i) const override {
         // The last 'cell' is a rss_cell with one spike at t=0
         if (i == ncell_) {
             if (param_.input_spike_path) {
@@ -141,21 +156,24 @@ public:
         return cell_kind::cable1d_neuron;
     }
 
-    cell_count_info get_cell_count_info(cell_gid_type i) const override {
-        cell_count_info cc = {1, param_.num_synapses, 0 };
-        unsigned cell_segments = get_morphology(i).components();
+    cell_size_type num_sources(cell_gid_type i) const override {
+        return 1;
+    }
 
-        // probe this cell?
-        if (std::floor(i*pdist_.proportion)!=std::floor((i-1.0)*pdist_.proportion)) {
-            std::size_t np = pdist_.membrane_voltage + pdist_.membrane_current;
-            if (pdist_.all_segments) {
-                np *= cell_segments;
-            }
+    cell_size_type num_targets(cell_gid_type i) const override {
+        return param_.num_synapses;
+    }
 
-            cc.num_probes = np;
-        }
+    cell_size_type num_probes(cell_gid_type i) const override {
+        bool has_probe = (std::floor(i*pdist_.proportion)!=std::floor((i-1.0)*pdist_.proportion));
 
-        return cc;
+        if (!has_probe) {
+            return 0;
+        }
+        else {
+            cell_size_type np = pdist_.all_segments? get_morphology(i).components(): 1;
+            return np*(pdist_.membrane_voltage+pdist_.membrane_current);
+        }
     }
 
 protected:
diff --git a/miniapp/trace.cpp b/miniapp/trace.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..32903c757e92904cdeb00da6f2dba6c8fe95f4a7
--- /dev/null
+++ b/miniapp/trace.cpp
@@ -0,0 +1,50 @@
+#include <fstream>
+#include <string>
+
+#include <json/json.hpp>
+
+#include <common_types.hpp>
+#include <util/strprintf.hpp>
+
+#include "trace.hpp"
+
+using namespace nest::mc;
+
+static std::string to_string(cell_member_type m) {
+    return std::to_string(m.gid)+"."+std::to_string(m.index);
+}
+
+void write_trace_csv(const sample_trace& trace, const std::string& prefix) {
+    auto path = prefix + to_string(trace.probe_id) + "_" + trace.name + ".csv";
+
+    std::ofstream file(path);
+    file << "# cell: " << trace.probe_id.gid << "\n";
+    file << "# probe: " << trace.probe_id.index << "\n";
+    file << "time_ms, " << trace.name << "_" << trace.units << "\n";
+
+    for (const auto& sample: trace.samples) {
+        file << util::strprintf("% 20.15f, % 20.15f\n", sample.t, sample.v);
+    }
+}
+
+void write_trace_json(const sample_trace& trace, const std::string& prefix) {
+    auto path = prefix + to_string(trace.probe_id) + "_" + trace.name + ".json";
+
+    nlohmann::json jrep;
+    jrep["name"] = trace.name;
+    jrep["units"] = trace.units;
+    jrep["cell"] = trace.probe_id.gid;
+    jrep["probe"] = trace.probe_id.index;
+
+    auto& jt = jrep["data"]["time"];
+    auto& jy = jrep["data"][trace.name];
+
+    for (const auto& sample: trace.samples) {
+        jt.push_back(sample.t);
+        jy.push_back(sample.v);
+    }
+
+    std::ofstream file(path);
+    file << std::setw(1) << jrep << "\n";
+}
+
diff --git a/miniapp/trace.hpp b/miniapp/trace.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..8bb5bf664001997c3eb9fb0c800a5d875f08e5ab
--- /dev/null
+++ b/miniapp/trace.hpp
@@ -0,0 +1,21 @@
+#pragma once
+
+/*
+ * Store trace data from samplers with metadata.
+ */
+
+#include <string>
+#include <vector>
+
+#include <common_types.hpp>
+#include <simple_sampler.hpp>
+
+struct sample_trace {
+    nest::mc::cell_member_type probe_id;
+    std::string name;
+    std::string units;
+    nest::mc::trace_data<double> samples;
+};
+
+void write_trace_csv(const sample_trace& trace, const std::string& prefix);
+void write_trace_json(const sample_trace& trace, const std::string& prefix);
diff --git a/miniapp/trace_sampler.hpp b/miniapp/trace_sampler.hpp
deleted file mode 100644
index 6bdc51a5bbf6f1c10bac2b8b5c5748faca888c18..0000000000000000000000000000000000000000
--- a/miniapp/trace_sampler.hpp
+++ /dev/null
@@ -1,75 +0,0 @@
-#pragma once
-
-/*
- * Simple(st?) implementation of a recorder of scalar
- * trace data from a cell probe, with some metadata.
- */
-
-#include <cstdlib>
-#include <vector>
-
-#include <common_types.hpp>
-#include <cell.hpp>
-#include <util/optional.hpp>
-
-#include <iostream>
-
-namespace nest {
-namespace mc {
-
-template <typename Time=float, typename Value=double>
-struct sample_trace {
-    using time_type = Time;
-    using value_type = Value;
-
-    struct sample_type {
-        time_type time;
-        value_type value;
-    };
-
-    std::string name;
-    std::string units;
-    cell_member_type probe_id;
-    std::vector<sample_type> samples;
-
-    sample_trace() =default;
-    sample_trace(cell_member_type probe_id, const std::string& name, const std::string& units):
-        name(name), units(units), probe_id(probe_id)
-    {}
-};
-
-template <typename Time=float, typename Value=double>
-struct trace_sampler {
-    using time_type = Time;
-    using value_type = Value;
-
-    time_type next_sample_t() const { return t_next_sample_; }
-
-    util::optional<time_type> operator()(time_type t, value_type v) {
-        if (t<t_next_sample_) {
-            return t_next_sample_;
-        }
-
-        trace_->samples.push_back({t,v});
-        return t_next_sample_+=sample_dt_;
-    }
-
-    trace_sampler(sample_trace<time_type, value_type> *trace, time_type sample_dt, time_type tfrom=0):
-       trace_(trace), sample_dt_(sample_dt), t_next_sample_(tfrom)
-    {}
-
-private:
-    sample_trace<time_type, value_type> *trace_;
-
-    time_type sample_dt_;
-    time_type t_next_sample_;
-};
-
-// with type deduction ...
-template <typename Time, typename Value>
-trace_sampler<Time, Value> make_trace_sampler(sample_trace<Time, Value> *trace, Time sample_dt, Time tfrom=0) {
-    return trace_sampler<Time, Value>(trace, sample_dt, tfrom);
-}
-
-} // namespace mc
-} // namespace nest
diff --git a/scripts/tsplot b/scripts/tsplot
index 64186c90f0c529786b136743ff7216498146a2f6..390b5bfd266a35afa657a164875c6445a6b66055 100755
--- a/scripts/tsplot
+++ b/scripts/tsplot
@@ -57,6 +57,8 @@ def parse_clargs():
                    help='use colour COLOUR a base for series matching EXPR')
     P.add_argument('-o', '--output', metavar='FILE', dest='outfile',
                    help='save plot to file FILE')
+    P.add_argument('-l', '--list', action='store_true',
+                   help='list selected time-series')
     P.add_argument('--dpi', metavar='NUM', dest='dpi',
                    type=int,
                    help='set dpi for output image')
@@ -446,19 +448,28 @@ for filename in args.inputs:
         j = json.load(f)
         tss.extend(read_json_timeseries(j, axis, select))
 
-if args.trange:
+if args.list:
     for ts in tss:
-        ts.trestrict(args.trange)
+        print 'name:', ts.meta['name']
+        print 'label:', ts.meta['label']
+        for k in [x for x in sorted(ts.meta.keys()) if x not in ['name', 'label']]:
+            print k+':', ts.meta[k]
+        print
 
-if args.exclude:
-    for ts in tss:
-        ts.exclude_outliers(args.exclude)
+else:
+    if args.trange:
+        for ts in tss:
+            ts.trestrict(args.trange)
+
+    if args.exclude:
+        for ts in tss:
+            ts.exclude_outliers(args.exclude)
 
-groupby = args.groupby if args.groupby else []
-plots = gather_ts_plots(tss, groupby)
+    groupby = args.groupby if args.groupby else []
+    plots = gather_ts_plots(tss, groupby)
 
-if not args.outfile:
-    M.interactive(False)
+    if not args.outfile:
+        M.interactive(False)
 
-colours = args.colours if args.colours else []
-plot_plots(plots, axis=axis, colour_overrides=colours, save=args.outfile, dpi=args.dpi, scale=args.scale)
+    colours = args.colours if args.colours else []
+    plot_plots(plots, axis=axis, colour_overrides=colours, save=args.outfile, dpi=args.dpi, scale=args.scale)
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index d189142b6d485c33290f65b478348eed127668a4..8429bac998cf01963b596fb50bda7a0efd70d101 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -16,6 +16,7 @@ set(BASE_SOURCES
     profiling/meter_manager.cpp
     profiling/power_meter.cpp
     profiling/profiler.cpp
+    schedule.cpp
     swcio.cpp
     threading/threading.cpp
     util/debug.cpp
diff --git a/src/cell.hpp b/src/cell.hpp
index fbcbe5047b4c636f16105ed5c71dc0490482884c..40139722691bce880508dcd59bcdb730532dc58a 100644
--- a/src/cell.hpp
+++ b/src/cell.hpp
@@ -8,7 +8,6 @@
 #include <common_types.hpp>
 #include <cell_tree.hpp>
 #include <morphology.hpp>
-#include <probes.hpp>
 #include <segment.hpp>
 #include <stimulus.hpp>
 #include <util/debug.hpp>
@@ -31,9 +30,13 @@ int find_compartment_index(
     compartment_model const& graph
 );
 
-struct probe_spec {
+struct cell_probe_address {
+    enum probe_kind {
+        membrane_voltage, membrane_current
+    };
+
     segment_location location;
-    probeKind kind;
+    probe_kind kind;
 };
 
 // used in constructor below
@@ -66,14 +69,12 @@ public:
     // constructor
     cell();
 
-    // sometimes we really do want a copy (pending big morphology
-    // refactor)
+    // Sometimes we really do want a copy (pending big morphology refactor).
     cell(clone_cell_t, const cell& other):
         parents_(other.parents_),
         stimuli_(other.stimuli_),
         synapses_(other.synapses_),
-        spike_detectors_(other.spike_detectors_),
-        probes_(other.probes_)
+        spike_detectors_(other.spike_detectors_)
      {
          // unique_ptr's cannot be copy constructed, do a manual assignment
          segments_.reserve(other.segments_.size());
@@ -181,19 +182,6 @@ public:
         return spike_detectors_;
     }
 
-    //////////////////
-    // probes
-    //////////////////
-    index_type add_probe(probe_spec p) {
-        probes_.push_back(p);
-        return probes_.size()-1;
-    }
-
-    const std::vector<probe_spec>&
-    probes() const {
-        return probes_;
-    }
-
 private:
     // storage for connections
     std::vector<index_type> parents_;
@@ -209,9 +197,6 @@ private:
 
     // the sensors
     std::vector<detector_instance> spike_detectors_;
-
-    // the probes
-    std::vector<probe_spec> probes_;
 };
 
 // Checks that two cells have the same
diff --git a/src/cell_group.hpp b/src/cell_group.hpp
index 1473411bc2563a37c157a07adcf0f812a24fd2c7..7c9bdd3c449cc97a90f7bc917fbbe775859787b2 100644
--- a/src/cell_group.hpp
+++ b/src/cell_group.hpp
@@ -7,8 +7,8 @@
 #include <common_types.hpp>
 #include <event_binner.hpp>
 #include <event_queue.hpp>
-#include <probes.hpp>
-#include <sampler_function.hpp>
+#include <sampling.hpp>
+#include <schedule.hpp>
 #include <spike.hpp>
 
 namespace nest {
@@ -26,8 +26,13 @@ public:
     virtual void enqueue_events(const std::vector<postsynaptic_spike_event>& events) = 0;
     virtual const std::vector<spike>& spikes() const = 0;
     virtual void clear_spikes() = 0;
-    virtual void add_sampler(cell_member_type probe_id, sampler_function s, time_type start_time = 0) = 0;
-    virtual std::vector<probe_record> probes() const = 0;
+
+    // Sampler association methods below should be thread-safe, as they might be invoked
+    // from a sampler call back called from a different cell group running on a different thread.
+
+    virtual void add_sampler(sampler_association_handle, cell_member_predicate, schedule, sampler_function, sampling_policy) = 0;
+    virtual void remove_sampler(sampler_association_handle) = 0;
+    virtual void remove_all_samplers() = 0;
 };
 
 using cell_group_ptr = std::unique_ptr<cell_group>;
diff --git a/src/cell_group_factory.cpp b/src/cell_group_factory.cpp
index 857aa26035dc1f5a9ebc477c2f27968a62e95eae..e3cf5b7632d1906ae2729844a6e36ea52f47e5c4 100644
--- a/src/cell_group_factory.cpp
+++ b/src/cell_group_factory.cpp
@@ -17,28 +17,20 @@ using gpu_fvm_cell = mc_cell_group<fvm::fvm_multicell<gpu::backend>>;
 using mc_fvm_cell = mc_cell_group<fvm::fvm_multicell<multicore::backend>>;
 
 cell_group_ptr cell_group_factory(const recipe& rec, const group_description& group) {
-    // Make a list of all the cell descriptions to be forwarded
-    // to the appropriate cell_group constructor.
-    std::vector<util::unique_any> descriptions;
-    descriptions.reserve(group.gids.size());
-    for (auto gid: group.gids) {
-        descriptions.push_back(rec.get_cell_description(gid));
-    }
-
     switch (group.kind) {
     case cell_kind::cable1d_neuron:
         if (group.backend == backend_kind::gpu) {
-            return make_cell_group<gpu_fvm_cell>(group.gids, descriptions);
+            return make_cell_group<gpu_fvm_cell>(group.gids, rec);
         }
         else {
-            return make_cell_group<mc_fvm_cell>(group.gids, descriptions);
+            return make_cell_group<mc_fvm_cell>(group.gids, rec);
         }
 
     case cell_kind::regular_spike_source:
-        return make_cell_group<rss_cell_group>(group.gids, descriptions);
+        return make_cell_group<rss_cell_group>(group.gids, rec);
 
     case cell_kind::data_spike_source:
-        return make_cell_group<dss_cell_group>(group.gids, descriptions);
+        return make_cell_group<dss_cell_group>(group.gids, rec);
 
     default:
         throw std::runtime_error("unknown cell kind");
diff --git a/src/common_types.hpp b/src/common_types.hpp
index 74bb33f6c6bf55c13de90e813c3cb7fe9bccc7c0..9a4530a82835873599791c2c80bc3f12c1ae2ca3 100644
--- a/src/common_types.hpp
+++ b/src/common_types.hpp
@@ -5,6 +5,8 @@
  * library. (Expect analogues in future versions to be template parameters?)
  */
 
+#include <cstddef>
+#include <functional>
 #include <iosfwd>
 #include <type_traits>
 
@@ -53,6 +55,10 @@ DEFINE_LEXICOGRAPHIC_ORDERING(cell_member_type,(a.gid,a.index),(b.gid,b.index))
 
 using time_type = float;
 
+// Extra contextual information associated with a probe.
+
+using probe_tag = int;
+
 // Enumeration used to indentify the cell type/kind, used by the model to
 // group equal kinds in the same cell group.
 
@@ -66,3 +72,16 @@ enum cell_kind {
 } // namespace nest
 
 std::ostream& operator<<(std::ostream& O, nest::mc::cell_member_type m);
+
+namespace std {
+    template <> struct hash<nest::mc::cell_member_type> {
+        std::size_t operator()(const nest::mc::cell_member_type& m) const {
+            using namespace nest::mc;
+            static_assert(sizeof(std::size_t)>sizeof(cell_gid_type), "invalid size assumptions for hash of cell_member_type");
+
+            std::size_t k = ((std::size_t)m.gid << (8*sizeof(cell_gid_type))) + m.index;
+            return std::hash<std::size_t>{}(k);
+        }
+    };
+}
+
diff --git a/src/dss_cell_description.hpp b/src/dss_cell_description.hpp
index 1fddebb75867071e916ed9b8141ea07c94091f41..bfb9fd68e2668b7aaf842dc88b68f3ea3c81f2ed 100644
--- a/src/dss_cell_description.hpp
+++ b/src/dss_cell_description.hpp
@@ -1,25 +1,23 @@
 #pragma once
-#pragma once
 
 #include <vector>
 
 #include <common_types.hpp>
-#include <util/debug.hpp>
 
 namespace nest {
 namespace mc {
 
-/// Description for a data spike source: A cell that generates spikes provided as a vector of
-/// floating point valued spiketimes at the start of a run
+/// Description for a data spike source: a cell that generates spikes provided as a vector of
+/// spike times at the start of a run.
+
 struct dss_cell_description {
     std::vector<time_type> spike_times;
 
     /// The description needs a vector of doubles for the description
-    dss_cell_description(std::vector<time_type> &  spike_times) :
-        spike_times(spike_times)
+    dss_cell_description(std::vector<time_type> spike_times):
+        spike_times(std::move(spike_times))
     {}
 };
 
-
 } // namespace mc
 } // namespace nest
diff --git a/src/dss_cell_group.hpp b/src/dss_cell_group.hpp
index d51a4e1f7bd847239a2682b8e7de46dd250f56d3..5f3e32d68548c453d65fae9ce2372826797c480d 100644
--- a/src/dss_cell_group.hpp
+++ b/src/dss_cell_group.hpp
@@ -2,6 +2,7 @@
 
 #include <cell_group.hpp>
 #include <dss_cell_description.hpp>
+#include <recipe.hpp>
 #include <util/span.hpp>
 #include <util/unique_any.hpp>
 
@@ -11,24 +12,21 @@ namespace mc {
 /// Cell_group to collect spike sources
 class dss_cell_group: public cell_group {
 public:
-    dss_cell_group(std::vector<cell_gid_type> gids,
-                   const std::vector<util::unique_any>& cell_descriptions):
+    dss_cell_group(std::vector<cell_gid_type> gids, const recipe& rec):
         gids_(std::move(gids))
     {
-        using util::make_span;
-        for (cell_gid_type i: make_span(0, cell_descriptions.size())) {
+        for (auto gid: gids_) {
+            auto desc = util::any_cast<dss_cell_description>(rec.get_cell_description(gid));
             // store spike times from description
-            auto times = util::any_cast<dss_cell_description>(cell_descriptions[i]).spike_times;
+            auto times = desc.spike_times;
             util::sort(times);
             spike_times_.push_back(std::move(times));
 
             // Take a reference to the first spike time
-            not_emit_it_.push_back(spike_times_[i].begin());
+            not_emit_it_.push_back(spike_times_.back().begin());
         }
     }
 
-    virtual ~dss_cell_group() = default;
-
     cell_kind get_cell_kind() const override {
         return cell_kind::data_spike_source;
     }
@@ -45,8 +43,7 @@ public:
         clear_spikes();
     }
 
-    void set_binning_policy(binning_kind policy, time_type bin_interval) override
-    {}
+    void set_binning_policy(binning_kind policy, time_type bin_interval) override {}
 
     void advance(time_type tfinal, time_type dt) override {
         for (auto i: util::make_span(0, not_emit_it_.size())) {
@@ -78,14 +75,14 @@ public:
         spikes_.clear();
     }
 
-    std::vector<probe_record> probes() const override {
-        return probes_;
-    }
-
-    void add_sampler(cell_member_type probe_id, sampler_function s, time_type start_time = 0) override {
+    void add_sampler(sampler_association_handle h, cell_member_predicate probe_ids, schedule sched, sampler_function fn, sampling_policy policy) override {
         std::logic_error("The dss_cells do not support sampling of internal state!");
     }
 
+    void remove_sampler(sampler_association_handle h) override {}
+
+    void remove_all_samplers() override {}
+
 private:
     // Spikes that are generated.
     std::vector<spike> spikes_;
@@ -93,8 +90,6 @@ private:
     // Map of local index to gid
     std::vector<cell_gid_type> gids_;
 
-    std::vector<probe_record> probes_;
-
     // The dss_cell is simple: Put all logic in the cellgroup cause accelerator support
     // is not expected. We need storage for the cell state
 
diff --git a/src/fvm_multicell.hpp b/src/fvm_multicell.hpp
index 571b600d409b75617e272fff1ca83e8cb7c58aef..37aba8de4ff4805bddb2f3417088f7a2834a3cad 100644
--- a/src/fvm_multicell.hpp
+++ b/src/fvm_multicell.hpp
@@ -16,6 +16,8 @@
 #include <matrix.hpp>
 #include <memory/memory.hpp>
 #include <profiling/profiler.hpp>
+#include <recipe.hpp>
+#include <sampler_map.hpp>
 #include <segment.hpp>
 #include <stimulus.hpp>
 #include <util/meta.hpp>
@@ -69,11 +71,16 @@ public:
         resting_potential_ = potential_mV;
     }
 
-    template <typename Cells, typename Targets, typename Probes>
+    // Set up data structures for a fixed collection of cells identified by `gids`
+    // with descriptions taken from the recipe `rec`.
+    //
+    // Lowered-cell specific handles for targets and probes are stored in the
+    // caller-provided vector `target_handles` and map `probe_map`.
     void initialize(
-        const Cells& cells,           // collection of nest::mc::cell descriptions
-        Targets& target_handles,      // (write) where to store target handles
-        Probes& probe_handles);       // (write) where to store probe handles
+        const std::vector<cell_gid_type>& gids,
+        const recipe& rec,
+        std::vector<target_handle>& target_handles,
+        probe_association_map<probe_handle>& probe_map);
 
     void reset();
 
@@ -226,8 +233,6 @@ public:
         return it==mechanisms_.end() ? util::nothing: util::just(*it);
     }
 
-    std::size_t num_probes() const { return probes_.size(); }
-
     //
     // Threshold crossing interface.
     // Used by calling code to perform spike detection
@@ -342,8 +347,6 @@ private:
     /// the ion species
     std::map<mechanisms::ionKind, ion> ions_;
 
-    std::vector<std::pair<const array fvm_multicell::*, size_type>> probes_;
-
     /// Compact representation of the control volumes into which a segment is
     /// decomposed. Used to reconstruct the weights used to convert current
     /// densities to currents for density channels.
@@ -520,13 +523,14 @@ fvm_multicell<Backend>::compute_cv_area_capacitance(
 }
 
 template <typename Backend>
-template <typename Cells, typename Targets, typename Probes>
 void fvm_multicell<Backend>::initialize(
-    const Cells& cells,
-    Targets& target_handles,
-    Probes& probe_handles)
+    const std::vector<cell_gid_type>& gids,
+    const recipe& rec,
+    std::vector<target_handle>& target_handles,
+    probe_association_map<probe_handle>& probe_map)
 {
     using memory::make_const_view;
+    using util::any_cast;
     using util::assign_by;
     using util::make_partition;
     using util::make_span;
@@ -535,13 +539,19 @@ void fvm_multicell<Backend>::initialize(
     using util::transform_view;
     using util::subrange_view;
 
-    // count total targets and probes for validation of handle container sizes
+    ncell_ = size(gids);
     std::size_t targets_count = 0u;
-    std::size_t probes_count = 0u;
-    auto targets_size = size(target_handles);
-    auto probes_size = size(probe_handles);
 
-    ncell_ = size(cells);
+    // Take cell descriptions from recipe. These are used initially
+    // to count compartments for allocation of data structures, and
+    // then interrogated again for the details for each cell in turn.
+
+    std::vector<cell> cells;
+    cells.reserve(gids.size());
+    for (auto gid: gids) {
+        cells.push_back(any_cast<cell>(rec.get_cell_description(gid)));
+    }
+
     auto cell_num_compartments =
         transform_view(cells, [](const cell& c) { return c.num_compartments(); });
 
@@ -583,8 +593,7 @@ void fvm_multicell<Backend>::initialize(
     // setup per-cell event stores.
     events_ = util::make_unique<multi_event_stream>(ncell_);
 
-    // create each cell:
-    auto probe_hi = probe_handles.begin();
+    // Create each cell:
 
     // Allocate scratch storage for calculating quantities used to build the
     // linear system: these will later be copied into target-specific storage
@@ -611,6 +620,7 @@ void fvm_multicell<Backend>::initialize(
     //  - each probe, stimulus and detector is attached to its compartment.
     for (auto i: make_span(0, ncell_)) {
         const auto& c = cells[i];
+        auto gid = gids[i];
         auto comp_ival = cell_comp_part[i];
 
         auto graph = c.model();
@@ -643,8 +653,6 @@ void fvm_multicell<Backend>::initialize(
         }
 
         for (const auto& syn: c.synapses()) {
-            EXPECTS(targets_count < targets_size);
-
             const auto& name = syn.mechanism.name();
             auto& map_entry = syn_mech_map[name];
 
@@ -693,22 +701,26 @@ void fvm_multicell<Backend>::initialize(
             thresholds.push_back(detector.threshold);
         }
 
-        // record probe locations by index into corresponding state vector
-        for (const auto& probe: c.probes()) {
-            EXPECTS(probes_count < probes_size);
+        // Retrieve probe addresses and tags from recipe for this cell.
+        for (cell_lid_type j: make_span(0, rec.num_probes(gid))) {
+            probe_info pi = rec.get_probe({gid, j});
+            auto where = any_cast<cell_probe_address>(pi.address);
+
+            auto comp = comp_ival.first+find_cv_index(where.location, graph);
+            probe_handle handle;
 
-            auto comp = comp_ival.first+find_cv_index(probe.location, graph);
-            switch (probe.kind) {
-            case probeKind::membrane_voltage:
-                *probe_hi++ = {&fvm_multicell::voltage_, comp};
+            switch (where.kind) {
+            case cell_probe_address::membrane_voltage:
+                handle = {&fvm_multicell::voltage_, comp};
                 break;
-            case probeKind::membrane_current:
-                *probe_hi++ = {&fvm_multicell::current_, comp};
+            case cell_probe_address::membrane_current:
+                handle = {&fvm_multicell::current_, comp};
                 break;
             default:
                 throw std::logic_error("unrecognized probeKind");
             }
-            ++probes_count;
+
+            probe_map.insert({pi.id, {handle, pi.tag}});
         }
     }
 
@@ -716,9 +728,6 @@ void fvm_multicell<Backend>::initialize(
     threshold_watcher_ =
         threshold_watcher(cv_to_cell_, time_, time_to_, voltage_, spike_detector_index, thresholds);
 
-    // confirm user-supplied container probes were appropriately sized.
-    EXPECTS(probes_size==probes_count);
-
     // store the geometric information in target-specific containers
     cv_areas_ = make_const_view(tmp_cv_areas);
 
@@ -778,6 +787,8 @@ void fvm_multicell<Backend>::initialize(
         mech_to_cv_index[mech_name] = mech_cv_index;
     }
 
+    target_handles.resize(targets_count);
+
     // Create point (synapse) mechanisms.
     for (auto& syni: syn_mech_map) {
         size_type mech_id = mechanisms_.size();
@@ -806,9 +817,6 @@ void fvm_multicell<Backend>::initialize(
         }
     }
 
-    // confirm user-supplied containers for targets are appropriately sized
-    EXPECTS(targets_size==targets_count);
-
     // build the ion species
     for (auto ion : mechanisms::ion_kinds()) {
         // find the compartment indexes of all compartments that have a
diff --git a/src/mc_cell_group.hpp b/src/mc_cell_group.hpp
index 4e25d15c88a3768caa62d1efa2d9bdae3241bb0a..5fa97cc2e6632ab1069ea0c0a3154e0c1d7a688d 100644
--- a/src/mc_cell_group.hpp
+++ b/src/mc_cell_group.hpp
@@ -13,9 +13,11 @@
 #include <event_binner.hpp>
 #include <event_queue.hpp>
 #include <recipe.hpp>
-#include <sampler_function.hpp>
+#include <sampler_map.hpp>
+#include <sampling.hpp>
 #include <spike.hpp>
 #include <util/debug.hpp>
+#include <util/filter.hpp>
 #include <util/partition.hpp>
 #include <util/range.hpp>
 #include <util/unique_any.hpp>
@@ -31,69 +33,38 @@ public:
     using lowered_cell_type = LoweredCell;
     using value_type = typename lowered_cell_type::value_type;
     using size_type  = typename lowered_cell_type::value_type;
-    using source_id_type = cell_member_type;
 
     mc_cell_group() = default;
 
-    template <typename Cells>
-    mc_cell_group(std::vector<cell_gid_type> gids, const Cells& cell_descriptions):
+    mc_cell_group(std::vector<cell_gid_type> gids, const recipe& rec):
         gids_(std::move(gids))
     {
         // Build lookup table for gid to local index.
         for (auto i: util::make_span(0, gids_.size())) {
-            gid2lid_[gids_[i]] = i;
+            gid_index_map_[gids_[i]] = i;
         }
 
-        // Create lookup structure for probe and target ids.
-        build_handle_partitions(cell_descriptions);
-        std::size_t n_probes = probe_handle_divisions_.back();
+        // Create lookup structure for target ids.
+        build_target_handle_partition(rec);
         std::size_t n_targets = target_handle_divisions_.back();
-        std::size_t n_detectors = algorithms::sum(util::transform_view(
-            cell_descriptions, [](const cell& c) { return c.detectors().size(); }));
 
-        // Allocate space to store handles.
-        target_handles_.resize(n_targets);
-        probe_handles_.resize(n_probes);
+        // Pre-allocate space to store handles, probe map.
+        auto n_probes = util::sum_by(gids_, [&rec](cell_gid_type i) { return rec.num_probes(i); });
+        probe_map_.reserve(n_probes);
+        target_handles_.reserve(n_targets);
 
-        lowered_.initialize(cell_descriptions, target_handles_, probe_handles_);
+        // Construct cell implementation, retrieving handles and maps. 
+        lowered_.initialize(gids_, rec, target_handles_, probe_map_);
 
-        // Create a list of the global identifiers for the spike sources.
-        auto source_gid = gids_.begin();
-        for (const auto& cell: cell_descriptions) {
-            for (cell_lid_type lid=0u; lid<cell.detectors().size(); ++lid) {
-                spike_sources_.push_back(source_id_type{*source_gid, lid});
+        // Create a list of the global identifiers for the spike sources
+        for (auto source_gid: gids_) {
+            for (cell_lid_type lid = 0; lid<rec.num_sources(source_gid); ++lid) {
+                spike_sources_.push_back({source_gid, lid});
             }
-            ++source_gid;
         }
-        EXPECTS(spike_sources_.size()==n_detectors);
-
-        // Create the enumeration of probes attached to cells in this cell group.
-        probes_.reserve(n_probes);
-        for (auto i: util::make_span(0, cell_descriptions.size())){
-            const cell_gid_type probe_gid = gids_[i];
-            const auto probes_on_cell = cell_descriptions[i].probes();
-            for (cell_lid_type lid: util::make_span(0, probes_on_cell.size())) {
-                // Get the unique global identifier of this probe.
-                cell_member_type id{probe_gid, lid};
-
-                // Get the location and kind information of the probe.
-                const auto p = probes_on_cell[lid];
-
-                // Record the combined identifier and probe details.
-                probes_.push_back(probe_record{id, p.location, p.kind});
-            }
-        }
-    }
 
-    mc_cell_group(std::vector<cell_gid_type> gids,
-                  const std::vector<util::unique_any>& cell_descriptions):
-        mc_cell_group(
-            std::move(gids),
-            util::transform_view(cell_descriptions,
-                [](const util::unique_any& c) -> const cell& {
-                    return util::any_cast<const cell&>(c);
-                }))
-    {}
+        spike_sources_.shrink_to_fit();
+    }
 
     cell_kind get_cell_kind() const override {
         return cell_kind::cable1d_neuron;
@@ -113,6 +84,7 @@ public:
 
     void advance(time_type tfinal, time_type dt) override {
         EXPECTS(lowered_.state_synchronized());
+        time_type tstart = lowered_.min_time();
 
         // Bin pending events and enqueue on lowered state.
         time_type ev_min_time = lowered_.max_time(); // (but we're synchronized here)
@@ -124,6 +96,34 @@ public:
 
         lowered_.setup_integration(tfinal, dt);
 
+        // Set up sample event queue.
+        struct sampler_entry {
+            typename lowered_cell_type::probe_handle handle;
+            probe_tag tag;
+            cell_member_type probe_id;
+            sampler_function sampler;
+        };
+        std::vector<sampler_entry> samplers;
+
+        for (auto &assoc: sampler_map_) {
+            auto ts = assoc.sched.events(tstart, tfinal);
+            if (ts.empty()) {
+                continue;
+            }
+
+            for (auto p: assoc.probe_ids) {
+                EXPECTS(probe_map_.count(p)>0);
+                sample_event::size_type idx = samplers.size();
+                auto pinfo = probe_map_[p];
+
+                samplers.push_back({pinfo.handle, pinfo.tag, p, assoc.sampler});
+
+                for (auto t: ts) {
+                    sample_events_.push({idx, t});
+                }
+            }
+        }
+
         util::optional<time_type> first_sample_time = sample_events_.time_if_before(tfinal);
         std::vector<sample_event> requeue_sample_events;
         while (!lowered_.integration_complete()) {
@@ -136,20 +136,18 @@ public:
 
                 requeue_sample_events.clear();
                 while (auto m = sample_events_.pop_if_before(cell_max_time)) {
-                    auto& s = samplers_[m->sampler_index];
+                    auto& s = samplers[m->sampler_index];
                     EXPECTS((bool)s.sampler);
 
-                    time_type cell_time = lowered_.time(gid2lid(s.cell_gid));
+                    time_type cell_time = lowered_.time(gid_to_index(s.probe_id.gid));
                     if (cell_time<m->time) {
                         // This cell hasn't reached this sample time yet.
                         requeue_sample_events.push_back(*m);
                     }
                     else {
-                        auto next = s.sampler(cell_time, lowered_.probe(s.handle));
-                        if (next) {
-                            m->time = std::max(*next, cell_time);
-                            requeue_sample_events.push_back(*m);
-                        }
+                        const double value = lowered_.probe(s.handle);
+                        sample_record record = {cell_time, &value};
+                        s.sampler(s.probe_id, s.tag, 1u, &record);
                     }
                 }
                 for (auto& ev: requeue_sample_events) {
@@ -201,36 +199,41 @@ public:
         spikes_.clear();
     }
 
-    const std::vector<source_id_type>& spike_sources() const {
+    const std::vector<cell_member_type>& spike_sources() const {
         return spike_sources_;
     }
 
-    void add_sampler(cell_member_type probe_id, sampler_function s, time_type start_time) override {
-        auto handle = get_probe_handle(probe_id);
+    void add_sampler(sampler_association_handle h, cell_member_predicate probe_ids,
+                     schedule sched, sampler_function fn, sampling_policy policy) override
+    {
+        std::vector<cell_member_type> probeset =
+            util::assign_from(util::filter(util::keys(probe_map_), probe_ids));
+
+        if (!probeset.empty()) {
+            sampler_map_.add(h, sampler_association{std::move(sched), std::move(fn), std::move(probeset)});
+        }
+    }
 
-        using size_type = sample_event::size_type;
-        auto sampler_index = size_type(samplers_.size());
-        samplers_.push_back({handle, probe_id.gid, s});
-        sampler_start_times_.push_back(start_time);
-        sample_events_.push({sampler_index, start_time});
+    void remove_sampler(sampler_association_handle h) override {
+        sampler_map_.remove(h);
     }
 
-    std::vector<probe_record> probes() const override {
-        return probes_;
+    void remove_all_samplers() override {
+        sampler_map_.clear();
     }
 
 private:
-    // List of the gids of the cells in the group
+    // List of the gids of the cells in the group.
     std::vector<cell_gid_type> gids_;
 
     // Hash table for converting gid to local index
-    std::unordered_map<cell_gid_type, cell_gid_type> gid2lid_;
+    std::unordered_map<cell_gid_type, cell_gid_type> gid_index_map_;
 
     // The lowered cell state (e.g. FVM) of the cell.
     lowered_cell_type lowered_;
 
     // Spike detectors attached to the cell.
-    std::vector<source_id_type> spike_sources_;
+    std::vector<cell_member_type> spike_sources_;
 
     // Spikes that are generated.
     std::vector<spike> spikes_;
@@ -243,73 +246,43 @@ private:
 
     // Pending samples to be taken.
     event_queue<sample_event> sample_events_;
-    std::vector<time_type> sampler_start_times_;
 
     // Handles for accessing lowered cell.
     using target_handle = typename lowered_cell_type::target_handle;
     std::vector<target_handle> target_handles_;
 
+    // Maps probe ids to probe handles (from lowered cell) and tags (from probe descriptions).
     using probe_handle = typename lowered_cell_type::probe_handle;
-    std::vector<probe_handle> probe_handles_;
-
-    struct sampler_entry {
-        typename lowered_cell_type::probe_handle handle;
-        cell_gid_type cell_gid;
-        sampler_function sampler;
-    };
+    probe_association_map<probe_handle> probe_map_;
 
     // Collection of samplers to be run against probes in this group.
-    std::vector<sampler_entry> samplers_;
-
-    // Lookup table for probe ids -> local probe handle indices.
-    std::vector<std::size_t> probe_handle_divisions_;
+    sampler_association_map sampler_map_;
 
     // Lookup table for target ids -> local target handle indices.
     std::vector<std::size_t> target_handle_divisions_;
 
-    // Enumeration of the probes that are attached to the cells in the cell group
-    std::vector<probe_record> probes_;
-
     // Build handle index lookup tables.
-    template <typename Cells>
-    void build_handle_partitions(const Cells& cells) {
-        auto probe_counts =
-            util::transform_view(cells, [](const cell& c) { return c.probes().size(); });
-        auto target_counts =
-            util::transform_view(cells, [](const cell& c) { return c.synapses().size(); });
-
-        make_partition(probe_handle_divisions_, probe_counts);
-        make_partition(target_handle_divisions_, target_counts);
-    }
-
-    // Use handle partition to get index from id.
-    template <typename Divisions>
-    std::size_t handle_partition_lookup(const Divisions& divisions, cell_member_type id) const {
-        return divisions[gid2lid(id.gid)]+id.index;
-    }
-
-    // Get probe handle from probe id.
-    probe_handle get_probe_handle(cell_member_type probe_id) const {
-        return probe_handles_[handle_partition_lookup(probe_handle_divisions_, probe_id)];
+    void build_target_handle_partition(const recipe& rec) {
+        util::make_partition(target_handle_divisions_,
+            util::transform_view(gids_, [&rec](cell_gid_type i) { return rec.num_targets(i); }));
     }
 
     // Get target handle from target id.
-    target_handle get_target_handle(cell_member_type target_id) const {
-        return target_handles_[handle_partition_lookup(target_handle_divisions_, target_id)];
+    target_handle get_target_handle(cell_member_type id) const {
+        return target_handles_[target_handle_divisions_[gid_to_index(id.gid)]+id.index];
     }
 
     void reset_samplers() {
         // clear all pending sample events and reset to start at time 0
         sample_events_.clear();
-        using size_type = sample_event::size_type;
-        for(size_type i=0; i<samplers_.size(); ++i) {
-            sample_events_.push({i, sampler_start_times_[i]});
+        for (auto &assoc: sampler_map_) {
+            assoc.sched.reset();
         }
     }
 
-    cell_gid_type gid2lid(cell_gid_type gid) const {
-        auto it = gid2lid_.find(gid);
-        EXPECTS(it!=gid2lid_.end());
+    cell_gid_type gid_to_index(cell_gid_type gid) const {
+        auto it = gid_index_map_.find(gid);
+        EXPECTS(it!=gid_index_map_.end());
         return it->second;
     }
 };
diff --git a/src/model.cpp b/src/model.cpp
index 0e84a1b8a8137f012fdf6995b5b39cca99daafe3..b74bdf6a6d57db3a4dbc9a5234e86b2b672a565a 100644
--- a/src/model.cpp
+++ b/src/model.cpp
@@ -1,11 +1,10 @@
-#include <model.hpp>
-
 #include <vector>
 
 #include <backends.hpp>
 #include <cell_group.hpp>
 #include <cell_group_factory.hpp>
 #include <domain_decomposition.hpp>
+#include <model.hpp>
 #include <recipe.hpp>
 #include <util/span.hpp>
 #include <util/unique_any.hpp>
@@ -32,11 +31,6 @@ model::model(const recipe& rec, const domain_decomposition& decomp):
             PL(2);
         });
 
-    // Store probes.
-    for (const auto& c: cell_groups_) {
-        util::append(probes_, c->probes());
-    }
-
     // Allocate an empty queue buffer for each cell group
     // These must be set initially to ensure that a queue is available for each
     // cell group for the first time step.
@@ -148,17 +142,33 @@ time_type model::run(time_type tfinal, time_type dt) {
     return t_;
 }
 
-void model::attach_sampler(cell_member_type probe_id, sampler_function f, time_type tfrom) {
-    // TODO: remove the gid_groups data structure completely when/if no longer needed
-    // for the probe interface.
-    auto it = gid_groups_.find(probe_id.gid);
-    if (it!=gid_groups_.end()) {
-        cell_groups_[it->second]->add_sampler(probe_id, f, tfrom);
-    }
+sampler_association_handle model::add_sampler(cell_member_predicate probe_ids, schedule sched, sampler_function f, sampling_policy policy) {
+    sampler_association_handle h = sassoc_handles_.acquire();
+
+    threading::parallel_for::apply(0, cell_groups_.size(),
+        [&](std::size_t i) {
+            cell_groups_[i]->add_sampler(h, probe_ids, sched, f, policy);
+        });
+
+    return h;
 }
 
-const std::vector<probe_record>& model::probes() const {
-    return probes_;
+void model::remove_sampler(sampler_association_handle h) {
+    threading::parallel_for::apply(0, cell_groups_.size(),
+        [&](std::size_t i) {
+            cell_groups_[i]->remove_sampler(h);
+        });
+
+    sassoc_handles_.release(h);
+}
+
+void model::remove_all_samplers() {
+    threading::parallel_for::apply(0, cell_groups_.size(),
+        [&](std::size_t i) {
+            cell_groups_[i]->remove_all_samplers();
+        });
+
+    sassoc_handles_.clear();
 }
 
 std::size_t model::num_spikes() const {
diff --git a/src/model.hpp b/src/model.hpp
index 6fe5a106dc07e6c3c8f7b76e9b13ce06e4940233..9c0c1ed65f6931b263f1ded3e2793b3186b7c99d 100644
--- a/src/model.hpp
+++ b/src/model.hpp
@@ -1,5 +1,6 @@
 #pragma once
 
+#include <mutex>
 #include <vector>
 
 #include <backends.hpp>
@@ -9,10 +10,10 @@
 #include <communication/communicator.hpp>
 #include <communication/global_policy.hpp>
 #include <recipe.hpp>
-#include <sampler_function.hpp>
+#include <sampling.hpp>
 #include <thread_private_spike_store.hpp>
 #include <util/nop.hpp>
-#include <util/rangeutil.hpp>
+#include <util/handle_set.hpp>
 #include <util/unique_any.hpp>
 
 namespace nest {
@@ -29,9 +30,15 @@ public:
 
     time_type run(time_type tfinal, time_type dt);
 
-    void attach_sampler(cell_member_type probe_id, sampler_function f, time_type tfrom = 0);
+    // Note: sampler functions may be invoked from a different thread than that
+    // which called the `run` method.
 
-    const std::vector<probe_record>& probes() const;
+    sampler_association_handle add_sampler(cell_member_predicate probe_ids,
+        schedule sched, sampler_function f, sampling_policy policy = sampling_policy::lax);
+
+    void remove_sampler(sampler_association_handle);
+
+    void remove_all_samplers();
 
     std::size_t num_spikes() const;
 
@@ -39,7 +46,7 @@ public:
     void set_binning_policy(binning_kind policy, time_type bin_interval);
 
     // access cell_group directly
-    // TODO: depricate. Currently used in some validation tests to inject
+    // TODO: deprecate. Currently used in some validation tests to inject
     // events directly into a cell group. This should be done with a spiking
     // neuron.
     cell_group& group(int i);
@@ -57,7 +64,6 @@ private:
 
     time_type t_ = 0.;
     std::vector<cell_group_ptr> cell_groups_;
-    std::vector<probe_record> probes_;
 
     using event_queue_type = typename communicator_type::event_queue;
     util::double_buffer<std::vector<event_queue_type>> event_queues_;
@@ -95,6 +101,9 @@ private:
 
     std::vector<event_queue_type>& current_events()  { return event_queues_.get(); }
     std::vector<event_queue_type>& future_events()   { return event_queues_.other(); }
+
+    // Sampler associations handles are managed by a helper class.
+    util::handle_set<sampler_association_handle> sassoc_handles_;
 };
 
 } // namespace mc
diff --git a/src/probes.hpp b/src/probes.hpp
deleted file mode 100644
index 36b27c3bf19227924fca9cb2a814de6911953837..0000000000000000000000000000000000000000
--- a/src/probes.hpp
+++ /dev/null
@@ -1,22 +0,0 @@
-#pragma once
-
-#include <cell.hpp>
-#include <morphology.hpp>
-#include <segment.hpp>
-
-namespace nest {
-namespace mc {
-
-enum class probeKind {
-    membrane_voltage,
-    membrane_current
-};
-
-struct probe_record {
-    cell_member_type id;
-    segment_location location;
-    probeKind kind;
-};
-
-} // namespace mc
-} // namespace nest
diff --git a/src/recipe.hpp b/src/recipe.hpp
index b2b4f6b04bf45e49914a8c04eb5a15880253cec4..1eda5252aa935800396a6fc46cc201a0b612842e 100644
--- a/src/recipe.hpp
+++ b/src/recipe.hpp
@@ -2,18 +2,20 @@
 
 #include <cstddef>
 #include <memory>
+#include <unordered_map>
 #include <stdexcept>
 
 #include <cell.hpp>
+#include <common_types.hpp>
 #include <util/unique_any.hpp>
 
 namespace nest {
 namespace mc {
 
-struct cell_count_info {
-    cell_size_type num_sources;
-    cell_size_type num_targets;
-    cell_size_type num_probes;
+struct probe_info {
+    cell_member_type id;
+    probe_tag tag;
+    util::any address;
 };
 
 class invalid_recipe_error: public std::runtime_error {
@@ -50,53 +52,17 @@ struct cell_connection {
 
 class recipe {
 public:
-    virtual cell_size_type num_cells() const =0;
+    virtual cell_size_type num_cells() const = 0;
 
-    virtual util::unique_any get_cell_description(cell_gid_type) const =0;
+    virtual util::unique_any get_cell_description(cell_gid_type) const = 0;
     virtual cell_kind get_cell_kind(cell_gid_type) const = 0;
 
-    virtual cell_count_info get_cell_count_info(cell_gid_type) const =0;
-    virtual std::vector<cell_connection> connections_on(cell_gid_type) const =0;
-};
-
-
-/*
- * Recipe consisting of a single, unconnected cell
- * is particularly simple. Note keeps a reference to
- * the provided cell, so be aware of life time issues.
- */
-
-class singleton_recipe: public recipe {
-public:
-    singleton_recipe(const cell& the_cell): cell_(the_cell) {}
-
-    cell_size_type num_cells() const override {
-        return 1;
-    }
-
-    util::unique_any get_cell_description(cell_gid_type) const override {
-        return util::unique_any(cell(clone_cell, cell_));
-    }
-
-    cell_kind get_cell_kind(cell_gid_type) const override {
-        return cell_.get_cell_kind();
-    }
-
-    cell_count_info get_cell_count_info(cell_gid_type) const override {
-        cell_count_info k;
-        k.num_sources = cell_.detectors().size();
-        k.num_targets = cell_.synapses().size();
-        k.num_probes = cell_.probes().size();
-
-        return k;
-    }
-
-    std::vector<cell_connection> connections_on(cell_gid_type) const override {
-        return std::vector<cell_connection>{};
-    }
+    virtual cell_size_type num_sources(cell_gid_type) const = 0;
+    virtual cell_size_type num_targets(cell_gid_type) const = 0;
+    virtual cell_size_type num_probes(cell_gid_type) const = 0;
 
-private:
-    const cell& cell_;
+    virtual std::vector<cell_connection> connections_on(cell_gid_type) const = 0;
+    virtual probe_info get_probe(cell_member_type probe_id) const = 0;
 };
 
 } // namespace mc
diff --git a/src/rss_cell.hpp b/src/rss_cell.hpp
index 7f627ffc965d61733692ec665fa7bbb0146b580a..6ee5d07c4082a492aa13f5113b6bcb69b90665dd 100644
--- a/src/rss_cell.hpp
+++ b/src/rss_cell.hpp
@@ -1,94 +1,17 @@
 #pragma once
-#pragma once
-
-#include <vector>
 
 #include <common_types.hpp>
-#include <util/debug.hpp>
 
 namespace nest {
 namespace mc {
 
-/// Regular spike source: A cell that generated spikes with a certain
-/// period for a set time range.
-class rss_cell {
-public:
-    using index_type = cell_lid_type;
-    using size_type = cell_local_size_type;
-    using value_type = double;
-
-    struct rss_cell_description {
-        time_type start_time;
-        time_type period;
-        time_type stop_time;
-
-        rss_cell_description(time_type st, time_type per, time_type stop):
-            start_time(st),
-            period(per),
-            stop_time(stop)
-        {}
-    };
-
-    /// Construct a rss cell from its description
-    rss_cell(rss_cell_description descr) :
-        start_time_(descr.start_time),
-        period_(descr.period),
-        stop_time_(descr.stop_time),
-        time_(0.0)
-    {}
-
-    /// Construct a rss cell from individual arguments
-    rss_cell(time_type start_time=0.0, time_type period=1.0, time_type stop_time=0.0):
-        start_time_(start_time),
-        period_(period),
-        stop_time_(stop_time),
-        time_(0.0)
-    {}
-
-    /// Return the kind of cell, used for grouping into cell_groups
-    cell_kind  get_cell_kind() const  {
-        return cell_kind::regular_spike_source;
-    }
-
-    /// Collect all spikes until tfinal.
-    // updates the internal time state to tfinal as a side effect
-    std::vector<time_type> spikes_until(time_type tfinal) {
-        std::vector<time_type> spike_times;
-
-        // If we should be spiking in this 'period'
-        if (tfinal > start_time_) {
-            // We have to spike till tfinal or the stop_time_of the neuron
-            auto end_time = stop_time_ < tfinal ? stop_time_ : tfinal;
-            // Generate all possible spikes in this time frame typically only
-            for (time_type time = start_time_ > time_ ? start_time_ : time_;
-                time < end_time;
-                time += period_) {
-                spike_times.push_back(time);
-            }
-        }
-        // Save our current time we generate exclusive a possible spike at tfinal
-        time_ = tfinal;
-
-        return spike_times;
-    }
-
-    /// reset internal time to 0.0
-    void reset() {
-        time_ = 0.0;
-    }
-
-private:
-    // When to start spiking
-    time_type start_time_;
-
-    // with what period
-    time_type period_;
-
-    // untill when
-    time_type stop_time_;
+/// Description class for a regular spike source: a cell that generates
+/// spikes with a fixed period over a given time interval.
 
-    // internal time, storage
-    time_type time_;
+struct rss_cell {
+    time_type start_time;
+    time_type period;
+    time_type stop_time;
 };
 
 } // namespace mc
diff --git a/src/rss_cell_group.hpp b/src/rss_cell_group.hpp
index 6fc50d3fc1dcd4f0c60395a7de855f6ac0371242..201b7b9f8b58e5131503532ad049991fefec5292 100644
--- a/src/rss_cell_group.hpp
+++ b/src/rss_cell_group.hpp
@@ -1,61 +1,55 @@
 #pragma once
 
+#include <utility>
+
 #include <cell_group.hpp>
+#include <recipe.hpp>
 #include <rss_cell.hpp>
-#include <util/optional.hpp>
-#include <util/span.hpp>
 #include <util/unique_any.hpp>
 
-
-#include <iostream>
 namespace nest {
 namespace mc {
 
-/// Cell_group to collect cells that spike at a set frequency
-/// Cell are lightweight and are not executed in anybackend implementation
+/// Cell group implementing RSS cells.
+
 class rss_cell_group: public cell_group {
 public:
-    using source_id_type = cell_member_type;
-
-    rss_cell_group(std::vector<cell_gid_type> gids,
-                   const std::vector<util::unique_any>& cell_descriptions):
-        gids_(gids)
-    {
-        using util::make_span;
-
-        for (cell_gid_type i: make_span(0, cell_descriptions.size())) {
-            cells_.push_back(rss_cell(
-                util::any_cast<rss_cell::rss_cell_description>(cell_descriptions[i])
-            ));
+    rss_cell_group(std::vector<cell_gid_type> gids, const recipe& rec) {
+        cells_.reserve(gids.size());
+        for (auto gid: gids) {
+            cells_.emplace_back(
+                util::any_cast<rss_cell>(rec.get_cell_description(gid)),
+                gid);
         }
+        reset();
     }
 
-    virtual ~rss_cell_group() = default;
-
     cell_kind get_cell_kind() const override {
         return cell_kind::regular_spike_source;
     }
 
     void reset() override {
-        for (auto cell: cells_) {
-            cell.reset();
-        }
+        clear_spikes();
+        time_ = 0;
     }
 
-    void set_binning_policy(binning_kind policy, time_type bin_interval) override
-    {}
+    void set_binning_policy(binning_kind policy, time_type bin_interval) override {}
 
     void advance(time_type tfinal, time_type dt) override {
-        // TODO: Move rss_cell implementation into the rss_cell_group
-        for (auto i: util::make_span(0, cells_.size())) {
-            for (auto spike_time: cells_[i].spikes_until(tfinal)) {
-                spikes_.push_back({{gids_[i], 0}, spike_time});
+        for (const auto& cell: cells_) {
+            auto t = std::max(cell.start_time, time_);
+            auto t_end = std::min(cell.stop_time, tfinal);
+
+            while (t < t_end) {
+                spikes_.push_back({{cell.gid, 0}, t});
+                t += cell.period;
             }
         }
-    };
+        time_ = tfinal;
+    }
 
     void enqueue_events(const std::vector<postsynaptic_spike_event>& events) override {
-        std::logic_error("The rss_cells do not support incoming events!");
+        std::logic_error("rss_cell cannot deliver events");
     }
 
     const std::vector<spike>& spikes() const override {
@@ -66,23 +60,32 @@ public:
         spikes_.clear();
     }
 
-    std::vector<probe_record> probes() const override {
-        return {};
+    virtual void add_sampler(sampler_association_handle, cell_member_predicate, schedule, sampler_function, sampling_policy) {
+        std::logic_error("rss_cell does not support sampling");
     }
 
-    void add_sampler(cell_member_type, sampler_function, time_type ts=0) override {
-        std::logic_error("The rss_cells do not support sampling of internal state!");
-    }
+    void remove_sampler(sampler_association_handle) override {}
+
+    void remove_all_samplers() override {}
 
 private:
-    // List of the gids of the cells in the group
-    std::vector<cell_gid_type> gids_;
+    // RSS description plus gid for each RSS cell.
+    struct rss_info: public rss_cell {
+        rss_info(rss_cell desc, cell_gid_type gid):
+            rss_cell(std::move(desc)), gid(gid)
+        {}
+
+        cell_gid_type gid;
+    };
+
+    // RSS cell descriptions.
+    std::vector<rss_info> cells_;
+
+    // Simulation time for all RSS cells in the group.
+    time_type time_;
 
     // Spikes that are generated.
     std::vector<spike> spikes_;
-
-    // Store a reference to the cell actually implementing the spiking
-    std::vector<rss_cell> cells_;
 };
 
 } // namespace mc
diff --git a/src/sampler_function.hpp b/src/sampler_function.hpp
deleted file mode 100644
index 6a042b585deb264a0378217b4ff10f0f9d1164df..0000000000000000000000000000000000000000
--- a/src/sampler_function.hpp
+++ /dev/null
@@ -1,14 +0,0 @@
-#pragma once
-
-#include <functional>
-
-#include <common_types.hpp>
-#include <util/optional.hpp>
-
-namespace nest {
-namespace mc {
-
-using sampler_function = std::function<util::optional<time_type>(time_type, double)>;
-
-} // namespace mc
-} // namespace nest
diff --git a/src/sampler_map.hpp b/src/sampler_map.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..34caadc7af34fdcd24fda895e484b5360846b750
--- /dev/null
+++ b/src/sampler_map.hpp
@@ -0,0 +1,77 @@
+#pragma once
+
+/*
+ * Helper classes for managing sampler/schedule associations in
+ * cell group classes (see sampling_api doc).
+ */
+
+#include <functional>
+#include <mutex>
+#include <unordered_map>
+
+#include <common_types.hpp>
+#include <sampling.hpp>
+#include <schedule.hpp>
+#include <util/deduce_return.hpp>
+#include <util/transform.hpp>
+
+namespace nest {
+namespace mc {
+
+// An association between a samplers, schedule, and set of probe ids, as provided
+// to e.g. `model::add_sampler()`.
+
+struct sampler_association {
+    schedule sched;
+    sampler_function sampler;
+    std::vector<cell_member_type> probe_ids;
+};
+
+// Maintain a set of associations paired with handles used for deletion.
+
+class sampler_association_map {
+public:
+    void add(sampler_association_handle h, sampler_association assoc) {
+        std::lock_guard<std::mutex> lock(m_);
+        map_.insert({h, std::move(assoc)});
+    }
+
+    void remove(sampler_association_handle h) {
+        std::lock_guard<std::mutex> lock(m_);
+        map_.erase(h);
+    }
+
+    void clear() {
+        std::lock_guard<std::mutex> lock(m_);
+        map_.clear();
+    }
+
+private:
+    using assoc_map = std::unordered_map<sampler_association_handle, sampler_association>;
+    assoc_map map_;
+    std::mutex m_;
+
+    static sampler_association& second(assoc_map::value_type& p) { return p.second; }
+    auto assoc_view() DEDUCED_RETURN_TYPE((util::transform_view(map_, &sampler_association_map::second)))
+
+public:
+    // Range-like view presents just the associations, omitting the handles.
+
+    auto begin() DEDUCED_RETURN_TYPE(assoc_view().begin());
+    auto end()   DEDUCED_RETURN_TYPE(assoc_view().end());
+};
+
+// Manage associations between probe ids, probe tags, and (lowered cell) probe handles.
+
+template <typename Handle>
+struct probe_association {
+    using probe_handle_type = Handle;
+    probe_handle_type handle;
+    probe_tag tag;
+};
+
+template <typename Handle>
+using probe_association_map = std::unordered_map<cell_member_type, probe_association<Handle>>;
+
+} // namespace mc
+} // namespace nest
diff --git a/src/sampling.hpp b/src/sampling.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..f1ca642e67a68ec6b655d61a8e9e4a3e0ddc3c69
--- /dev/null
+++ b/src/sampling.hpp
@@ -0,0 +1,36 @@
+#pragma once
+
+#include <cstddef>
+#include <functional>
+
+#include <common_types.hpp>
+#include <util/any_ptr.hpp>
+
+namespace nest {
+namespace mc {
+
+using cell_member_predicate = std::function<bool (cell_member_type)>;
+
+static cell_member_predicate all_probes = [](cell_member_type pid) { return true; };
+
+static inline cell_member_predicate one_probe(cell_member_type pid) {
+    return [pid](cell_member_type x) { return pid==x; };
+}
+
+struct sample_record {
+    time_type time;
+    util::any_ptr data;
+};
+
+using sampler_function = std::function<void (cell_member_type, probe_tag, std::size_t, const sample_record*)>;
+
+using sampler_association_handle = std::size_t;
+
+enum class sampling_policy {
+    lax,
+    // interpolated, // placeholder: unsupported
+    // exact         // placeholder: unsupported
+};
+
+} // namespace mc
+} // namespace nest
diff --git a/src/schedule.cpp b/src/schedule.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..c1f569a685c26ad040a2f4b5c1ce3fe16341d7f0
--- /dev/null
+++ b/src/schedule.cpp
@@ -0,0 +1,50 @@
+#include <algorithm>
+#include <iterator>
+#include <utility>
+#include <vector>
+
+#include <common_types.hpp>
+#include <schedule.hpp>
+
+// Implementations for specific schedules.
+
+namespace nest {
+namespace mc {
+
+// Regular schedule implementation.
+
+std::vector<time_type> regular_schedule_impl::events(time_type t0, time_type t1) {
+    std::vector<time_type> ts;
+
+    t0 = t0<0? 0: t0;
+    if (t1>t0) {
+        ts.reserve(1+std::size_t((t1-t0)*oodt_));
+
+        long long n = t0*oodt_;
+        time_type t = n*dt_;
+
+        while (t<t0) {
+            t = (++n)*dt_;
+        }
+
+        while (t<t1) {
+            ts.push_back(t);
+            t = (++n)*dt_;
+        }
+    }
+
+    return ts;
+}
+
+// Explicit schedule implementation.
+
+std::vector<time_type> explicit_schedule_impl::events(time_type t0, time_type t1) {
+    auto lb = std::lower_bound(times_.begin()+start_index_, times_.end(), t0);
+    auto ub = std::lower_bound(times_.begin()+start_index_, times_.end(), t1);
+
+    start_index_ = std::distance(times_.begin(), ub);
+    return std::vector<time_type>(lb, ub);
+}
+
+} // namespace mc
+} // namespace nest
diff --git a/src/schedule.hpp b/src/schedule.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..4a3212e0c975be82ad468f5713e6ad0a67d7b5e9
--- /dev/null
+++ b/src/schedule.hpp
@@ -0,0 +1,185 @@
+#pragma once
+
+#include <algorithm>
+#include <memory>
+#include <random>
+#include <vector>
+
+#include <common_types.hpp>
+#include <util/compat.hpp>
+#include <util/debug.hpp>
+#include <util/meta.hpp>
+
+// Time schedules for probe–sampler associations.
+
+namespace nest {
+namespace mc {
+
+// A schedule describes a sequence of time values used for sampling. Schedules
+// are queried monotonically in time: if two method calls `events(t0, t1)` 
+// and `events(t2, t3)` are made without an intervening call to `reset()`,
+// then 0 ≤ _t0_ ≤ _t1_ ≤ _t2_ ≤ _t3_.
+
+class schedule {
+public:
+    template <typename Impl>
+    explicit schedule(const Impl& impl):
+        impl_(new wrap<Impl>(impl)) {}
+
+    template <typename Impl>
+    explicit schedule(Impl&& impl):
+        impl_(new wrap<Impl>(std::move(impl))) {}
+
+    schedule(schedule&& other) = default;
+    schedule& operator=(schedule&& other) = default;
+
+    schedule(const schedule& other):
+        impl_(other.impl_->clone()) {}
+
+    schedule& operator=(const schedule& other) {
+        impl_ = other.impl_->clone();
+        return *this;
+    }
+
+    std::vector<time_type> events(time_type t0, time_type t1) {
+        return impl_->events(t0, t1);
+    }
+
+    void reset() { impl_->reset(); }
+
+private:
+    struct interface {
+        virtual std::vector<time_type> events(time_type t0, time_type t1) = 0;
+        virtual void reset() = 0;
+        virtual std::unique_ptr<interface> clone() = 0;
+        virtual ~interface() {}
+    };
+
+    std::unique_ptr<interface> impl_;
+
+    template <typename Impl>
+    struct wrap: interface {
+        explicit wrap(const Impl& impl): wrapped(impl) {}
+        explicit wrap(Impl&& impl): wrapped(std::move(impl)) {}
+
+        virtual std::vector<time_type> events(time_type t0, time_type t1) {
+            return wrapped.events(t0, t1);
+        }
+
+        virtual void reset() {
+            wrapped.reset();
+        }
+
+        virtual std::unique_ptr<interface> clone() {
+            return std::unique_ptr<interface>(new wrap<Impl>(wrapped));
+        }
+
+        Impl wrapped;
+    };
+};
+
+
+// Common schedules
+
+// Schedule at k·dt for integral k≥0.
+class regular_schedule_impl {
+public:
+    explicit regular_schedule_impl(time_type dt):
+        dt_(dt), oodt_(1./dt) {};
+
+    void reset() {}
+    std::vector<time_type> events(time_type t0, time_type t1);
+
+private:
+    time_type dt_;
+    time_type oodt_;
+};
+
+inline schedule regular_schedule(time_type dt) {
+    return schedule(regular_schedule_impl(dt));
+}
+
+// Schedule at times given explicitly via a provided sorted sequence.
+class explicit_schedule_impl {
+public:
+    template <typename Seq, typename = util::enable_if_sequence_t<Seq>>
+    explicit explicit_schedule_impl(const Seq& seq):
+        start_index_(0),
+        times_(std::begin(seq), compat::end(seq))
+    {
+        EXPECTS(std::is_sorted(times_.begin(), times_.end()));
+    }
+
+    void reset() {
+        start_index_ = 0;
+    }
+
+    std::vector<time_type> events(time_type t0, time_type t1);
+
+private:
+    std::ptrdiff_t start_index_;
+    std::vector<time_type> times_;
+};
+
+template <typename Seq>
+inline schedule explicit_schedule(const Seq& seq) {
+    return schedule(explicit_schedule_impl(seq));
+}
+
+// Schedule at Poisson point process with rate 1/mean_dt,
+// restricted to non-negative times.
+template <typename RandomNumberEngine>
+class poisson_schedule_impl {
+public:
+    poisson_schedule_impl(time_type tstart, time_type mean_dt, const RandomNumberEngine& rng):
+        tstart_(tstart), exp_(1./mean_dt), rng_(rng), reset_state_(rng), next_(tstart)
+    {
+        EXPECTS(tstart_>=0);
+        step();
+    }
+
+    void reset() {
+        rng_ = reset_state_;
+        next_ = tstart_;
+        step();
+    }
+
+    std::vector<time_type> events(time_type t0, time_type t1) {
+        std::vector<time_type> ts;
+
+        while (next_<t0) {
+            step();
+        }
+
+        while (next_<t1) {
+            ts.push_back(next_);
+            step();
+        }
+
+        return ts;
+    }
+
+private:
+    void step() {
+        next_ += exp_(rng_);
+    }
+
+    time_type tstart_;
+    std::exponential_distribution<time_type> exp_;
+    RandomNumberEngine rng_;
+    RandomNumberEngine reset_state_;
+    time_type next_;
+};
+
+template <typename RandomNumberEngine>
+inline schedule poisson_schedule(time_type mean_dt, const RandomNumberEngine& rng) {
+    return schedule(poisson_schedule_impl<RandomNumberEngine>(0., mean_dt, rng));
+}
+
+template <typename RandomNumberEngine>
+inline schedule poisson_schedule(time_type tstart, time_type mean_dt, const RandomNumberEngine& rng) {
+    return schedule(poisson_schedule_impl<RandomNumberEngine>(tstart, mean_dt, rng));
+}
+
+} // namespace mc
+} // namespace nest
diff --git a/src/simple_sampler.hpp b/src/simple_sampler.hpp
index 5ffc41d2470dbd55f0b1af732c1f0aa43db11530..e096756414d9813b8e0ed0e9caa79f343f40fd7c 100644
--- a/src/simple_sampler.hpp
+++ b/src/simple_sampler.hpp
@@ -5,73 +5,74 @@
  * trace data from a cell probe, with some metadata.
  */
 
-#include <functional>
 #include <vector>
 
 #include <common_types.hpp>
-#include <sampler_function.hpp>
-#include <util/optional.hpp>
+#include <sampling.hpp>
+#include <util/any_ptr.hpp>
 #include <util/deduce_return.hpp>
+#include <util/span.hpp>
 #include <util/transform.hpp>
 
+#include <iostream>
+
 namespace nest {
 namespace mc {
 
+template <typename V>
 struct trace_entry {
-    float t;
-    double v;
+    time_type t;
+    V v;
 };
 
-using trace_data = std::vector<trace_entry>;
+template <typename V>
+using trace_data = std::vector<trace_entry<V>>;
 
 // NB: work-around for lack of function return type deduction
 // in C++11; can't use lambda within DEDUCED_RETURN_TYPE.
 
 namespace impl {
-    inline float time(const trace_entry& x) { return x.t; }
-    inline float value(const trace_entry& x) { return x.v; }
+    template <typename V>
+    inline float time(const trace_entry<V>& x) { return x.t; }
+
+    template <typename V>
+    inline const V& value(const trace_entry<V>& x) { return x.v; }
 }
 
-inline auto times(const trace_data& trace) DEDUCED_RETURN_TYPE(
-   util::transform_view(trace, impl::time)
+template <typename V>
+inline auto times(const trace_data<V>& trace) DEDUCED_RETURN_TYPE(
+   util::transform_view(trace, impl::time<V>)
 )
 
-inline auto values(const trace_data& trace) DEDUCED_RETURN_TYPE(
-   util::transform_view(trace, impl::value)
+template <typename V>
+inline auto values(const trace_data<V>& trace) DEDUCED_RETURN_TYPE(
+   util::transform_view(trace, impl::value<V>)
 )
 
+template <typename V, typename = util::enable_if_trivially_copyable_t<V>>
 class simple_sampler {
 public:
-    trace_data trace;
+    explicit simple_sampler(trace_data<V>& trace): trace_(trace) {}
 
-    simple_sampler(time_type dt, time_type t0=0):
-        t0_(t0),
-        sample_dt_(dt),
-        t_next_sample_(t0)
-    {}
-
-    void reset() {
-        trace.clear();
-        t_next_sample_ = t0_;
-    }
-
-    sampler_function sampler() {
-        return [&](time_type t, double v) -> util::optional<time_type> {
-            if (t<t_next_sample_) {
-                return t_next_sample_;
+    void operator()(cell_member_type probe_id, probe_tag tag, std::size_t n, const sample_record* recs) {
+        for (auto i: util::make_span(0, n)) {
+            if (auto p = util::any_cast<const V*>(recs[i].data)) {
+                trace_.push_back({recs[i].time, *p});
             }
             else {
-                trace.push_back({t, v});
-                return t_next_sample_+=sample_dt_;
+                throw std::runtime_error("unexpected sample type in simple_sampler");
             }
-        };
+        }
     }
 
 private:
-    time_type t0_ = 0;
-    time_type sample_dt_ = 0;
-    time_type t_next_sample_ = 0;
+    trace_data<V>& trace_;
 };
 
+template <typename V>
+inline simple_sampler<V> make_simple_sampler(trace_data<V>& trace) {
+    return simple_sampler<V>(trace);
+}
+
 } // namespace mc
 } // namespace nest
diff --git a/src/util/any_ptr.hpp b/src/util/any_ptr.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..cbd7433cf691e7519d8a174cf9db4713bdc6c592
--- /dev/null
+++ b/src/util/any_ptr.hpp
@@ -0,0 +1,98 @@
+#pragma once
+
+/* Specialied type erasure for pointer types.
+ *
+ * `any_ptr` represents a non-owning pointer to an arbitrary type
+ * that can be confirmed at run-time.
+ *
+ * Semantics:
+ *
+ * 1. An `any_ptr` value p represents either a null pointer, or
+ *    a non-null pointer of a specific but arbitrary type T.
+ *
+ * 2. The value of the pointer as a `void*` value can be retrieved
+ *    with the member function `as<void*>()`.
+ *
+ * 3. The value of the pointer as a type T which is not `void*` is
+ *    retrieved with the member function `as<T>()`. If the represented
+ *    pointer is the null pointer or a pointer to a different type,
+ *    `as<T>()` will return the null pointer.
+ */
+
+#include <cstddef>
+#include <type_traits>
+
+#include <util/lexcmp_def.hpp>
+#include <util/meta.hpp>
+
+namespace nest {
+namespace mc {
+namespace util {
+
+struct any_ptr {
+    any_ptr() {}
+
+    any_ptr(std::nullptr_t) {}
+
+    template <typename T>
+    any_ptr(T* ptr):
+        ptr_((void *)ptr), type_ptr_(&typeid(T*)) {}
+
+    const std::type_info& type() const noexcept { return *type_ptr_; }
+
+    bool has_value() const noexcept { return ptr_; }
+
+    operator bool() const noexcept { return has_value(); }
+
+    void reset() noexcept { ptr_ = nullptr; }
+
+    void reset(std::nullptr_t) noexcept { ptr_ = nullptr; }
+
+    template <typename T>
+    void reset(T* ptr) noexcept {
+        ptr_ = (void*)ptr;
+        type_ptr_ = &typeid(T*);
+    }
+
+    template <typename T, typename = enable_if_t<std::is_pointer<T>::value>>
+    T as() const noexcept {
+        if (std::is_same<T, void*>::value) {
+            return (T)ptr_;
+        }
+        else {
+            return typeid(T)==type()? (T)ptr_: nullptr;
+        }
+    }
+
+    any_ptr& operator=(const any_ptr& other) noexcept {
+        type_ptr_ = other.type_ptr_;
+        ptr_ = other.ptr_;
+        return *this;
+    }
+
+    any_ptr& operator=(std::nullptr_t) noexcept {
+        reset();
+        return *this;
+    }
+
+    template <typename T>
+    any_ptr& operator=(T* ptr) noexcept {
+        reset(ptr);
+        return *this;
+    }
+
+private:
+    void* ptr_ = nullptr;
+    const std::type_info* type_ptr_ = &typeid(void);
+};
+
+// Order, compare by pointer value:
+DEFINE_LEXICOGRAPHIC_ORDERING_BY_VALUE(any_ptr, (a.as<void*>()), (b.as<void*>()))
+
+// Overload `util::any_cast` for these pointers.
+template <typename T>
+T any_cast(any_ptr p) noexcept { return p.as<T>(); }
+
+} // namespace util
+} // namespace mc
+} // namespace nest
diff --git a/src/util/handle_set.hpp b/src/util/handle_set.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..04086c1ea07ec71a2fa79c7e6a8e75133848470e
--- /dev/null
+++ b/src/util/handle_set.hpp
@@ -0,0 +1,63 @@
+#pragma once
+
+#include <mutex>
+#include <stdexcept>
+#include <utility>
+#include <vector>
+
+/*
+ * Manage a set of integer-valued handles.
+ *
+ * An error is thrown if no unused handle value is available on acquire.
+ *
+ * Simple implementation below does not try hard to reuse released
+ * handles; smarter versions to be implemented as required.
+ */
+
+namespace nest {
+namespace mc {
+namespace util {
+
+template <typename Handle>
+class handle_set {
+public:
+    using value_type = Handle;
+
+    value_type acquire() {
+        lock_guard lock(mex_);
+
+        if (top_==std::numeric_limits<Handle>::max()) {
+            throw std::out_of_range("no more handles");
+        }
+        return top_++;
+    }
+
+    // Pre-requisite: h is a handle returned by
+    // `acquire`, which has not been subject
+    // to a subsequent `release`.
+    void release(value_type h) {
+        lock_guard lock(mex_);
+
+        if (h+1==top_) {
+            --top_;
+        }
+    }
+
+    // Release all handles.
+    void clear() {
+        lock_guard lock(mex_);
+
+        top_ = 0;
+    }
+
+private:
+    value_type top_ = 0;
+
+    using lock_guard = std::lock_guard<std::mutex>;
+    std::mutex mex_;
+};
+
+} // namespace util
+} // namespace mc
+} // namespace nest
+
diff --git a/src/util/meta.hpp b/src/util/meta.hpp
index 35b1b3e3363c14b6fcb9f0162c0845b748667ea3..8984bc6b9df6c8a877674cd9bfb8f2f4b365fee1 100644
--- a/src/util/meta.hpp
+++ b/src/util/meta.hpp
@@ -123,6 +123,10 @@ template <typename T>
 using enable_if_move_assignable_t =
     enable_if_t<std::is_move_assignable<T>::value>;
 
+template <typename T>
+using enable_if_trivially_copyable_t =
+    enable_if_t<std::is_trivially_copyable<T>::value>;
+
 // Iterator class test
 // (might not be portable before C++17)
 
@@ -271,8 +275,6 @@ struct second_t {
 };
 constexpr second_t second{};
 
-
-
 } // namespace util
 } // namespace mc
 } // namespace nest
diff --git a/src/util/rangeutil.hpp b/src/util/rangeutil.hpp
index 8d03be492d52ade3b90c5007e1bd83919cc2636c..b5d4d040ebbc848e87f1b44d0926ca85602a5594 100644
--- a/src/util/rangeutil.hpp
+++ b/src/util/rangeutil.hpp
@@ -10,6 +10,7 @@
 #include <ostream>
 #include <numeric>
 
+#include <util/deduce_return.hpp>
 #include <util/meta.hpp>
 #include <util/range.hpp>
 #include <util/transform.hpp>
@@ -314,6 +315,12 @@ std::pair<Value, Value> minmax_value(const Seq& seq, Compare cmp = Compare{}) {
     return {lower, upper};
 }
 
+// View over the keys in an associative container.
+
+template <typename Map>
+auto keys(Map& m) DEDUCED_RETURN_TYPE(util::transform_view(m, util::first));
+
+
 template <typename C, typename Seq>
 C make_copy(Seq const& seq) {
     return C{std::begin(seq), std::end(seq)};
diff --git a/src/util/transform.hpp b/src/util/transform.hpp
index da9165cf085cde1766f651ab3a3f8f8a5eedb6c5..ed9a31d6bc2aab70ae61a898fb2e65e0b4d05905 100644
--- a/src/util/transform.hpp
+++ b/src/util/transform.hpp
@@ -35,7 +35,7 @@ class transform_iterator: public iterator_adaptor<transform_iterator<I, F>, I> {
     const I& inner() const { return inner_; }
     I& inner() { return inner_; }
 
-    using inner_value_type = util::decay_t<decltype(*inner_)>;
+    using inner_value_type = decltype(*inner_);
     using raw_value_type = typename std::result_of<F (inner_value_type)>::type;
 
     static constexpr bool present_lvalue = std::is_reference<raw_value_type>::value;
@@ -129,25 +129,25 @@ transform_iterator<I, util::decay_t<F>> make_transform_iterator(const I& i, cons
 template <
     typename Seq,
     typename F,
-    typename seq_citer = typename sequence_traits<Seq>::const_iterator,
-    typename seq_csent = typename sequence_traits<Seq>::const_sentinel,
-    typename = enable_if_t<std::is_same<seq_citer, seq_csent>::value>
+    typename seq_iter = typename sequence_traits<Seq>::iterator,
+    typename seq_sent = typename sequence_traits<Seq>::sentinel,
+    typename = enable_if_t<std::is_same<seq_iter, seq_sent>::value>
 >
-range<transform_iterator<seq_citer, util::decay_t<F>>>
-transform_view(const Seq& s, const F& f) {
-    return {make_transform_iterator(util::cbegin(s), f), make_transform_iterator(util::cend(s), f)};
+range<transform_iterator<seq_iter, util::decay_t<F>>>
+transform_view(Seq&& s, const F& f) {
+    return {make_transform_iterator(std::begin(s), f), make_transform_iterator(std::end(s), f)};
 }
 
 template <
     typename Seq,
     typename F,
-    typename seq_citer = typename sequence_traits<Seq>::const_iterator,
-    typename seq_csent = typename sequence_traits<Seq>::const_sentinel,
-    typename = enable_if_t<!std::is_same<seq_citer, seq_csent>::value>
+    typename seq_iter = typename sequence_traits<Seq>::iterator,
+    typename seq_sent = typename sequence_traits<Seq>::sentinel,
+    typename = enable_if_t<!std::is_same<seq_iter, seq_sent>::value>
 >
-range<transform_iterator<seq_citer, util::decay_t<F>>, seq_csent>
-transform_view(const Seq& s, const F& f) {
-    return {make_transform_iterator(util::cbegin(s), f), util::cend(s)};
+range<transform_iterator<seq_iter, util::decay_t<F>>, seq_sent>
+transform_view(Seq&& s, const F& f) {
+    return {make_transform_iterator(std::begin(s), f), std::end(s)};
 }
 
 } // namespace util
diff --git a/tests/test_common_cells.hpp b/tests/common_cells.hpp
similarity index 94%
rename from tests/test_common_cells.hpp
rename to tests/common_cells.hpp
index e5f1a49bc40147822ad9e22e18c26bb87d63ecbe..feae376db2b26534fea3f97413b961ce30cb0501 100644
--- a/tests/test_common_cells.hpp
+++ b/tests/common_cells.hpp
@@ -1,6 +1,7 @@
 #include <cmath>
 
 #include <cell.hpp>
+#include <recipe.hpp>
 #include <segment.hpp>
 #include <math.hpp>
 #include <parameter_list.hpp>
@@ -297,22 +298,5 @@ inline cell make_cell_simple_cable(bool with_stim = true) {
     return c;
 }
 
-
-/*
- * Attach voltage probes at each cable mid-point and end-point,
- * and at soma mid-point.
- */
-
-inline cell& add_common_voltage_probes(cell& c) {
-    auto ns = c.num_segments();
-    for (auto i=0u; i<ns; ++i) {
-        c.add_probe({{i, 0.5}, probeKind::membrane_voltage});
-        if (i>0) {
-            c.add_probe({{i, 1.0}, probeKind::membrane_voltage});
-        }
-    }
-    return c;
-}
-
 } // namespace mc
 } // namespace nest
diff --git a/tests/simple_recipes.hpp b/tests/simple_recipes.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..0dd687362b09958c4caf8fc53540ec25665242c0
--- /dev/null
+++ b/tests/simple_recipes.hpp
@@ -0,0 +1,114 @@
+#pragma once
+
+// Simple recipe classes for use in unit and validation tests.
+
+#include <unordered_map>
+#include <vector>
+
+#include <cell.hpp>
+#include <recipe.hpp>
+
+namespace nest {
+namespace mc {
+
+// Common functionality: maintain an unordered map of probe data
+// per gid, built with `add_probe()`.
+
+class simple_recipe_base: public recipe {
+public:
+    cell_size_type num_probes(cell_gid_type i) const override {
+        return probes_.count(i)? probes_.at(i).size(): 0;
+    }
+
+    std::vector<cell_connection> connections_on(cell_gid_type) const override {
+        return {};
+    }
+
+    virtual probe_info get_probe(cell_member_type probe_id) const override {
+        return probes_.at(probe_id.gid).at(probe_id.index);
+    }
+
+    virtual void add_probe(cell_gid_type gid, probe_tag tag, util::any address) {
+        auto& pvec_ = probes_[gid];
+
+        cell_member_type probe_id{gid, cell_lid_type(pvec_.size())};
+        pvec_.push_back({probe_id, tag, std::move(address)});
+    }
+
+protected:
+    std::unordered_map<cell_gid_type, std::vector<probe_info>> probes_;
+};
+
+// Convenience derived recipe class for wrapping n copies of a single
+// cell description, with no sources or targets. (Derive the class to
+// add sources, targets, connections etc.)
+//
+// Probes are simply stored in a multimap, keyed by gid and can be manually
+// added with 'add_probe()'.
+//
+// Wrapped description class must be both move- and copy-constructable.
+
+template <cell_kind Kind, typename Description>
+class homogeneous_recipe: public simple_recipe_base {
+public:
+    homogeneous_recipe(cell_size_type n, Description desc):
+        n_(n), desc_(std::move(desc))
+    {}
+
+    cell_size_type num_cells() const override { return n_; }
+    cell_kind get_cell_kind(cell_gid_type) const override { return Kind; }
+
+    cell_size_type num_sources(cell_gid_type) const override { return 0; }
+    cell_size_type num_targets(cell_gid_type) const override { return 0; }
+
+    util::unique_any get_cell_description(cell_gid_type) const override {
+        return util::make_unique_any<Description>(desc_);
+    }
+
+protected:
+    cell_size_type n_;
+    Description desc_;
+};
+
+// Recipe for a set of `cable1d_neuron` neurons without connections,
+// and probes which can be added by `add_probe()` (similar to above).
+//
+// Cell descriptions passed to the constructor are cloned.
+
+class cable1d_recipe: public simple_recipe_base {
+public:
+    template <typename Seq>
+    explicit cable1d_recipe(const Seq& cells) {
+        for (const auto& c: cells) {
+            cells_.emplace_back(clone_cell, c);
+        }
+    }
+
+    explicit cable1d_recipe(const cell& c) {
+        cells_.reserve(1);
+        cells_.emplace_back(clone_cell, c);
+    }
+
+    cell_size_type num_cells() const override { return cells_.size(); }
+    cell_kind get_cell_kind(cell_gid_type) const override { return cell_kind::cable1d_neuron; }
+
+    cell_size_type num_sources(cell_gid_type i) const override {
+        return cells_.at(i).detectors().size();
+    }
+
+    cell_size_type num_targets(cell_gid_type i) const override {
+        return cells_.at(i).synapses().size();
+    }
+
+    util::unique_any get_cell_description(cell_gid_type i) const override {
+        return util::make_unique_any<cell>(clone_cell, cells_[i]);
+    }
+
+protected:
+    std::vector<cell> cells_;
+};
+
+
+} // namespace mc
+} // namespace nest
+
diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt
index 40427c1ab001a231e1001cafa096b24be472e3f8..61b36aa2a2b79acc7a7ec36e44d206873807dafc 100644
--- a/tests/unit/CMakeLists.txt
+++ b/tests/unit/CMakeLists.txt
@@ -61,12 +61,14 @@ set(TEST_SOURCES
     test_path.cpp
     test_point.cpp
     test_probe.cpp
-    test_segment.cpp
     test_range.cpp
+    test_segment.cpp
+    test_schedule.cpp
     test_rss_cell.cpp
     test_span.cpp
     test_spikes.cpp
     test_spike_store.cpp
+    test_stats.cpp
     test_stimulus.cpp
     test_strprintf.cpp
     test_swcio.cpp
@@ -79,6 +81,9 @@ set(TEST_SOURCES
 
     # unit test driver
     test.cpp
+
+    # common routines
+    stats.cpp
 )
 
 
diff --git a/tests/unit/stats.cpp b/tests/unit/stats.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..0e4a114df63e378b7257594355942aeb54ec1889
--- /dev/null
+++ b/tests/unit/stats.cpp
@@ -0,0 +1,157 @@
+#include <cmath>
+#include <numeric>
+#include <vector>
+
+#include "stats.hpp"
+
+namespace testing {
+namespace ks {
+
+/* Compute the exact cdf F(d) of the one-sample, two-sided Kolmogorov–Smirnov
+ * statisitc Dn for n observations, using the Dubin 1973 algorithm with the
+ * faster implementation of Carvalho 2015.
+ *
+ * Bounds on numerical applicability, and the lower bound of 1-10^-15 (giving a
+ * result of 1 in double precision) when n*d^2 ≥ 18 is from Simard and L'Ecuyer
+ * 2011.
+ *
+ * This function should implement the Pelz-Good asymptopic approximation, but
+ * doesn't. Instead it will throw an error if the parameters fall into a bad
+ * region.
+ *
+ * References:
+ *
+ * Durbin, J. 1973. "Distribution Theory for Tests Based on the Sample
+ *     Distribution Function". Society for Industrial Mathematics.
+ *     ISBN: 9780898710076
+ *     DOI: 10.1137/1.9781611970568.ch1
+ * 
+ * Carvalho, L. 2015. "An improved evaluation of Kolmogorov's distribution."
+ *     Journal of Statistical Software, Code Samples 65 (3), pp. 1-8.
+ *     DOI: 10.18637/jss.v065.c03
+ *
+ * Simard, R. and P. L'Ecuyer, 2011. "Computing the two-sided Kolmogorov-Smirnov distributiobnn."
+ *     Journal of Statistical Softwarem, Articles 39 (11), pp. 1-18.
+ *     DOI: 10.18637/jss.v039.i11
+ */
+
+double dn_cdf(double d, int  n) {
+    // Tail cases:
+
+    double nd = n*d;
+
+    if (d>=1 || nd*d>=18.37) {
+        return 1;
+    }
+
+    if (nd<=0.5) {
+        return 0;
+    }
+    else if (nd<=1) {
+        double x = 2*d-1./n;
+        double s = x;
+        for (int i = 2; i<=n; ++i) {
+            s *= x*i;
+        }
+        return s;
+    }
+    else if (nd>=n-1) {
+       return 1-2*std::pow(1-d, n);
+    }
+
+    // If n>=10^5, or n>140 and n·d²>0.3, throw an
+    // exception rather than risk bad numeric behaviour.
+    // Fix this if required by implementing Pelz-Good.
+
+    if (n>=1e5 || (n>140 && nd*d>0.3)) {
+        throw std::range_error("approximation for parameters not implemented");
+    }
+
+    int k = std::ceil(nd);
+    int m = 2*k-1;
+    double h = k-nd;
+
+    // Representation of matrix H [Dubin eq 2.4.3, Carvalho eq 6].
+    std::vector<double> data(3*m-2);
+    double* v = data.data(); // length m
+    double* q = v+m;  // length m
+    double* w = q+m;  // length m-2
+
+    // Initialize H representation.
+    double r=1;
+    double p=h;
+    for (int i=0; i<m-1; ++i) {
+        if (i<m-2) {
+            w[i] = r;
+        }
+        v[i] = (1-p)*r;
+        p *= h;
+        r /= i+2;
+    }
+    double pp = h<=0.5? 0: std::pow(2*h-1, m);
+    v[m-1] = (1-2*p+pp)*r;
+    q[k-1] = 1;
+
+    // Compute n!/n^n H(k,k).
+    auto dot = [](double* a, double* b, int n) {
+        return std::inner_product(a, a+n, b, 0.);
+    };
+
+    constexpr double scale = 1e140;
+    constexpr double ooscale = 1/scale;
+    int scale_pow = 0;
+
+    double s = 1;
+    double oon = 1.0/n;
+    for (int i = 1; i<=n; ++i) {
+        s = i*oon;
+
+        double qprev = q[0];
+        q[0] = s*dot(v, q, m);
+        for (int j = 1; j<m-1; ++j) {
+            double a = qprev;
+            qprev = q[j];
+            q[j] = s*(dot(w, q+j, m-j-1) + v[m-j-1]*q[m-1] + a);
+        }
+        q[m-1] = s*(v[0]*q[m-1] + qprev);
+
+        if (q[k-1]>scale) {
+            for (int i = 0; i<m; ++i) q[i] *= ooscale;
+            ++scale_pow;
+        }
+        else if (q[k-1]<ooscale) {
+            for (int i = 0; i<m; ++i) q[i] *= scale;
+            --scale_pow;
+        }
+    }
+
+    return scale_pow? q[k-1]*std::pow(scale, scale_pow): q[k-1];
+}
+
+} // namespace ks
+
+
+namespace poisson {
+
+/* Approximate cdf of Poisson(μ) by using Wilson-Hilferty transform
+ * and then the normal distribution cdf.
+ *
+ * See e.g.:
+ *
+ * Terrell, G. 2003. "The Wilson–Hilferty transformation is locally saddlepoint,"
+ *     Biometrika. 90 (2), pp. 445-453.
+ *     DOI: 10.1093/biomet/90.2.445
+ */
+
+double poisson_cdf_approx(int n, double mu) {
+    double x = std::pow(0.5+n, 2./3.);
+    double norm_mu = std::pow(mu, 2./3.)*(1-1./(9*mu));
+    double norm_sd = 2./3.*std::pow(mu, 1./6.)*(1+1./(24*mu));
+
+    return 0.5+0.5*std::erf((x-norm_mu)/(norm_sd*std::sqrt(2.)));
+}
+
+
+} // namespace poisson
+
+} // namespace testing
diff --git a/tests/unit/stats.hpp b/tests/unit/stats.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..b2430738055e16fa91cebed5cc1b203393a18458
--- /dev/null
+++ b/tests/unit/stats.hpp
@@ -0,0 +1,101 @@
+#pragma once
+
+#include <limits>
+
+#include <util/meta.hpp>
+
+/* For randomness, distribution testing we need some
+ * statistical tests... */
+
+namespace testing {
+
+// Simple summary statistics (min, max, mean, variance)
+// computed with on-line algorithm.
+//
+// Note that variance is population variance (i.e. with
+// denominator n, not n-1).
+
+struct summary_stats {
+    int n = 0;
+    double max = -std::numeric_limits<double>::infinity();
+    double min = std::numeric_limits<double>::infinity();
+    double mean = 0;
+    double variance = 0;
+};
+
+template <typename Seq>
+summary_stats summarize(const Seq& xs) {
+    summary_stats s;
+
+    for (auto x: xs) {
+        ++s.n;
+        s.min = x<s.min? x: s.min;
+        s.max = x>s.max? x: s.max;
+        double d1 = x-s.mean;
+        s.mean += d1/s.n;
+        double d2 = x-s.mean;
+        s.variance += d1*d2;
+    }
+
+    if (s.n) {
+        s.variance /= s.n;
+    }
+    return s;
+}
+
+// One-sample Kolmogorov-Smirnov test: test probability sample
+// comes from a continuous, 1-dimensional distribution.
+
+namespace ks {
+
+// Compute K-S test statistic:
+//
+// Input: n quantiles of the sample data F(x_1) ... F(x_n) in
+// increasing order, where F is the cdf of the specified distribution.
+//
+// Output: K-S test statistic Dn of the x_i.
+
+template <typename Seq>
+double dn_statistic(const Seq& qs) {
+    double n = static_cast<double>(nest::mc::util::size(qs));
+    double d = 0;
+    int j = 0;
+    for (auto q: qs) {
+        auto nq = n*q;
+        double d1 = nq-j;
+        ++j;
+        double d2 = j-nq;
+        d = std::max(d, std::max(d1, d2));
+    }
+    return d/n;
+}
+
+// One sample K-S test.
+// 
+// Input: K-S test statistic d, number of observations n.
+// Output: P(Dn < d) where Dn ~ one-sample K–S statistic for n observations.
+//
+// Note: the implementation does not cover the full parameter space;
+// if N>140 and n·u² in [0.3, 18], the algorithm used may give a poor
+// result. The Pelz-Good approximation should be used here, but it has
+// not yet been implemented.
+
+double dn_cdf(double d, int n);
+
+} // namespace ks
+
+// Functions related to the Poisson distribution.
+
+namespace poisson {
+
+// Approximate cdf of Poisson distribution using Wilson-Hilferty transform.
+//
+// Input: integer sample n.
+// Output: P(X < n) where X ~ Poisson(mu).
+
+double poisson_cdf_approx(int n, double mu);
+
+} // namespace poisson
+
+} // namespace testing
+
diff --git a/tests/unit/test_any.cpp b/tests/unit/test_any.cpp
index 1bfb0627d0e8ce50696c812fb9cb5b63554cc6f6..594cb63a61404f43b23d2657498ceb550221eefc 100644
--- a/tests/unit/test_any.cpp
+++ b/tests/unit/test_any.cpp
@@ -1,9 +1,13 @@
+//#include <iostream>
+#include <type_traits>
+
 #include "../gtest.h"
 #include "common.hpp"
 
-#include <iostream>
-
 #include <util/any.hpp>
+#include <util/any_ptr.hpp>
+
+#include <typeinfo>
 
 using namespace nest::mc;
 
@@ -322,5 +326,120 @@ TEST(any, make_any) {
         std::vector<int> ref{1, 2, 3};
         EXPECT_EQ(ref, *vec);
     }
+}
+
+// Tests below are for `any_ptr` class.
+
+TEST(any_ptr, ctor_and_assign) {
+    using util::any_ptr;
+
+    any_ptr p;
+
+    EXPECT_FALSE(p);
+    EXPECT_FALSE(p.has_value());
+
+    int x;
+    any_ptr q(&x);
+
+    EXPECT_TRUE(q);
+    EXPECT_TRUE(q.has_value());
+
+    p = q;
+
+    EXPECT_TRUE(p);
+    EXPECT_TRUE(p.has_value());
+
+    p = nullptr;
+
+    EXPECT_FALSE(p);
+    EXPECT_FALSE(p.has_value());
+
+    p = &x;
+
+    EXPECT_TRUE(p);
+    EXPECT_TRUE(p.has_value());
+
+    p.reset();
+
+    EXPECT_FALSE(p);
+    EXPECT_FALSE(p.has_value());
+
+    p.reset(&x);
+
+    EXPECT_TRUE(p);
+    EXPECT_TRUE(p.has_value());
+
+    p.reset(nullptr);
+
+    EXPECT_FALSE(p);
+    EXPECT_FALSE(p.has_value());
+
+    p = nullptr;
 
+    EXPECT_FALSE(p);
+    EXPECT_FALSE(p.has_value());
 }
+
+
+TEST(any_ptr, ordering) {
+    using util::any_ptr;
+
+    int x[2];
+    double y;
+
+    any_ptr a(&x[0]);
+    any_ptr b(&x[1]);
+
+    EXPECT_LT(a, b);
+    EXPECT_LE(a, b);
+    EXPECT_NE(a, b);
+    EXPECT_GE(b, a);
+    EXPECT_GT(b, a);
+    EXPECT_FALSE(a==b);
+
+    any_ptr c(&y);
+
+    EXPECT_NE(c, a);
+    EXPECT_TRUE(a<c || a>c);
+    EXPECT_FALSE(a==c);
+}
+
+TEST(any_ptr, as_and_type) {
+    using util::any_ptr;
+
+    int x = 0;
+    const int y = 0;
+    any_ptr p;
+
+    EXPECT_FALSE(p.as<int*>());
+
+    p = &y;
+    EXPECT_FALSE(p.as<int*>());
+    EXPECT_TRUE(p.as<const int*>());
+    EXPECT_EQ(typeid(const int*), p.type());
+
+    p = &x;
+    EXPECT_TRUE(p.as<int*>());
+    EXPECT_FALSE(p.as<const int*>());
+    EXPECT_EQ(typeid(int*), p.type());
+
+    *p.as<int*>() = 3;
+    EXPECT_EQ(3, x);
+}
+
+TEST(any_ptr, any_cast) {
+    using util::any_ptr;
+    using util::any_cast;
+
+    int x = 0;
+    any_ptr p;
+
+    auto c1 = any_cast<int*>(p);
+    EXPECT_FALSE(c1);
+    EXPECT_TRUE((std::is_same<int*, decltype(c1)>::value));
+
+    p = &x;
+    auto c2 = any_cast<int*>(p);
+    EXPECT_TRUE(c2);
+}
+
diff --git a/tests/unit/test_domain_decomposition.cpp b/tests/unit/test_domain_decomposition.cpp
index 90c00f5e6e4757369117f38f4f6bbf03fd63f165..f9724af498840e37779176d170f136411a922f78 100644
--- a/tests/unit/test_domain_decomposition.cpp
+++ b/tests/unit/test_domain_decomposition.cpp
@@ -1,52 +1,28 @@
 #include "../gtest.h"
 
+#include <stdexcept>
+
 #include <backends.hpp>
 #include <domain_decomposition.hpp>
 #include <hardware/node_info.hpp>
 #include <load_balance.hpp>
 
+#include "../simple_recipes.hpp"
+
 using namespace nest::mc;
 
 namespace {
-    //
-    // Dummy recipes type for testing.
-    //
-
-    // Homogenous cell population of cable cells.
-    class homo_recipe: public recipe {
-    public:
-        homo_recipe(cell_size_type s): size_(s)
-        {}
-
-        cell_size_type num_cells() const override {
-            return size_;
-        }
-
-        util::unique_any get_cell_description(cell_gid_type) const override {
-            return {};
-        }
-        cell_kind get_cell_kind(cell_gid_type) const override {
-            return cell_kind::cable1d_neuron;
-        }
+    // Dummy recipes types for testing.
 
-        cell_count_info get_cell_count_info(cell_gid_type) const override {
-            return {0, 0, 0};
-        }
-        std::vector<cell_connection> connections_on(cell_gid_type) const override {
-            return {};
-        }
-
-    private:
-        cell_size_type size_;
-    };
+    struct dummy_cell {};
+    using homo_recipe = homogeneous_recipe<cell_kind::cable1d_neuron, dummy_cell>;
 
     // Heterogenous cell population of cable and rss cells.
-    // Interleaved so that cells with even gid are cable cells, and even gid are
+    // Interleaved so that cells with even gid are cable cells, and odd gid are
     // rss cells.
     class hetero_recipe: public recipe {
     public:
-        hetero_recipe(cell_size_type s): size_(s)
-        {}
+        hetero_recipe(cell_size_type s): size_(s) {}
 
         cell_size_type num_cells() const override {
             return size_;
@@ -55,19 +31,25 @@ namespace {
         util::unique_any get_cell_description(cell_gid_type) const override {
             return {};
         }
+
         cell_kind get_cell_kind(cell_gid_type gid) const override {
             return gid%2?
                 cell_kind::regular_spike_source:
                 cell_kind::cable1d_neuron;
         }
 
-        cell_count_info get_cell_count_info(cell_gid_type) const override {
-            return {0, 0, 0};
-        }
+        cell_size_type num_sources(cell_gid_type) const override { return 0; }
+        cell_size_type num_targets(cell_gid_type) const override { return 0; }
+        cell_size_type num_probes(cell_gid_type) const override { return 0; }
+
         std::vector<cell_connection> connections_on(cell_gid_type) const override {
             return {};
         }
 
+        probe_info get_probe(cell_member_type) const override {
+            throw std::logic_error("no probes");
+        }
+
     private:
         cell_size_type size_;
     };
@@ -82,7 +64,7 @@ TEST(domain_decomposition, homogenous_population)
         hw::node_info nd(1, 0);
 
         unsigned num_cells = 10;
-        const auto D = partition_load_balance(homo_recipe(num_cells), nd);
+        const auto D = partition_load_balance(homo_recipe(num_cells, dummy_cell{}), nd);
 
         EXPECT_EQ(D.num_global_cells, num_cells);
         EXPECT_EQ(D.num_local_cells, num_cells);
@@ -110,7 +92,7 @@ TEST(domain_decomposition, homogenous_population)
         hw::node_info nd(1, 1);
 
         unsigned num_cells = 10;
-        const auto D = partition_load_balance(homo_recipe(num_cells), nd);
+        const auto D = partition_load_balance(homo_recipe(num_cells, dummy_cell{}), nd);
 
         EXPECT_EQ(D.num_global_cells, num_cells);
         EXPECT_EQ(D.num_local_cells, num_cells);
diff --git a/tests/unit/test_dss_cell_group.cpp b/tests/unit/test_dss_cell_group.cpp
index 5d853b10cb4f1e6d5b3ded429187805d46574321..8c841bc2f8ffe2eef3586a967c12b1358b8fed4c 100644
--- a/tests/unit/test_dss_cell_group.cpp
+++ b/tests/unit/test_dss_cell_group.cpp
@@ -1,75 +1,60 @@
 #include "../gtest.h"
 
-#include "dss_cell_description.hpp"
-#include "dss_cell_group.hpp"
+#include <dss_cell_description.hpp>
+#include <dss_cell_group.hpp>
 #include <util/unique_any.hpp>
 
+#include "../simple_recipes.hpp"
 
-using namespace  nest::mc;
+using namespace nest::mc;
 
-TEST(dss_cell, constructor)
-{
-    std::vector<time_type> spikes;
-
-    std::vector<util::unique_any> cell_descriptions(1);
-    cell_descriptions[0] = util::unique_any(dss_cell_description(spikes));
-
-    dss_cell_group sut({0}, cell_descriptions);
-}
+using dss_recipe = homogeneous_recipe<cell_kind::data_spike_source, dss_cell_description>;
 
 TEST(dss_cell, basic_usage)
 {
-    std::vector<time_type> spikes_to_emit;
-
-    time_type spike_time = 0.1;
-    spikes_to_emit.push_back(spike_time);
+    const time_type spike_time = 0.1;
+    dss_recipe rec(1u, dss_cell_description({spike_time}));
+    dss_cell_group sut({0}, rec);
 
-    std::vector<util::unique_any> cell_descriptions(1);
-    cell_descriptions[0] = util::unique_any(dss_cell_description(spikes_to_emit));
-
-    dss_cell_group sut({0}, cell_descriptions);
-
-    // no spikes in this time frame
-    sut.advance(0.09, 0.01);   // The dt (0,01) is not used
+    // No spikes in this time frame.
+    time_type dt = 0.01; // (note that dt is ignored in dss_cell_group).
+    sut.advance(0.09, dt);
 
     auto spikes = sut.spikes();
-    EXPECT_EQ(size_t(0), spikes.size());
+    EXPECT_EQ(0u, spikes.size());
 
-    // only one in this time frame
+    // Only one in this time frame.
     sut.advance(0.11, 0.01);
     spikes = sut.spikes();
-    EXPECT_EQ(size_t(1), spikes.size());
+    EXPECT_EQ(1u, spikes.size());
     ASSERT_FLOAT_EQ(spike_time, spikes[0].time);
 
-    // Clear the spikes after 'processing' them
+    // Clear the spikes after 'processing' them.
     sut.clear_spikes();
     spikes = sut.spikes();
-    EXPECT_EQ(size_t(0), spikes.size());
+    EXPECT_EQ(0u, spikes.size());
 
-    // No spike to be emitted
-    sut.advance(0.12, 0.01);
+    // No spike to be emitted.
+    sut.advance(0.12, dt);
     spikes = sut.spikes();
-    EXPECT_EQ(size_t(0), spikes.size());
+    EXPECT_EQ(0u, spikes.size());
 
-    // Reset the internal state to null
+    // Reset the internal state.
     sut.reset();
 
-    // Expect 10 excluding the 0.2
-    sut.advance(0.2, 0.01);
+    // Expect to have the one spike again after reset.
+    sut.advance(0.2, dt);
     spikes = sut.spikes();
-    EXPECT_EQ(size_t(1), spikes.size());
+    EXPECT_EQ(1u, spikes.size());
     ASSERT_FLOAT_EQ(spike_time, spikes[0].time);
 }
 
 
 TEST(dss_cell, cell_kind_correct)
 {
-    std::vector<time_type> spikes_to_emit;
-
-    std::vector<util::unique_any> cell_descriptions(1);
-    cell_descriptions[0] = util::unique_any(dss_cell_description(spikes_to_emit));
-
-    dss_cell_group sut({0}, cell_descriptions);
+    const time_type spike_time = 0.1;
+    dss_recipe rec(1u, dss_cell_description({spike_time}));
+    dss_cell_group sut({0}, rec);
 
     EXPECT_EQ(cell_kind::data_spike_source, sut.get_cell_kind());
 }
diff --git a/tests/unit/test_fvm_multi.cpp b/tests/unit/test_fvm_multi.cpp
index 6a9863191e2f71d4bc1733caabb15fc4311cda55..78057b856024884a3576f70a2c369a4c9dc2a3c6 100644
--- a/tests/unit/test_fvm_multi.cpp
+++ b/tests/unit/test_fvm_multi.cpp
@@ -7,11 +7,14 @@
 #include <cell.hpp>
 #include <common_types.hpp>
 #include <fvm_multicell.hpp>
+#include <recipe.hpp>
+#include <sampler_map.hpp>
 #include <util/meta.hpp>
 #include <util/rangeutil.hpp>
 
 #include "../test_util.hpp"
-#include "../test_common_cells.hpp"
+#include "../common_cells.hpp"
+#include "../simple_recipes.hpp"
 
 using fvm_cell =
     nest::mc::fvm::fvm_multicell<nest::mc::multicore::backend>;
@@ -23,10 +26,10 @@ TEST(fvm_multi, cable)
     nest::mc::cell cell=make_cell_ball_and_3stick();
 
     std::vector<fvm_cell::target_handle> targets;
-    std::vector<fvm_cell::probe_handle> probes;
+    probe_association_map<fvm_cell::probe_handle> probe_map;
 
     fvm_cell fvcell;
-    fvcell.initialize(util::singleton_view(cell), targets, probes);
+    fvcell.initialize({0}, cable1d_recipe(cell), targets, probe_map);
 
     auto& J = fvcell.jacobian();
 
@@ -66,10 +69,10 @@ TEST(fvm_multi, init)
     cell.segment(1)->set_compartments(10);
 
     std::vector<fvm_cell::target_handle> targets;
-    std::vector<fvm_cell::probe_handle> probes;
+    probe_association_map<fvm_cell::probe_handle> probe_map;
 
     fvm_cell fvcell;
-    fvcell.initialize(util::singleton_view(cell), targets, probes);
+    fvcell.initialize({0}, cable1d_recipe(cell), targets, probe_map);
 
     // This is naughty: removing const from the matrix reference, but is needed
     // to test the build_matrix() method below (which is only accessable
@@ -103,7 +106,6 @@ TEST(fvm_multi, init)
     };
     EXPECT_TRUE(is_neg(mat.u(1, J.size())));
     EXPECT_TRUE(is_pos(mat.d));
-
 }
 
 TEST(fvm_multi, multi_init)
@@ -129,11 +131,13 @@ TEST(fvm_multi, multi_init)
 
     cells[1].add_detector({0, 0}, 3.3);
 
-    std::vector<fvm_cell::target_handle> targets(4);
-    std::vector<fvm_cell::probe_handle> probes;
+    std::vector<fvm_cell::target_handle> targets;
+    probe_association_map<fvm_cell::probe_handle> probe_map;
 
     fvm_cell fvcell;
-    fvcell.initialize(cells, targets, probes);
+    fvcell.initialize({0, 1}, cable1d_recipe(cells), targets, probe_map);
+
+    EXPECT_EQ(4u, targets.size());
 
     auto& J = fvcell.jacobian();
     EXPECT_EQ(J.size(), 5u+13u);
@@ -169,7 +173,6 @@ TEST(fvm_multi, multi_init)
 TEST(fvm_multi, stimulus)
 {
     using namespace nest::mc;
-    using util::singleton_view;
 
     // the default ball and stick has one stimulus at the terminal end of the dendrite
     auto cell = make_cell_ball_and_stick();
@@ -191,10 +194,10 @@ TEST(fvm_multi, stimulus)
     // as during the stimulus windows.
 
     std::vector<fvm_cell::target_handle> targets;
-    std::vector<fvm_cell::probe_handle> probes;
+    probe_association_map<fvm_cell::probe_handle> probe_map;
 
     fvm_cell fvcell;
-    fvcell.initialize(singleton_view(cell), targets, probes);
+    fvcell.initialize({0}, cable1d_recipe(cell), targets, probe_map);
 
     auto ref = fvcell.find_mechanism("stimulus");
     ASSERT_TRUE(ref) << "no stimuli retrieved from lowered fvm cell: expected 2";
@@ -283,10 +286,10 @@ TEST(fvm_multi, mechanism_indexes)
 
     // generate the lowered fvm cell
     std::vector<fvm_cell::target_handle> targets;
-    std::vector<fvm_cell::probe_handle> probes;
+    probe_association_map<fvm_cell::probe_handle> probe_map;
 
     fvm_cell fvcell;
-    fvcell.initialize(util::singleton_view(c), targets, probes);
+    fvcell.initialize({0}, cable1d_recipe(c), targets, probe_map);
 
     // make vectors with the expected CV indexes for each mechanism
     std::vector<unsigned> hh_index  = {0u, 4u, 5u, 6u, 7u, 8u};
@@ -380,11 +383,11 @@ void run_target_handle_test(std::vector<handle_info> all_handles) {
     }
 
     auto n = all_handles.size();
-    std::vector<fvm_cell::target_handle> targets(n);
-    std::vector<fvm_cell::probe_handle> probes;
+    std::vector<fvm_cell::target_handle> targets;
+    probe_association_map<fvm_cell::probe_handle> probe_map;
 
     fvm_cell fvcell;
-    fvcell.initialize(cells, targets, probes);
+    fvcell.initialize({0, 1}, cable1d_recipe(cells), targets, probe_map);
 
     ASSERT_EQ(n, util::size(targets));
     unsigned i = 0;
diff --git a/tests/unit/test_mc_cell_group.cpp b/tests/unit/test_mc_cell_group.cpp
index 4c157cb1e71578d7cc2f9982bde90c4ccd58f334..4d5b14692e3150d59c70fa40624a5aface3b91b4 100644
--- a/tests/unit/test_mc_cell_group.cpp
+++ b/tests/unit/test_mc_cell_group.cpp
@@ -7,7 +7,8 @@
 #include <util/rangeutil.hpp>
 
 #include "common.hpp"
-#include "../test_common_cells.hpp"
+#include "../common_cells.hpp"
+#include "../simple_recipes.hpp"
 
 using namespace nest::mc;
 using fvm_cell = fvm::fvm_multicell<nest::mc::multicore::backend>;
@@ -21,16 +22,15 @@ cell make_cell() {
     return c;
 }
 
-
 TEST(mc_cell_group, get_kind) {
-    mc_cell_group<fvm_cell> group{ {0}, util::singleton_view(make_cell()) };
+    mc_cell_group<fvm_cell> group{{0}, cable1d_recipe(make_cell()) };
 
     // we are generating a mc_cell_group which should be of the correct type
     EXPECT_EQ(cell_kind::cable1d_neuron, group.get_cell_kind());
 }
 
 TEST(mc_cell_group, test) {
-    mc_cell_group<fvm_cell> group{{0}, util::singleton_view(make_cell())};
+    mc_cell_group<fvm_cell> group{{0}, cable1d_recipe(make_cell()) };
 
     group.advance(50, 0.01);
 
@@ -40,28 +40,34 @@ TEST(mc_cell_group, test) {
 }
 
 TEST(mc_cell_group, sources) {
-    using cell_group_type = mc_cell_group<fvm_cell>;
+    // Make twenty cells, with an extra detector on gids 0, 3 and 17
+    // to make things more interesting.
+    std::vector<cell> cells;
+
+    for (int i=0; i<20; ++i) {
+        cells.push_back(make_cell());
+        if (i==0 || i==3 || i==17) {
+            cells.back().add_detector({1, 0.3}, 2.3);
+        }
 
-    auto cell = make_cell();
-    EXPECT_EQ(cell.detectors().size(), 1u);
-    // add another detector on the cell to make things more interesting
-    cell.add_detector({1, 0.3}, 2.3);
+        EXPECT_EQ(1u + (i==0 || i==3 || i==17), cells.back().detectors().size());
+    }
 
-    cell_gid_type first_gid = 37u;
-    auto group = cell_group_type{{first_gid}, util::singleton_view(cell)};
+    std::vector<cell_gid_type> gids = {3u, 4u, 10u, 16u, 17u, 18u};
+    mc_cell_group<fvm_cell> group{gids, cable1d_recipe(cells)};
 
-    // expect group sources to be lexicographically sorted by source id
-    // with gids in cell group's range and indices starting from zero
+    // Expect group sources to be lexicographically sorted by source id
+    // with gids in cell group's range and indices starting from zero.
 
     const auto& sources = group.spike_sources();
-    for (unsigned i = 0; i<sources.size(); ++i) {
-        auto id = sources[i];
-        if (i==0) {
-            EXPECT_EQ(id.gid, first_gid);
+    for (unsigned j = 0; j<sources.size(); ++j) {
+        auto id = sources[j];
+        if (j==0) {
+            EXPECT_EQ(id.gid, gids[0]);
             EXPECT_EQ(id.index, 0u);
         }
         else {
-            auto prev = sources[i-1];
+            auto prev = sources[j-1];
             EXPECT_GT(id, prev);
             EXPECT_EQ(id.index, id.gid==prev.gid? prev.index+1: 0u);
         }
diff --git a/tests/unit/test_probe.cpp b/tests/unit/test_probe.cpp
index 8ea355eaf261e2610d8ba736244fe49232b29f1d..1caa2a53b0bbb6a52d9de8f7cb53e07a5ee04431 100644
--- a/tests/unit/test_probe.cpp
+++ b/tests/unit/test_probe.cpp
@@ -6,79 +6,60 @@
 #include <fvm_multicell.hpp>
 #include <util/rangeutil.hpp>
 
-TEST(probe, instantiation)
-{
-    using namespace nest::mc;
-
-    cell c1;
-
-    segment_location loc1{0, 0};
-    segment_location loc2{1, 0.6};
-
-    auto p1 = c1.add_probe({loc1, probeKind::membrane_voltage});
-    auto p2 = c1.add_probe({loc2, probeKind::membrane_current});
-
-    // expect locally provided probe ids to be numbered sequentially from zero.
-
-    EXPECT_EQ(0u, p1);
-    EXPECT_EQ(1u, p2);
-
-    // expect the probes() return to be a collection with these two probes.
-
-    auto probes = c1.probes();
-    EXPECT_EQ(2u, probes.size());
+#include "../common_cells.hpp"
+#include "../simple_recipes.hpp"
 
-    EXPECT_EQ(loc1, probes[0].location);
-    EXPECT_EQ(probeKind::membrane_voltage, probes[0].kind);
-
-    EXPECT_EQ(loc2, probes[1].location);
-    EXPECT_EQ(probeKind::membrane_current, probes[1].kind);
-}
+using namespace nest::mc;
 
 TEST(probe, fvm_multicell)
 {
-    using namespace nest::mc;
+    using fvm_cell = fvm::fvm_multicell<nest::mc::multicore::backend>;
 
-    cell bs;
+    cell bs = make_cell_ball_and_stick(false);
 
-    // ball-and-stick model morphology
+    i_clamp stim(0, 100, 0.3);
+    bs.add_stimulus({1, 1}, stim);
 
-    bs.add_soma(12.6157/2.0);
-    bs.add_cable(0, section_kind::dendrite, 0.5, 0.5, 200);
-    bs.soma()->set_compartments(5);
+    cable1d_recipe rec(bs);
 
     segment_location loc0{0, 0};
     segment_location loc1{1, 1};
-    segment_location loc2{1, 0.5};
+    segment_location loc2{1, 0.7};
 
-    bs.add_probe({loc0, probeKind::membrane_voltage});
-    bs.add_probe({loc1, probeKind::membrane_voltage});
-    bs.add_probe({loc2, probeKind::membrane_current});
+    rec.add_probe(0, 10, cell_probe_address{loc0, cell_probe_address::membrane_voltage});
+    rec.add_probe(0, 20, cell_probe_address{loc1, cell_probe_address::membrane_voltage});
+    rec.add_probe(0, 30, cell_probe_address{loc2, cell_probe_address::membrane_current});
 
-    i_clamp stim(0, 100, 0.3);
-    bs.add_stimulus({1, 1}, stim);
+    std::vector<fvm_cell::target_handle> targets;
+    probe_association_map<fvm_cell::probe_handle> probe_map;
+
+    fvm_cell lcell;
+    lcell.initialize({0}, rec, targets, probe_map);
+
+    EXPECT_EQ(3u, rec.num_probes(0));
+    EXPECT_EQ(3u, probe_map.size());
 
-    using fvm_multicell = fvm::fvm_multicell<nest::mc::multicore::backend>;
-    std::vector<fvm_multicell::target_handle> targets;
-    std::vector<fvm_multicell::probe_handle> probes{3};
+    EXPECT_EQ(10, probe_map.at({0, 0}).tag);
+    EXPECT_EQ(20, probe_map.at({0, 1}).tag);
+    EXPECT_EQ(30, probe_map.at({0, 2}).tag);
 
-    fvm_multicell lcell;
-    lcell.initialize(util::singleton_view(bs), targets, probes);
+    fvm_cell::probe_handle p0 = probe_map.at({0, 0}).handle;
+    fvm_cell::probe_handle p1 = probe_map.at({0, 1}).handle;
+    fvm_cell::probe_handle p2 = probe_map.at({0, 2}).handle;
 
     // Know from implementation that probe_handle.second
     // is a compartment index: expect probe values and
-    // direct queries of voltage and current
-    // to be equal in fvm cell
+    // direct queries of voltage and current to be equal in fvm_cell.
 
-    EXPECT_EQ(lcell.voltage()[probes[0].second], lcell.probe(probes[0]));
-    EXPECT_EQ(lcell.voltage()[probes[1].second], lcell.probe(probes[1]));
-    EXPECT_EQ(lcell.current()[probes[2].second], lcell.probe(probes[2]));
+    EXPECT_EQ(lcell.voltage()[p0.second], lcell.probe(p0));
+    EXPECT_EQ(lcell.voltage()[p1.second], lcell.probe(p1));
+    EXPECT_EQ(lcell.current()[p2.second], lcell.probe(p2));
 
     lcell.setup_integration(0.05, 0.05);
     lcell.step_integration();
 
-    EXPECT_EQ(lcell.voltage()[probes[0].second], lcell.probe(probes[0]));
-    EXPECT_EQ(lcell.voltage()[probes[1].second], lcell.probe(probes[1]));
-    EXPECT_EQ(lcell.current()[probes[2].second], lcell.probe(probes[2]));
+    EXPECT_EQ(lcell.voltage()[p0.second], lcell.probe(p0));
+    EXPECT_EQ(lcell.voltage()[p1.second], lcell.probe(p1));
+    EXPECT_EQ(lcell.current()[p2.second], lcell.probe(p2));
 }
 
diff --git a/tests/unit/test_range.cpp b/tests/unit/test_range.cpp
index e058604f7b31d1341328f42ee06efba97eaf550e..52e054eed48fd6de96920f19889ee0254efecc7a 100644
--- a/tests/unit/test_range.cpp
+++ b/tests/unit/test_range.cpp
@@ -22,6 +22,7 @@
 
 using namespace nest::mc;
 using testing::null_terminated;
+using testing::nocopy;
 
 TEST(range, list_iterator) {
     std::list<int> l = { 2, 4, 6, 8, 10 };
@@ -477,6 +478,43 @@ TEST(range, all_of_any_of) {
     EXPECT_TRUE(util::any_of(cstr("87654x"), pred));
 }
 
+TEST(range, keys) {
+    {
+        std::map<int, double> map = {{10, 2.0}, {3, 8.0}};
+        std::vector<int> expected = {3, 10};
+        std::vector<int> keys = util::assign_from(util::keys(map));
+        EXPECT_EQ(expected, keys);
+    }
+
+    {
+        struct cmp {
+            bool operator()(const nocopy<int>& a, const nocopy<int>& b) const {
+                return a.value<b.value;
+            }
+        };
+        std::map<nocopy<int>, double, cmp> map;
+        map.insert(std::pair<nocopy<int>, double>(11, 2.0));
+        map.insert(std::pair<nocopy<int>, double>(2,  0.3));
+        map.insert(std::pair<nocopy<int>, double>(2,  0.8));
+        map.insert(std::pair<nocopy<int>, double>(5,  0.1));
+
+        std::vector<int> expected = {2, 5, 11};
+        std::vector<int> keys;
+        for (auto& k: util::keys(map)) {
+            keys.push_back(k.value);
+        }
+        EXPECT_EQ(expected, keys);
+    }
+
+    {
+        std::unordered_multimap<int, double> map = {{3, 0.1}, {5, 0.4}, {11, 0.8}, {5, 0.2}};
+        std::vector<int> expected = {3, 5, 5, 11};
+        std::vector<int> keys = util::assign_from(util::keys(map));
+        util::sort(keys);
+        EXPECT_EQ(expected, keys);
+    }
+}
+
 #ifdef NMC_HAVE_TBB
 
 TEST(range, tbb_split) {
diff --git a/tests/unit/test_rss_cell.cpp b/tests/unit/test_rss_cell.cpp
index e12bbb472743db453a4097bfae2bcf5293326996..5812c2c2e35b888f54d5fa79d823bf0647d8c7f9 100644
--- a/tests/unit/test_rss_cell.cpp
+++ b/tests/unit/test_rss_cell.cpp
@@ -1,57 +1,68 @@
 #include "../gtest.h"
 
-#include "rss_cell.hpp"
+#include <rss_cell.hpp>
+#include <rss_cell_group.hpp>
 
-using namespace  nest::mc;
+#include "../simple_recipes.hpp"
 
-TEST(rss_cell, constructor)
-{
-    rss_cell test(0.0, 0.01, 1.0);
-}
+using namespace nest::mc;
+
+using rss_recipe = homogeneous_recipe<cell_kind::regular_spike_source, rss_cell>;
 
 TEST(rss_cell, basic_usage)
 {
-    rss_cell sut(0.1, 0.01, 0.2);
+    constexpr time_type dt = 0.01; // dt is ignored by rss_cell_group::advance().
 
-    // no spikes in this time frame
-    auto spikes = sut.spikes_until(0.09);
-    EXPECT_EQ(size_t(0), spikes.size());
+    // Use floating point times with an exact representation in order to avoid
+    // rounding issues.
+    rss_cell desc{0.125, 0.03125, 0.5};
+    rss_cell_group sut({0}, rss_recipe(1u, desc));
 
-    //only on in this time frame
-    spikes = sut.spikes_until(0.11);
-    EXPECT_EQ(size_t(1), spikes.size());
+    // No spikes in this time frame.
+    sut.advance(0.1, dt);
+    EXPECT_EQ(0u, sut.spikes().size());
 
-    // Reset the internal state to null
+    // Only on in this time frame
+    sut.clear_spikes();
+    sut.advance(0.127, dt);
+    EXPECT_EQ(1u, sut.spikes().size());
+
+    // Reset cell group state.
     sut.reset();
 
-    // Expect 10 excluding the 0.2
-    spikes = sut.spikes_until(0.2);
-    EXPECT_EQ(size_t(10), spikes.size());
+    // Expect 12 spikes excluding the 0.5 end point.
+    sut.advance(0.5, dt);
+    EXPECT_EQ(12u, sut.spikes().size());
 }
 
 TEST(rss_cell, poll_time_after_end_time)
 {
-    rss_cell sut(0.1, 0.01, 0.2);
+    constexpr time_type dt = 0.01; // dt is ignored by rss_cell_group::advance().
+
+    rss_cell desc{0.125, 0.03125, 0.5};
+    rss_cell_group sut({0}, rss_recipe(1u, desc));
 
-    // no spikes in this time frame
-    auto spikes = sut.spikes_until(0.3);
-    EXPECT_EQ(size_t(10), spikes.size());
+    // Expect 12 spikes in this time frame.
+    sut.advance(0.7, dt);
+    EXPECT_EQ(12u, sut.spikes().size());
 
-    // now ask for spikes for a time slot already passed.
-    spikes = sut.spikes_until(0.2);
+    // Now ask for spikes for a time slot already passed:
     // It should result in zero spikes because of the internal state!
-    EXPECT_EQ(size_t(0), spikes.size());
+    sut.clear_spikes();
+    sut.advance(0.2, dt);
+    EXPECT_EQ(0u, sut.spikes().size());
 
     sut.reset();
 
-    // Expect 10 excluding the 0.2
-    spikes = sut.spikes_until(0.2);
-    EXPECT_EQ(size_t(10), spikes.size());
+    // Expect 12 excluding the 0.5
+    sut.advance(0.5, dt);
+    EXPECT_EQ(12u, sut.spikes().size());
 }
 
 TEST(rss_cell, cell_kind_correct)
 {
-    rss_cell sut(0.1, 0.01, 0.2);
+    rss_cell desc{0.1, 0.01, 0.2};
+    rss_cell_group sut({0}, rss_recipe(1u, desc));
 
     EXPECT_EQ(cell_kind::regular_spike_source, sut.get_cell_kind());
 }
diff --git a/tests/unit/test_schedule.cpp b/tests/unit/test_schedule.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..055b17eebfeea64c46497481cfd36f2a7f6a3642
--- /dev/null
+++ b/tests/unit/test_schedule.cpp
@@ -0,0 +1,305 @@
+#include <algorithm>
+#include <random>
+#include <stdexcept>
+#include <vector>
+
+#include <common_types.hpp>
+#include <schedule.hpp>
+#include <util/partition.hpp>
+#include <util/rangeutil.hpp>
+
+#include "common.hpp"
+#include "stats.hpp"
+
+using namespace nest::mc;
+using namespace testing;
+
+// Pull events from n non-contiguous subintervals of [t0, t1)
+// and check for monotonicity and boundedness.
+
+void run_invariant_checks(schedule S, time_type t0, time_type t1, unsigned n, int seed=0) {
+    if (!n) return;
+
+    std::minstd_rand R(seed);
+    std::uniform_real_distribution<time_type> U(t0, t1);
+
+    std::vector<time_type> divisions = {t0, t1};
+    std::generate_n(std::back_inserter(divisions), 2*(n-1), [&] { return U(R); });
+    util::sort(divisions);
+
+    bool skip = false;
+    for (auto ival: util::partition_view(divisions)) {
+        if (!skip) {
+            auto ts = S.events(ival.first, ival.second);
+
+            EXPECT_TRUE(std::is_sorted(ts.begin(), ts.end()));
+            if (!ts.empty()) {
+                EXPECT_LE(t0, ts.front());
+                EXPECT_GT(t1, ts.back());
+            }
+        }
+        skip = !skip;
+    }
+}
+
+// Take events from n contiguous intervals comprising [t0, t1), reset, and
+// then compare with events taken from a different set of contiguous
+// intervals comprising [t0, t1).
+
+void run_reset_check(schedule S, time_type t0, time_type t1, unsigned n, int seed=0) {
+    if (!n) return;
+
+    std::minstd_rand R(seed);
+    std::uniform_real_distribution<time_type> U(t0, t1);
+
+    std::vector<time_type> first_div = {t0, t1};
+    std::generate_n(std::back_inserter(first_div), n-1, [&] { return U(R); });
+    util::sort(first_div);
+
+    std::vector<time_type> second_div = {t0, t1};
+    std::generate_n(std::back_inserter(second_div), n-1, [&] { return U(R); });
+    util::sort(second_div);
+
+    std::vector<time_type> first;
+    for (auto ival: util::partition_view(first_div)) {
+        util::append(first, S.events(ival.first, ival.second));
+    }
+
+    S.reset();
+    std::vector<time_type> second;
+    for (auto ival: util::partition_view(second_div)) {
+        util::append(second, S.events(ival.first, ival.second));
+    }
+
+    EXPECT_EQ(first, second);
+}
+
+TEST(schedule, regular) {
+    // Use exact fp representations for strict equality testing.
+    std::vector<time_type> expected = {0, 0.25, 0.5, 0.75, 1.0};
+
+    schedule S = regular_schedule(0.25);
+    EXPECT_EQ(expected, S.events(0, 1.25));
+
+    S.reset();
+    EXPECT_EQ(expected, S.events(0, 1.25));
+
+    S.reset();
+    expected = {0.25, 0.5, 0.75, 1.0};
+    EXPECT_EQ(expected, S.events(0.1, 1.01));
+}
+
+TEST(schedule, regular_invariants) {
+    SCOPED_TRACE("regular_invariants");
+    run_invariant_checks(regular_schedule(0.3), 3, 12, 7);
+}
+
+TEST(schedule, regular_reset) {
+    SCOPED_TRACE("regular_reset");
+    run_reset_check(regular_schedule(0.3), 3, 12, 7);
+}
+
+TEST(schedule, explicit_schedule) {
+    time_type times[] = {0.1, 0.3, 1.0, 1.25, 1.7, 2.2};
+    std::vector<time_type> expected = {0.1, 0.3, 1.0};
+
+    schedule S = explicit_schedule(times);
+    EXPECT_EQ(expected, S.events(0, 1.25));
+
+    S.reset();
+    EXPECT_EQ(expected, S.events(0, 1.25));
+
+    S.reset();
+    expected = {0.3, 1.0, 1.25, 1.7};
+    EXPECT_EQ(expected, S.events(0.3, 1.71));
+}
+
+TEST(schedule, explicit_invariants) {
+    SCOPED_TRACE("explicit_invariants");
+
+    time_type times[] = {0.1, 0.3, 0.4, 0.42, 2.1, 2.3, 6.01, 9, 9.1, 9.8, 10, 11.2, 13};
+    run_invariant_checks(explicit_schedule(times), 0.4, 10.2, 5);
+}
+
+TEST(schedule, explicit_reset) {
+    SCOPED_TRACE("explicit_reset");
+
+    time_type times[] = {0.1, 0.3, 0.4, 0.42, 2.1, 2.3, 6.01, 9, 9.1, 9.8, 10, 11.2, 13};
+    run_reset_check(explicit_schedule(times), 0.4, 10.2, 5);
+}
+
+// A Uniform Random Bit Generator[*] adaptor that deliberately
+// skews the generated numbers by raising their quantile to
+// the given power.
+//
+// [*] Not actually uniform.
+
+template <typename RNG>
+struct skew_adaptor {
+    using result_type = typename RNG::result_type;
+    static constexpr result_type min() { return RNG::min(); }
+    static constexpr result_type max() { return RNG::max(); }
+
+    explicit skew_adaptor(double power): power_(power) {}
+    result_type operator()() {
+        constexpr double scale = max()-min();
+        constexpr double ooscale = 1./scale;
+
+        double x = ooscale*(G_()-min());
+        x = std::pow(x, power_);
+        return min()+scale*x;
+    }
+
+private:
+    RNG G_;
+    double power_;
+};
+
+template <typename RNG>
+double poisson_schedule_dispersion(int nbin, double mean_dt, RNG& G) {
+    schedule S = poisson_schedule(mean_dt, G);
+
+    std::vector<int> bin(nbin);
+    for (auto t: S.events(0, nbin)) {
+        int j = (int)t;
+        if (j<0 || j>=nbin) {
+            throw std::logic_error("poisson schedule result out of bounds");
+        }
+        ++bin[j];
+    }
+
+    summary_stats stats = summarize(bin);
+    return stats.mean/stats.variance;
+}
+
+// NOTE: schedule.poisson_uniformity tests can be expected to
+// fail approximately 1% of the time, if the underlying
+// random sequence were allowed to vary freely.
+
+TEST(schedule, poisson_uniformity) {
+    // Run Poisson dispersion test for N=101 with two-sided
+    // χ²-test critical value α=0.01.
+    //
+    // Test based on: N·dispersion ~ χ²(N-1) (approximately)
+    // 
+    // F(chi2_lb; N-1) = α/2
+    // F(chi2_ub; N-1) = 1-α/2
+    //
+    // Numbers taken from scipy:
+    //    scipy.stats.chi2.isf(0.01/2, 1000)
+    //    scipy.stats.chi2.isf(1-0.01/2, 1000)
+
+    constexpr int N = 1001;
+    //constexpr double alpha = 0.01;
+    constexpr double chi2_lb = 888.56352318146696;
+    constexpr double chi2_ub = 1118.9480663231843;
+
+    std::mt19937_64 G;
+    double dispersion = poisson_schedule_dispersion(N, 1.23, G);
+    double test_value = N*dispersion;
+    EXPECT_GT(test_value, chi2_lb);
+    EXPECT_LT(test_value, chi2_ub);
+
+    // Run one sample K-S test for uniformity, with critical
+    // value for the finite K-S statistic Dn of α=0.01.
+
+    schedule S = poisson_schedule(1./100, G);
+    auto events = S.events(0,1);
+    int n = (int)events.size();
+    double dn = ks::dn_statistic(events);
+
+    EXPECT_LT(ks::dn_cdf(dn, n), 0.99);
+
+    // Check that these tests fail for a non-Poisson
+    // source.
+
+    skew_adaptor<std::mt19937_64> W(1.5);
+    dispersion = poisson_schedule_dispersion(N, 1.23, W);
+    test_value = N*dispersion;
+
+    EXPECT_FALSE(test_value>=chi2_lb && test_value<=chi2_ub);
+
+    S = poisson_schedule(1./100, W);
+    events = S.events(0,1);
+    n = (int)events.size();
+    dn = ks::dn_statistic(events);
+
+    // This test is currently failing, because we can't
+    // use a sufficiently high `n` in the `dn_cdf` function
+    // to get enough discrimination from the K-S test at
+    // 1%. TODO: Fix this by implementing n>140 case in
+    // `dn_cdf`.
+
+    // EXPECT_GT(ks::dn_cdf(dn, n), 0.99);
+}
+
+TEST(schedule, poisson_rate) {
+    // Test Poisson events over an interval against
+    // corresponding Poisson distribution.
+
+    constexpr double alpha = 0.01;
+    constexpr double lambda = 123.4;
+
+    std::mt19937_64 G;
+    schedule S = poisson_schedule(1./lambda, G);
+    int n = (int)S.events(0,1).size();
+    double cdf = poisson::poisson_cdf_approx(n, lambda);
+
+    EXPECT_GT(cdf, alpha/2);
+    EXPECT_LT(cdf, 1-alpha/2);
+
+    // Check that the test fails for a non-Poisson
+    // source.
+
+    skew_adaptor<std::mt19937_64> W(1.5);
+    S = poisson_schedule(1./lambda, W);
+    n = (int)S.events(0,1).size();
+    cdf = poisson::poisson_cdf_approx(n, lambda);
+
+    EXPECT_FALSE(cdf>=alpha/2 && cdf<=1-alpha/2);
+}
+
+TEST(schedule, poisson_invariants) {
+    SCOPED_TRACE("poisson_invariants");
+    std::mt19937_64 G;
+    G.discard(100);
+    run_invariant_checks(poisson_schedule(12.3, G), 5.1, 15.3, 7);
+}
+
+TEST(schedule, poisson_reset) {
+    SCOPED_TRACE("poisson_reset");
+    std::mt19937_64 G;
+    G.discard(200);
+    run_reset_check(poisson_schedule(9.1, G), 1, 10, 7);
+}
+
+TEST(schedule, poisson_offset) {
+    // Expect Poisson schedule with an offset to give exactly the
+    // same sequence, after the offset, as a regular zero-based Poisson.
+
+    const double offset = 3.3;
+
+    std::mt19937_64 G1;
+    G1.discard(300);
+
+    std::vector<time_type> expected;
+    for (auto t: poisson_schedule(3.4, G1).events(0., 100.)) {
+        t += offset;
+        if (t<100.) {
+            expected.push_back(t);
+        }
+    }
+
+    std::mt19937_64 G2;
+    G2.discard(300);
+
+    EXPECT_TRUE(seq_almost_eq<time_type>(expected, poisson_schedule(offset, 3.4, G2).events(0., 100.)));
+}
+
+TEST(schedule, poisson_offset_reset) {
+    SCOPED_TRACE("poisson_reset");
+    std::mt19937_64 G;
+    G.discard(400);
+    run_reset_check(poisson_schedule(0.3, 9.1, G), 1, 10, 7);
+}
+
diff --git a/tests/unit/test_stats.cpp b/tests/unit/test_stats.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..e9c84ade8005ea2762cd1d2e0ac11ce58e830de1
--- /dev/null
+++ b/tests/unit/test_stats.cpp
@@ -0,0 +1,157 @@
+#include <string>
+#include <string>
+
+
+#include "common.hpp"
+#include "stats.hpp"
+
+/* Unit tests for the test statistics routines */
+
+using namespace testing;
+
+TEST(stats, dn_statistic) {
+    // Dn should equal the maximum vertical distance between the
+    // empirical distribution and the straight line from (0,0) to
+    // (1,1).
+
+    // Expected: statistic=0.75 (empirical cdf should be 1 at 0.25).
+    double x1[] = {0.25};
+    EXPECT_DOUBLE_EQ(0.75, ks::dn_statistic(x1));
+
+    // Expected: statistic=0.55 (empirical cdf should be 0.75 at 0.2).
+    double x2[] = {0.1, 0.15, 0.2, 0.8};
+    EXPECT_DOUBLE_EQ(0.55, ks::dn_statistic(x2));
+
+    // Expected: statistic=1 (empirical cdf should be 0 at 1¯).
+    double x3[] = {1.0};
+    EXPECT_DOUBLE_EQ(1.0, ks::dn_statistic(x3));
+}
+
+TEST(stats, dn_cdf) {
+    using std::pow;
+    using std::tgamma;
+
+    // Compute cdf F(x; n) of Dn for some known values and accuracies.
+    // (See e.g. Simard and L'Ecuyer 2011.).
+
+    // Zero and one tails:
+    // 0 if x ≤ 1/2n; 1 if x ≥ 1.
+
+    EXPECT_EQ(0.0, ks::dn_cdf(0.0, 1));
+    EXPECT_EQ(0.0, ks::dn_cdf(0.0, 10));
+    EXPECT_EQ(0.0, ks::dn_cdf(0.01, 10));
+    EXPECT_EQ(0.0, ks::dn_cdf(0.04999, 10));
+    EXPECT_EQ(0.0, ks::dn_cdf(0.00004999, 10000));
+    EXPECT_EQ(1.0, ks::dn_cdf(1, 1));
+    EXPECT_EQ(1.0, ks::dn_cdf(1234.45, 1));
+    EXPECT_EQ(1.0, ks::dn_cdf(1, 10000));
+    EXPECT_EQ(1.0, ks::dn_cdf(1234.45, 10000));
+
+    // When x in [1/2n, 1/n), F(x; n) = n!(2x-1/n)^n.
+    int n = 3;
+    double x = 0.3;
+    double expected = tgamma(n+1)*pow(2*x-1./n, n);
+    EXPECT_NEAR(expected, ks::dn_cdf(x, n), expected*1e-15);
+
+    // When x in [1-1/n, 1), F(x; n) = 1-2(1-x)^n.
+    n = 5;
+    x = 0.81;
+    expected = 1-2*pow(1-x, n);
+    EXPECT_NEAR(expected, ks::dn_cdf(x, n), expected*1e-15);
+
+    // When n·x^2 > 18.37, F(x; n) should be within double epsilon of 1.
+    n = 75;
+    x = 0.5;
+    EXPECT_EQ(1., ks::dn_cdf(x, n));
+
+    // Various spots in the middle (avoiding n>140 until we have
+    // a more complete implementation).
+
+    n = 100;
+    x = 0.2;
+    expected = 1-0.000555192732802810;
+    EXPECT_NEAR(expected, ks::dn_cdf(x, n), 1e-15); // note: absolute error bound
+
+    n = 140;
+    x = 0.0464158883361278;
+    expected = 0.0902623294750042;
+    EXPECT_NEAR(expected, ks::dn_cdf(x, n), expected*1e-14); // note: larger rel tol here
+}
+
+TEST(stats, running) {
+    // Exercise simple summary statistics:
+    summary_stats S;
+
+    double x1[] = {};
+    S = summarize(x1);
+
+    EXPECT_EQ(0, S.n);
+    EXPECT_EQ(0, S.mean);
+
+    double x2[] = {3.1, 3.1, 3.1};
+    S = summarize(x2);
+
+    EXPECT_EQ(3, S.n);
+    EXPECT_EQ(3.1, S.mean);
+    EXPECT_EQ(0, S.variance);
+    EXPECT_EQ(3.1, S.min);
+    EXPECT_EQ(3.1, S.max);
+
+    constexpr double inf = std::numeric_limits<double>::infinity();
+    double x3[] = {0.3, inf, 1.};
+    S = summarize(x3);
+
+    EXPECT_EQ(3, S.n);
+    EXPECT_EQ(0.3, S.min);
+    EXPECT_EQ(inf, S.max);
+
+    double x4[] = {-1, -2, 1, 0, 2};
+    S = summarize(x4);
+
+    EXPECT_EQ(5, S.n);
+    EXPECT_DOUBLE_EQ(0, S.mean);
+    EXPECT_DOUBLE_EQ(2, S.variance);
+    EXPECT_EQ(-2, S.min);
+    EXPECT_EQ(2, S.max);
+}
+
+TEST(stats, poisson_cdf) {
+    // Wilson-Hilferty transform approximation of the Poisson CDF
+    // is expected to be within circa 1e-3 (absolute) of true for
+    // parameter μ ≥ 6.
+
+    // Exact values in table computed with scipy.stats.
+    struct point {
+        int n;
+        double mu;
+        double cdf;
+    };
+
+    point points[] = {
+        {1,   6.00,   0.017351265236665},
+        {3,   6.00,   0.151203882776648},
+        {6,   6.00,   0.606302782412592},
+        {10,  6.00,   0.957379076417462},
+        {4,   11.30,  0.012323331570747},
+        {7,   11.30,  0.124852978325848},
+        {11,  11.30,  0.543501467974833},
+        {18,  11.30,  0.977530236363240},
+        {13,  23.00,  0.017428210696087},
+        {18,  23.00,  0.174768719569196},
+        {23,  23.00,  0.555149935616771},
+        {32,  23.00,  0.971056607478885},
+        {31,  44.70,  0.019726646735143},
+        {38,  44.70,  0.177614024770857},
+        {44,  44.70,  0.498035947942948},
+        {58,  44.70,  0.976892082270190},
+        {171, 200.00, 0.019982398243966},
+        {185, 200.00, 0.152411852483996},
+        {200, 200.00, 0.518794309678716},
+        {228, 200.00, 0.976235501016406},
+    };
+
+    for (auto p: points) {
+        SCOPED_TRACE("n="+std::to_string(p.n)+"; mu="+std::to_string(p.mu));
+        EXPECT_NEAR(p.cdf, poisson::poisson_cdf_approx(p.n, p.mu), 1.e-3); 
+    }
+}
diff --git a/tests/validation/convergence_test.hpp b/tests/validation/convergence_test.hpp
index 7c9f593634fc80334f4b1cb3afbdaa6673cc82e6..daf6f4b885c7f09f872e648be4a709812bfaa286 100644
--- a/tests/validation/convergence_test.hpp
+++ b/tests/validation/convergence_test.hpp
@@ -1,8 +1,12 @@
 #pragma once
 
+#include <vector>
+
+#include <model.hpp>
+#include <sampling.hpp>
+#include <simple_sampler.hpp>
 #include <util/filter.hpp>
 #include <util/rangeutil.hpp>
-#include <cell.hpp>
 
 #include <json/json.hpp>
 
@@ -14,10 +18,9 @@
 namespace nest {
 namespace mc {
 
-struct sampler_info {
+struct probe_label {
     const char* label;
-    cell_member_type probe;
-    simple_sampler sampler;
+    cell_member_type probe_id;
 };
 
 /* Common functionality for testing convergence towards
@@ -34,25 +37,25 @@ private:
     std::string param_name_;
     bool run_validation_;
     nlohmann::json meta_;
-    std::vector<sampler_info> cell_samplers_;
-    std::map<std::string, trace_data> ref_data_;
+    std::vector<probe_label> probe_labels_;
+    std::map<std::string, trace_data<double>> ref_data_;
     std::vector<conv_entry<Param>> conv_tbl_;
 
 public:
-    template <typename SamplerInfoSeq>
+    template <typename ProbeLabelSeq>
     convergence_test_runner(
         const std::string& param_name,
-        const SamplerInfoSeq& samplers,
+        const ProbeLabelSeq& probe_labels,
         const nlohmann::json& meta
     ):
         param_name_(param_name),
         run_validation_(false),
         meta_(meta)
     {
-        util::assign(cell_samplers_, samplers);
+        util::assign(probe_labels_, probe_labels);
     }
 
-    // allow free access to JSON meta data attached to saved traces
+    // Allow free access to JSON meta data attached to saved traces.
     nlohmann::json& metadata() { return meta_; }
 
     void load_reference_data(const util::path& ref_path) {
@@ -60,8 +63,8 @@ public:
         try {
             ref_data_ = g_trace_io.load_traces(ref_path);
 
-            run_validation_ = util::all_of(cell_samplers_,
-                [&](const sampler_info& se) { return ref_data_.count(se.label)>0; });
+            run_validation_ = util::all_of(probe_labels_,
+                [&](const probe_label& pl) { return ref_data_.count(pl.label)>0; });
 
             EXPECT_TRUE(run_validation_);
         }
@@ -70,34 +73,50 @@ public:
         }
     }
 
-    template <typename Model>
-    void run(Model& m, Param p, float t_end, float dt, const std::vector<float>& excl) {
-        // reset samplers and attach to probe locations
-        for (auto& se: cell_samplers_) {
-            se.sampler.reset();
-            m.attach_sampler(se.probe, se.sampler.sampler());
+    void run(model& m, Param p, float sample_dt, float t_end, float dt, const std::vector<float>& excl) {
+        struct sampler_state {
+            sampler_association_handle h; // Keep these for clean up at end.
+            const char* label;
+            trace_data<double> trace;
+        };
+
+        // Attach new samplers to labelled probe ids.
+        std::vector<sampler_state> samplers;
+        samplers.reserve(probe_labels_.size());
+
+        for (auto& pl: probe_labels_) {
+            samplers.push_back({});
+            auto& entry = samplers.back();
+
+            entry.label = pl.label;
+            entry.h = m.add_sampler(one_probe(pl.probe_id), regular_schedule(sample_dt), simple_sampler<double>(entry.trace));
         }
 
         m.run(t_end, dt);
 
-        for (auto& se: cell_samplers_) {
-            std::string label = se.label;
-            const auto& trace = se.sampler.trace;
+        for (auto& entry: samplers) {
+            std::string label = entry.label;
+            const auto& trace = entry.trace;
 
-            // save trace
+            // Save trace.
             nlohmann::json trace_meta(meta_);
             trace_meta[param_name_] = p;
 
             g_trace_io.save_trace(label, trace, trace_meta);
 
-            // compute metrics
+            // Compute metreics.
             if (run_validation_) {
-                double linf = linf_distance(trace, ref_data_[label], excl);
+                double linf = linf_distance(ref_data_[label], trace, excl);
                 auto pd = peak_delta(trace, ref_data_[label]);
 
                 conv_tbl_.push_back({label, p, linf, pd});
             }
         }
+
+        // Remove added samplers.
+        for (const auto& entry: samplers) {
+            m.remove_sampler(entry.h);
+        }
     }
 
     void report() {
@@ -109,10 +128,10 @@ public:
     }
 
     void assert_all_convergence() const {
-        for (const sampler_info& se: cell_samplers_) {
-            SCOPED_TRACE(se.label);
+        for (const auto& pl: probe_labels_) {
+            SCOPED_TRACE(pl.label);
             assert_convergence(util::filter(conv_tbl_,
-                        [&](const conv_entry<Param>& e) { return e.id==se.label; }));
+                        [&](const conv_entry<Param>& e) { return e.id==pl.label; }));
         }
     }
 };
diff --git a/tests/validation/trace_analysis.cpp b/tests/validation/trace_analysis.cpp
index b83d41a2ca9ce76d79d63dd84744294366a56c18..befe39f57ed43fad6965316c73bd8f59df704d72 100644
--- a/tests/validation/trace_analysis.cpp
+++ b/tests/validation/trace_analysis.cpp
@@ -18,7 +18,7 @@ namespace nest {
 namespace mc {
 
 struct trace_interpolant {
-    trace_interpolant(const trace_data& trace): trace_(trace) {}
+    trace_interpolant(const trace_data<double>& trace): trace_(trace) {}
 
     double operator()(float t) const {
         if (trace_.empty()) return std::nan("");
@@ -37,15 +37,15 @@ struct trace_interpolant {
         return math::lerp(vx[i], vx[i+1], (t-p.first)/(p.second-p.first));
     }
 
-    const trace_data& trace_;
+    const trace_data<double>& trace_;
 };
 
-double linf_distance(const trace_data& u, const trace_data& ref) {
-    trace_interpolant f{ref};
+double linf_distance(const trace_data<double>& u, const trace_data<double>& r) {
+    trace_interpolant f{r};
 
     return util::max_value(
             util::transform_view(u,
-                [&](trace_entry x) { return std::abs(x.v-f(x.t)); }));
+                [&](trace_entry<double> x) { return std::abs(x.v-f(x.t)); }));
 }
 
 // Compute linf distance as above, but excluding sample points that lie
@@ -53,10 +53,10 @@ double linf_distance(const trace_data& u, const trace_data& ref) {
 //
 // `excl` contains the times to exclude, in ascending order.
 
-double linf_distance(const trace_data& u, const trace_data& ref, const std::vector<float>& excl) {
+double linf_distance(const trace_data<double>& u, const trace_data<double>& ref, const std::vector<float>& excl) {
     trace_interpolant f{ref};
 
-    trace_data reduced;
+    trace_data<double> reduced;
     unsigned nexcl = excl.size();
     unsigned ei = 0;
 
@@ -85,7 +85,7 @@ double linf_distance(const trace_data& u, const trace_data& ref, const std::vect
     return linf_distance(reduced, ref);
 }
 
-std::vector<trace_peak> local_maxima(const trace_data& u) {
+std::vector<trace_peak> local_maxima(const trace_data<double>& u) {
     std::vector<trace_peak> peaks;
     if (u.size()<2) return peaks;
 
@@ -116,7 +116,7 @@ std::vector<trace_peak> local_maxima(const trace_data& u) {
     return peaks;
 }
 
-util::optional<trace_peak> peak_delta(const trace_data& a, const trace_data& b) {
+util::optional<trace_peak> peak_delta(const trace_data<double>& a, const trace_data<double>& b) {
     auto p = local_maxima(a);
     auto q = local_maxima(b);
 
diff --git a/tests/validation/trace_analysis.hpp b/tests/validation/trace_analysis.hpp
index 8b0976237bc1790dc13141ee86c625cf196fc4d4..3bed1446512cc55a44195f9a415c5af4c9473fab 100644
--- a/tests/validation/trace_analysis.hpp
+++ b/tests/validation/trace_analysis.hpp
@@ -17,14 +17,14 @@ namespace mc {
 
 // Compute max |v_i - f(t_i)| where (t, v) is the 
 // first trace `u` and f is the piece-wise linear interpolant
-// of the second trace `ref`.
+// of the second trace `r`.
 
-double linf_distance(const trace_data& u, const trace_data& ref);
+double linf_distance(const trace_data<double>& u, const trace_data<double>& r);
 
-// Compute linf distance as above, excluding samples near
-// times given in `excl`, monotonically increasing.
+// Compute linf distance as above, excluding samples in the first trace
+// near times given in `excl`, monotonically increasing.
 
-double linf_distance(const trace_data& u, const trace_data& ref, const std::vector<float>& excl);
+double linf_distance(const trace_data<double>& u, const trace_data<double>& r, const std::vector<float>& excl);
 
 // Find local maxima (peaks) in a trace, excluding end points.
 
@@ -38,14 +38,14 @@ struct trace_peak {
     }
 };
 
-std::vector<trace_peak> local_maxima(const trace_data& u);
+std::vector<trace_peak> local_maxima(const trace_data<double>& u);
 
 // Compare differences in peak times across two traces.
 // Returns largest magnitute displacement between peaks,
 // together with a sampling error bound, or `nothing`
 // if the number of peaks differ.
 
-util::optional<trace_peak> peak_delta(const trace_data& a, const trace_data& b);
+util::optional<trace_peak> peak_delta(const trace_data<double>& a, const trace_data<double>& b);
 
 // Record for error data for convergence testing.
 // Only linf and peak_delta are used for convergence testing below;
diff --git a/tests/validation/validate.cpp b/tests/validation/validate.cpp
index e186a64f9ddcab1699ef45dae9a59128ae0a7a72..911e3067f3c4392bbb07a1d5bd6f430e5b1b8781 100644
--- a/tests/validation/validate.cpp
+++ b/tests/validation/validate.cpp
@@ -21,8 +21,8 @@ const char* usage_str =
 "  -p, --path=DIR      Look for validation reference data in DIR\n"
 "  -m, --max-comp=N    Run convergence tests to a maximum of N\n"
 "                      compartments per segment\n"
-"  -d, --min-dt=DT     Run convergence tests with or with a minimumf\n"
-"                      timestep DT\n"
+"  -d, --min-dt=DT     Run convergence tests with a minimum timestep DT [ms]\n"
+"  -s, --sample-dt=DT  Sample rate for simulations [ms]\n"
 "  -h, --help          Display usage information and exit\n"
 "\n"
 "Validation data is searched by default in the directory specified by\n"
@@ -52,6 +52,9 @@ int main(int argc, char **argv) {
             else if (auto o = parse_opt<float>(arg, 'd', "min-dt")) {
                 g_trace_io.set_min_dt(*o);
             }
+            else if (auto o = parse_opt<float>(arg, 's', "sample-dt")) {
+                g_trace_io.set_sample_dt(*o);
+            }
             else if (auto o = parse_opt(arg, 'v', "verbose")) {
                 g_trace_io.set_verbose(true);
             }
@@ -66,7 +69,6 @@ int main(int argc, char **argv) {
 
         return RUN_ALL_TESTS();
     }
-
     catch (to::parse_opt_error& e) {
         to::usage(argv[0], usage_str, e.what());
         return 1;
diff --git a/tests/validation/validate_ball_and_stick.hpp b/tests/validation/validate_ball_and_stick.hpp
index 21dd6469fd21b4bc167226696c9e4e654efda3a8..516d82c77560d4987c9fe30d0925bee9818c1873 100644
--- a/tests/validation/validate_ball_and_stick.hpp
+++ b/tests/validation/validate_ball_and_stick.hpp
@@ -7,29 +7,40 @@
 #include <hardware/node_info.hpp>
 #include <model.hpp>
 #include <recipe.hpp>
+#include <segment.hpp>
 #include <simple_sampler.hpp>
+#include <util/meta.hpp>
 #include <util/path.hpp>
 
 #include "../gtest.h"
 
-#include "../test_common_cells.hpp"
+#include "../common_cells.hpp"
+#include "../simple_recipes.hpp"
 #include "convergence_test.hpp"
 #include "trace_analysis.hpp"
 #include "validation_data.hpp"
 
-template <typename SamplerInfoSeq>
+#include <iostream>
+
+struct probe_point {
+    const char* label;
+    nest::mc::segment_location where;
+};
+
+template <typename ProbePointSeq>
 void run_ncomp_convergence_test(
     const char* model_name,
     const nest::mc::util::path& ref_data_path,
     nest::mc::backend_kind backend,
     const nest::mc::cell& c,
-    SamplerInfoSeq& samplers,
+    ProbePointSeq& probe_points,
     float t_end=100.f)
 {
     using namespace nest::mc;
 
     auto max_ncomp = g_trace_io.max_ncomp();
     auto dt = g_trace_io.min_dt();
+    auto sample_dt = g_trace_io.sample_dt();
 
     nlohmann::json meta = {
         {"name", "membrane voltage"},
@@ -42,7 +53,14 @@ void run_ncomp_convergence_test(
 
     auto exclude = stimulus_ends(c);
 
-    convergence_test_runner<int> runner("ncomp", samplers, meta);
+    auto n_probe = util::size(probe_points);
+    std::vector<probe_label> plabels;
+    plabels.reserve(n_probe);
+    for (unsigned i = 0; i<n_probe; ++i) {
+        plabels.push_back(probe_label{probe_points[i].label, {0u, i}});
+    }
+
+    convergence_test_runner<int> runner("ncomp", plabels, meta);
     runner.load_reference_data(ref_data_path);
 
     for (int ncomp = 10; ncomp<max_ncomp; ncomp*=2) {
@@ -51,11 +69,16 @@ void run_ncomp_convergence_test(
                 seg->set_compartments(ncomp);
             }
         }
+        cable1d_recipe rec{c};
+        for (const auto& p: probe_points) {
+            rec.add_probe(0, 0, cell_probe_address{p.where, cell_probe_address::membrane_voltage});
+        }
+
         hw::node_info nd(1, backend==backend_kind::gpu? 1: 0);
-        auto decomp = partition_load_balance(singleton_recipe{c}, nd);
-        model m(singleton_recipe{c}, decomp);
+        auto decomp = partition_load_balance(rec, nd);
+        model m(rec, decomp);
 
-        runner.run(m, ncomp, t_end, dt, exclude);
+        runner.run(m, ncomp, sample_dt, t_end, dt, exclude);
     }
     runner.report();
     runner.assert_all_convergence();
@@ -65,13 +88,10 @@ void validate_ball_and_stick(nest::mc::backend_kind backend) {
     using namespace nest::mc;
 
     cell c = make_cell_ball_and_stick();
-    add_common_voltage_probes(c);
-
-    float sample_dt = 0.025f;
-    sampler_info samplers[] = {
-        {"soma.mid", {0u, 0u}, simple_sampler(sample_dt)},
-        {"dend.mid", {0u, 1u}, simple_sampler(sample_dt)},
-        {"dend.end", {0u, 2u}, simple_sampler(sample_dt)}
+    probe_point points[] = {
+        {"soma.mid", {0u, 0.5}},
+        {"dend.mid", {1u, 0.5}},
+        {"dend.end", {1u, 1.0}}
     };
 
     run_ncomp_convergence_test(
@@ -79,20 +99,17 @@ void validate_ball_and_stick(nest::mc::backend_kind backend) {
         "neuron_ball_and_stick.json",
         backend,
         c,
-        samplers);
+        points);
 }
 
 void validate_ball_and_taper(nest::mc::backend_kind backend) {
     using namespace nest::mc;
 
     cell c = make_cell_ball_and_taper();
-    add_common_voltage_probes(c);
-
-    float sample_dt = 0.025f;
-    sampler_info samplers[] = {
-        {"soma.mid", {0u, 0u}, simple_sampler(sample_dt)},
-        {"taper.mid", {0u, 1u}, simple_sampler(sample_dt)},
-        {"taper.end", {0u, 2u}, simple_sampler(sample_dt)}
+    probe_point points[] = {
+        {"soma.mid",  {0u, 0.5}},
+        {"taper.mid", {1u, 0.5}},
+        {"taper.end", {1u, 1.0}}
     };
 
     run_ncomp_convergence_test(
@@ -100,24 +117,21 @@ void validate_ball_and_taper(nest::mc::backend_kind backend) {
         "neuron_ball_and_taper.json",
         backend,
         c,
-        samplers);
+        points);
 }
 
 void validate_ball_and_3stick(nest::mc::backend_kind backend) {
     using namespace nest::mc;
 
     cell c = make_cell_ball_and_3stick();
-    add_common_voltage_probes(c);
-
-    float sample_dt = 0.025f;
-    sampler_info samplers[] = {
-        {"soma.mid",  {0u, 0u}, simple_sampler(sample_dt)},
-        {"dend1.mid", {0u, 1u}, simple_sampler(sample_dt)},
-        {"dend1.end", {0u, 2u}, simple_sampler(sample_dt)},
-        {"dend2.mid", {0u, 3u}, simple_sampler(sample_dt)},
-        {"dend2.end", {0u, 4u}, simple_sampler(sample_dt)},
-        {"dend3.mid", {0u, 5u}, simple_sampler(sample_dt)},
-        {"dend3.end", {0u, 6u}, simple_sampler(sample_dt)}
+    probe_point points[] = {
+        {"soma.mid",  {0u, 0.5}},
+        {"dend1.mid", {1u, 0.5}},
+        {"dend1.end", {1u, 1.0}},
+        {"dend2.mid", {2u, 0.5}},
+        {"dend2.end", {2u, 1.0}},
+        {"dend3.mid", {3u, 0.5}},
+        {"dend3.end", {3u, 1.0}}
     };
 
     run_ncomp_convergence_test(
@@ -125,24 +139,17 @@ void validate_ball_and_3stick(nest::mc::backend_kind backend) {
         "neuron_ball_and_3stick.json",
         backend,
         c,
-        samplers);
+        points);
 }
 
 void validate_rallpack1(nest::mc::backend_kind backend) {
     using namespace nest::mc;
 
     cell c = make_cell_simple_cable();
-
-    // three probes: left end, 30% along, right end.
-    c.add_probe({{1, 0.0}, probeKind::membrane_voltage});
-    c.add_probe({{1, 0.3}, probeKind::membrane_voltage});
-    c.add_probe({{1, 1.0}, probeKind::membrane_voltage});
-
-    float sample_dt = 0.025f;
-    sampler_info samplers[] = {
-        {"cable.x0.0", {0u, 0u}, simple_sampler(sample_dt)},
-        {"cable.x0.3", {0u, 1u}, simple_sampler(sample_dt)},
-        {"cable.x1.0", {0u, 2u}, simple_sampler(sample_dt)},
+    probe_point points[] = {
+        {"cable.x0.0", {1u, 0.0}},
+        {"cable.x0.3", {1u, 0.3}},
+        {"cable.x1.0", {1u, 1.0}}
     };
 
     run_ncomp_convergence_test(
@@ -150,7 +157,7 @@ void validate_rallpack1(nest::mc::backend_kind backend) {
         "numeric_rallpack1.json",
         backend,
         c,
-        samplers,
+        points,
         250.f);
 }
 
@@ -158,13 +165,10 @@ void validate_ball_and_squiggle(nest::mc::backend_kind backend) {
     using namespace nest::mc;
 
     cell c = make_cell_ball_and_squiggle();
-    add_common_voltage_probes(c);
-
-    float sample_dt = 0.025f;
-    sampler_info samplers[] = {
-        {"soma.mid", {0u, 0u}, simple_sampler(sample_dt)},
-        {"dend.mid", {0u, 1u}, simple_sampler(sample_dt)},
-        {"dend.end", {0u, 2u}, simple_sampler(sample_dt)}
+    probe_point points[] = {
+        {"soma.mid", {0u, 0.5}},
+        {"dend.mid", {1u, 0.5}},
+        {"dend.end", {1u, 1.0}}
     };
 
 #if 0
@@ -183,5 +187,5 @@ void validate_ball_and_squiggle(nest::mc::backend_kind backend) {
         "neuron_ball_and_squiggle.json",
         backend,
         c,
-        samplers);
+        points);
 }
diff --git a/tests/validation/validate_compartment_policy.cpp b/tests/validation/validate_compartment_policy.cpp
index 4848ea12a14c9da2bce2dff0fd28ff4f042b4bd5..3c78c3a2e415be7aa1ce51c1e1a77b0d4a87883e 100644
--- a/tests/validation/validate_compartment_policy.cpp
+++ b/tests/validation/validate_compartment_policy.cpp
@@ -13,8 +13,10 @@
 
 #include "../gtest.h"
 
-#include "../test_common_cells.hpp"
+#include "../common_cells.hpp"
+#include "../simple_recipes.hpp"
 #include "../test_util.hpp"
+
 #include "trace_analysis.hpp"
 #include "validation_data.hpp"
 
diff --git a/tests/validation/validate_kinetic.hpp b/tests/validation/validate_kinetic.hpp
index 75a4d5efe4d235e1ae90f1d9a24d71bf5d3f0bfb..919324932ed5b00afb7de3e873c57f1c19fc0c70 100644
--- a/tests/validation/validate_kinetic.hpp
+++ b/tests/validation/validate_kinetic.hpp
@@ -10,7 +10,9 @@
 #include <simple_sampler.hpp>
 #include <util/rangeutil.hpp>
 
-#include "../test_common_cells.hpp"
+#include "../common_cells.hpp"
+#include "../simple_recipes.hpp"
+
 #include "convergence_test.hpp"
 #include "trace_analysis.hpp"
 #include "validation_data.hpp"
@@ -24,19 +26,21 @@ void run_kinetic_dt(
 {
     using namespace nest::mc;
 
-    float sample_dt = .025f;
-    sampler_info samplers[] = {
-        {"soma.mid", {0u, 0u}, simple_sampler(sample_dt)}
-    };
+    float sample_dt = g_trace_io.sample_dt();
+
+    cable1d_recipe rec{c};
+    rec.add_probe(0, 0, cell_probe_address{{0, 0.5}, cell_probe_address::membrane_voltage});
+    probe_label plabels[1] = {"soma.mid", {0u, 0u}};
 
     meta["sim"] = "nestmc";
     meta["backend_kind"] = to_string(backend);
-    convergence_test_runner<float> runner("dt", samplers, meta);
+
+    convergence_test_runner<float> runner("dt", plabels, meta);
     runner.load_reference_data(ref_file);
 
     hw::node_info nd(1, backend==backend_kind::gpu? 1: 0);
-    auto decomp = partition_load_balance(singleton_recipe{c}, nd);
-    model model(singleton_recipe{c}, decomp);
+    auto decomp = partition_load_balance(rec, nd);
+    model model(rec, decomp);
 
     auto exclude = stimulus_ends(c);
 
@@ -49,7 +53,7 @@ void run_kinetic_dt(
 
             model.reset();
             float dt = float(1./oo_dt);
-            runner.run(model, dt, t_end, dt, exclude);
+            runner.run(model, dt, sample_dt, t_end, dt, exclude);
         }
     }
 
@@ -64,7 +68,6 @@ void validate_kinetic_kin1(nest::mc::backend_kind backend) {
     // 20 µm diameter soma with single mechanism, current probe
     cell c;
     auto soma = c.add_soma(10);
-    c.add_probe({{0, 0.5}, probeKind::membrane_current});
     soma->add_mechanism(std::string("test_kin1"));
 
     nlohmann::json meta = {
@@ -82,7 +85,6 @@ void validate_kinetic_kinlva(nest::mc::backend_kind backend) {
     // 20 µm diameter soma with single mechanism, current probe
     cell c;
     auto soma = c.add_soma(10);
-    c.add_probe({{0, 0.5}, probeKind::membrane_voltage});
     c.add_stimulus({0,0.5}, {20., 130., -0.025});
     soma->add_mechanism(std::string("test_kinlva"));
 
diff --git a/tests/validation/validate_soma.hpp b/tests/validation/validate_soma.hpp
index b33fa7b80959555a728c57d393fe42915d1f74bc..edc985b84ca734dbccf6024216c91f3c1538ff98 100644
--- a/tests/validation/validate_soma.hpp
+++ b/tests/validation/validate_soma.hpp
@@ -10,7 +10,9 @@
 #include <simple_sampler.hpp>
 #include <util/rangeutil.hpp>
 
-#include "../test_common_cells.hpp"
+#include "../common_cells.hpp"
+#include "../simple_recipes.hpp"
+
 #include "convergence_test.hpp"
 #include "trace_analysis.hpp"
 #include "validation_data.hpp"
@@ -18,15 +20,17 @@
 void validate_soma(nest::mc::backend_kind backend) {
     using namespace nest::mc;
 
+    float sample_dt = g_trace_io.sample_dt();
+
     cell c = make_cell_soma_only();
-    add_common_voltage_probes(c);
 
-    hw::node_info nd(1, backend==backend_kind::gpu? 1: 0);
-    auto decomp = partition_load_balance(singleton_recipe{c}, nd);
-    model m(singleton_recipe{c}, decomp);
+    cable1d_recipe rec{c};
+    rec.add_probe(0, 0, cell_probe_address{{0, 0.5}, cell_probe_address::membrane_voltage});
+    probe_label plabels[1] = {"soma.mid", {0u, 0u}};
 
-    float sample_dt = .025f;
-    sampler_info samplers[] = {{"soma.mid", {0u, 0u}, simple_sampler(sample_dt)}};
+    hw::node_info nd(1, backend==backend_kind::gpu? 1: 0);
+    auto decomp = partition_load_balance(rec, nd);
+    model m(rec, decomp);
 
     nlohmann::json meta = {
         {"name", "membrane voltage"},
@@ -36,7 +40,7 @@ void validate_soma(nest::mc::backend_kind backend) {
         {"backend_kind", to_string(backend)}
     };
 
-    convergence_test_runner<float> runner("dt", samplers, meta);
+    convergence_test_runner<float> runner("dt", plabels, meta);
     runner.load_reference_data("numeric_soma.json");
 
     float t_end = 100.f;
@@ -50,7 +54,7 @@ void validate_soma(nest::mc::backend_kind backend) {
 
             m.reset();
             float dt = float(1./oo_dt);
-            runner.run(m, dt, t_end, dt, {});
+            runner.run(m, dt, sample_dt, t_end, dt, {});
         }
     }
 end:
diff --git a/tests/validation/validate_synapses.hpp b/tests/validation/validate_synapses.hpp
index 3e814add278ccb91cad9c8722c1c23261a6d8c6b..429da370fb0ed8cc164cc8e3c953e46591c2162c 100644
--- a/tests/validation/validate_synapses.hpp
+++ b/tests/validation/validate_synapses.hpp
@@ -12,7 +12,9 @@
 
 #include "../gtest.h"
 
-#include "../test_common_cells.hpp"
+#include "../common_cells.hpp"
+#include "../simple_recipes.hpp"
+
 #include "convergence_test.hpp"
 #include "trace_analysis.hpp"
 #include "validation_data.hpp"
@@ -38,7 +40,6 @@ void run_synapse_test(
     cell c = make_cell_ball_and_stick(false); // no stimuli
     parameter_list syn_default(syn_type);
     c.add_synapse({1, 0.5}, syn_default);
-    add_common_voltage_probes(c);
 
     // injected spike events
     std::vector<postsynaptic_spike_event> synthetic_events = {
@@ -50,24 +51,33 @@ void run_synapse_test(
     // exclude points of discontinuity from linf analysis
     std::vector<float> exclude = {10.f, 20.f, 40.f};
 
-    float sample_dt = 0.025f;
-    sampler_info samplers[] = {
-        {"soma.mid", {0u, 0u}, simple_sampler(sample_dt)},
-        {"dend.mid", {0u, 1u}, simple_sampler(sample_dt)},
-        {"dend.end", {0u, 2u}, simple_sampler(sample_dt)}
+    float sample_dt = g_trace_io.sample_dt();
+    probe_label plabels[3] = {
+        {"soma.mid", {0u, 0u}},
+        {"dend.mid", {0u, 1u}},
+        {"dend.end", {0u, 2u}}
     };
 
-    convergence_test_runner<int> runner("ncomp", samplers, meta);
+    convergence_test_runner<int> runner("ncomp", plabels, meta);
     runner.load_reference_data(ref_data_path);
 
     hw::node_info nd(1, backend==backend_kind::gpu? 1: 0);
     for (int ncomp = 10; ncomp<max_ncomp; ncomp*=2) {
         c.cable(1)->set_compartments(ncomp);
-        auto decomp = partition_load_balance(singleton_recipe{c}, nd);
-        model m(singleton_recipe{c}, decomp);
+
+        cable1d_recipe rec{c};
+        // soma.mid
+        rec.add_probe(0, 0, cell_probe_address{{0, 0.5}, cell_probe_address::membrane_voltage});
+        // dend.mid
+        rec.add_probe(0, 0, cell_probe_address{{1, 0.5}, cell_probe_address::membrane_voltage});
+        // dend.end
+        rec.add_probe(0, 0, cell_probe_address{{1, 1.0}, cell_probe_address::membrane_voltage});
+
+        auto decomp = partition_load_balance(rec, nd);
+        model m(rec, decomp);
         m.group(0).enqueue_events(synthetic_events);
 
-        runner.run(m, ncomp, t_end, dt, exclude);
+        runner.run(m, ncomp, sample_dt, t_end, dt, exclude);
     }
     runner.report();
     runner.assert_all_convergence();
diff --git a/tests/validation/validation_data.cpp b/tests/validation/validation_data.cpp
index 7b44713bf96a6b7e03c054612f398fe35dade33b..7bb9d86152fd5a580e231bae5a86174bd59a6bdf 100644
--- a/tests/validation/validation_data.cpp
+++ b/tests/validation/validation_data.cpp
@@ -48,11 +48,11 @@ util::path trace_io::find_datadir() {
     return util::path();
 }
 
-void trace_io::save_trace(const std::string& label, const trace_data& data, const nlohmann::json& meta) {
+void trace_io::save_trace(const std::string& label, const trace_data<double>& data, const nlohmann::json& meta) {
     save_trace("time", label, data, meta);
 }
 
-void trace_io::save_trace(const std::string& abscissa, const std::string& label, const trace_data& data, const nlohmann::json& meta) {
+void trace_io::save_trace(const std::string& abscissa, const std::string& label, const trace_data<double>& data, const nlohmann::json& meta) {
     using namespace nest::mc;
 
     nlohmann::json j = meta;
@@ -65,8 +65,8 @@ void trace_io::save_trace(const std::string& abscissa, const std::string& label,
 }
 
 template <typename Seq1, typename Seq2>
-static trace_data zip_trace_data(const Seq1& ts, const Seq2& vs) {
-    trace_data trace;
+static trace_data<double> zip_trace_data(const Seq1& ts, const Seq2& vs) {
+    trace_data<double> trace;
 
     auto ti = std::begin(ts);
     auto te = std::end(ts);
@@ -79,7 +79,7 @@ static trace_data zip_trace_data(const Seq1& ts, const Seq2& vs) {
     return trace;
 }
 
-static void parse_trace_json(const nlohmann::json& j, std::map<std::string, trace_data>& traces) {
+static void parse_trace_json(const nlohmann::json& j, std::map<std::string, trace_data<double>>& traces) {
     if (j.is_array()) {
         for (auto& i: j) parse_trace_json(i, traces);
     }
@@ -94,7 +94,7 @@ static void parse_trace_json(const nlohmann::json& j, std::map<std::string, trac
     }
 }
 
-std::map<std::string, trace_data> trace_io::load_traces(const util::path& name) {
+std::map<std::string, trace_data<double>> trace_io::load_traces(const util::path& name) {
     util::path file  = datadir_/name;
     std::ifstream fid(file);
     if (!fid) {
@@ -104,7 +104,7 @@ std::map<std::string, trace_data> trace_io::load_traces(const util::path& name)
     nlohmann::json data;
     fid >> data;
 
-    std::map<std::string, trace_data> traces;
+    std::map<std::string, trace_data<double>> traces;
     parse_trace_json(data, traces);
     return traces;
 }
diff --git a/tests/validation/validation_data.hpp b/tests/validation/validation_data.hpp
index a470ebf47f8c47c5dec1d9088d80c435814eae04..e462b5d8b2fbecc69652f44d481c9f45953d9a83 100644
--- a/tests/validation/validation_data.hpp
+++ b/tests/validation/validation_data.hpp
@@ -23,6 +23,9 @@ namespace mc {
  * output JSON file, if specified.
  */
 
+// TODO: split simulation options from trace_io structure; rename
+// file to e.g. 'trace_io.hpp'
+
 class trace_io {
 public:
     // Try to find the data directory on construction.
@@ -42,9 +45,9 @@ public:
         }
     }
 
-    void save_trace(const std::string& label, const trace_data& data, const nlohmann::json& meta);
-    void save_trace(const std::string& abscissa, const std::string& label, const trace_data& data, const nlohmann::json& meta);
-    std::map<std::string, trace_data> load_traces(const util::path& name);
+    void save_trace(const std::string& label, const trace_data<double>& data, const nlohmann::json& meta);
+    void save_trace(const std::string& abscissa, const std::string& label, const trace_data<double>& data, const nlohmann::json& meta);
+    std::map<std::string, trace_data<double>> load_traces(const util::path& name);
 
     // common flags, options set by driver
 
@@ -57,6 +60,9 @@ public:
     void set_min_dt(float dt) { min_dt_ = dt; }
     float min_dt() const { return min_dt_; }
 
+    void set_sample_dt(float dt) { sample_dt_ = dt; }
+    float sample_dt() const { return sample_dt_; }
+
     void set_datadir(const util::path& dir) { datadir_ = dir; }
 
     void set_output(const util::path& file) {
@@ -81,6 +87,7 @@ private:
     bool verbose_flag_ = false;
     int max_ncomp_ = 100;
     float min_dt_ = 0.001f;
+    float sample_dt_ = 0.005f;
 
     // Returns value of NMC_DATADIR environment variable if set,
     // otherwise make a 'best-effort' search for the data directory,