From b2e5d1f0f1cd181a34dc62a860589091f52ed760 Mon Sep 17 00:00:00 2001
From: Sam Yates <sam@quux.dropbear.id.au>
Date: Fri, 22 May 2020 15:06:10 +0200
Subject: [PATCH] Extend cable cell probe variety. (#1034)

Fixes #589 and #730, providing cell-wide probes and a correct total trans-membrane current.
Fixes #822, providing spatial interpolation of membrane voltages as governed by cable resistivity and diameter.

* Add partition of point mechanism target vector by cell index to `fvm_mechanism_config`.
* Fix sign error in axial current interpolation.
* Replace `fvm_probe_info` with class that wraps a variant type capturing one of a set of backend-cellgroup probe translation representations: `fvm_probe_scalar`, `fvm_probe_interpolated`, `fvm_probe_multi`, `fvm_probe_weighted_multi`, `fvm_probe_membrance_currents`.
* Refactor `fvm_lowered_cell_impl::resolve_probe_address` so that it avoids the long chain of if-else tests on wrapped `any` type with an invocation of `util::any_visitor`. Each specific cable cell probe address representation is translated into one of the intermediate `fvm_probe_info` variants via an overload of `resolve_probe`.
* Add new non-scalar cell probe address types for all-of-cell values, including point mechanism state, density mechanism state, ion currents and concentrations, membrane voltage by CV cable, and total trans-mebrane currents. The latter are ultimately calculated by computing the net axial current fluxes in each CV.
* Add `util::any_ptr` field to the sampling function interface, pointing to constant-per-simulation-run metadata associated with a probe address. Underlying metadata type varies between probe types.
* Extend `simple_sampler` so that it optionally captures sampler metadata.
* Add special case to `simple_sampler` for vector-valued sample data, allowing it to translate from a pointer-pair range to a std::vector.
* Fix minor bugs in `util::variant`.
* Add to `mc_cell_group` the logic for translating each intermediate probe representation to a sampler callback invocation, reusing temporary scratch vectors across calls.
* Add unit comments to `embed_pwlin_data`.
* Add probe variety demonstration `probe-demo` to examples, covering most of the new cable cell probe classes.
* Add `util::any_visitor` runtime-type dependent dispatcher class.
* Add `util::overload` helper for generating overloaded functional objects from individual functions, lambdas or functionals.
* Extend probe unit tests in `test_probe.cpp` to cover new probe address types.
* Add unit tests for `any_visitor` and `overload`.
---
 arbor/backends/gpu/fvm.hpp               |   1 +
 arbor/backends/multicore/fvm.hpp         |   1 +
 arbor/fvm_layout.cpp                     |  46 +-
 arbor/fvm_layout.hpp                     |   6 +-
 arbor/fvm_lowered_cell.hpp               | 105 ++-
 arbor/fvm_lowered_cell_impl.hpp          | 443 ++++++++---
 arbor/include/arbor/cable_cell.hpp       | 129 ++-
 arbor/include/arbor/sampling.hpp         |   8 +-
 arbor/include/arbor/simple_sampler.hpp   |  85 +-
 arbor/include/arbor/util/any_visitor.hpp | 155 ++++
 arbor/include/arbor/util/variant.hpp     |   7 +-
 arbor/mc_cell_group.cpp                  | 275 ++++++-
 arbor/mc_cell_group.hpp                  |   2 +-
 arbor/morph/embed_pwlin.cpp              |  10 +-
 doc/cpp_cable_cell.rst                   | 303 +++++++
 doc/sampling_api.rst                     |  13 +-
 example/CMakeLists.txt                   |   1 +
 example/dryrun/dryrun.cpp                |   6 +-
 example/gap_junctions/gap_junctions.cpp  |   2 +-
 example/generators/generators.cpp        |  15 +-
 example/generators/readme.md             |  12 +-
 example/probe-demo/CMakeLists.txt        |   3 +
 example/probe-demo/probe-demo.cpp        | 247 ++++++
 example/ring/ring.cpp                    |   2 +-
 example/single/single.cpp                |   2 +-
 python/recipe.cpp                        |   4 +-
 python/sampling.cpp                      |   2 +-
 python/single_cell_model.cpp             |   4 +-
 test/unit/CMakeLists.txt                 |   3 +
 test/unit/common_morphologies.hpp        |   5 +-
 test/unit/mod/ca_linear.mod              |  19 +
 test/unit/mod/param_as_state.mod         |  24 +
 test/unit/test_any_visitor.cpp           | 190 +++++
 test/unit/test_cv_layout.cpp             |   6 +-
 test/unit/test_fvm_layout.cpp            |  12 +-
 test/unit/test_fvm_lowered.cpp           |  31 +-
 test/unit/test_probe.cpp                 | 958 +++++++++++++++++++----
 test/unit/test_schedule.cpp              |   2 +-
 test/unit/unit_test_catalogue.cpp        |   8 +-
 test/unit/unit_test_catalogue.hpp        |   2 +-
 40 files changed, 2776 insertions(+), 373 deletions(-)
 create mode 100644 arbor/include/arbor/util/any_visitor.hpp
 create mode 100644 example/probe-demo/CMakeLists.txt
 create mode 100644 example/probe-demo/probe-demo.cpp
 create mode 100644 test/unit/mod/ca_linear.mod
 create mode 100644 test/unit/mod/param_as_state.mod
 create mode 100644 test/unit/test_any_visitor.cpp

diff --git a/arbor/backends/gpu/fvm.hpp b/arbor/backends/gpu/fvm.hpp
index 0a56bf3c..0fa52b63 100644
--- a/arbor/backends/gpu/fvm.hpp
+++ b/arbor/backends/gpu/fvm.hpp
@@ -47,6 +47,7 @@ struct backend {
     using sample_event_stream = arb::gpu::sample_event_stream;
 
     using shared_state = arb::gpu::shared_state;
+    using ion_state = arb::gpu::ion_state;
 
     static threshold_watcher voltage_watcher(
         const shared_state& state,
diff --git a/arbor/backends/multicore/fvm.hpp b/arbor/backends/multicore/fvm.hpp
index 46767b34..d7b233de 100644
--- a/arbor/backends/multicore/fvm.hpp
+++ b/arbor/backends/multicore/fvm.hpp
@@ -45,6 +45,7 @@ struct backend {
     using sample_event_stream = arb::multicore::sample_event_stream;
 
     using shared_state = arb::multicore::shared_state;
+    using ion_state = arb::multicore::ion_state;
 
     static threshold_watcher voltage_watcher(
         const shared_state& state,
diff --git a/arbor/fvm_layout.cpp b/arbor/fvm_layout.cpp
index 7eb69f63..a950e5d0 100644
--- a/arbor/fvm_layout.cpp
+++ b/arbor/fvm_layout.cpp
@@ -272,6 +272,17 @@ namespace impl {
             ctr.push_back(x+1==0? x: offset+x);
         }
     }
+
+    template <typename Container>
+    void append_divs(Container& left, const Container& right) {
+        if (left.empty()) {
+            left = right;
+        }
+        else if (!right.empty()) {
+            append_offset(left, left.back(), tail(right));
+        }
+    };
+
 }
 
 // Merge CV geometry lists in-place.
@@ -279,6 +290,7 @@ namespace impl {
 cv_geometry& append(cv_geometry& geom, const cv_geometry& right) {
     using impl::tail;
     using impl::append_offset;
+    using impl::append_divs;
 
     if (!right.n_cell()) {
         return geom;
@@ -289,15 +301,6 @@ cv_geometry& append(cv_geometry& geom, const cv_geometry& right) {
         return geom;
     }
 
-    auto append_divs = [](auto& left, const auto& right) {
-        if (left.empty()) {
-            left = right;
-        }
-        else if (!right.empty()) {
-            append_offset(left, left.back(), tail(right));
-        }
-    };
-
     auto geom_n_cv = geom.size();
     auto geom_n_cell = geom.n_cell();
 
@@ -432,7 +435,23 @@ fvm_cv_discretization fvm_cv_discretize(const cable_cell& cell, const cable_cell
         if (D.cv_area[i]>0) {
             D.init_membrane_potential[i] /= D.cv_area[i];
             D.temperature_K[i] /= D.cv_area[i];
+
+            // If parent is trivial, and there is no grandparent, then we can use values from this CV
+            // to get initial values for the parent. (The other case, when there is a grandparent, is
+            // caught below.)
+
+            if (p!=-1 && D.geometry.cv_parent[p]==-1 && D.cv_area[p]==0) {
+                D.init_membrane_potential[p] = D.init_membrane_potential[i];
+                D.temperature_K[p] = D.temperature_K[i];
+            }
+        }
+        else if (p!=-1) {
+            // Use parent CV to get a sensible initial value for voltage and temp on zero-size CVs.
+
+            D.init_membrane_potential[i] = D.init_membrane_potential[p];
+            D.temperature_K[i] = D.temperature_K[p];
         }
+
         if (cv_length>0) {
             D.diam_um[i] = D.cv_area[i]/(cv_length*math::pi<double>);
         }
@@ -671,8 +690,8 @@ fvm_voltage_interpolant fvm_axial_current(const cable_cell& cell, const fvm_cv_d
         mcable rr_span  = mcable{bid, vrefs.proximal.loc.pos , vrefs.distal.loc.pos};
         double rr_conductance = 100/embedding.integrate_ixa(rr_span, D.axial_resistivity[cell_idx].at(bid)); // [µS]
 
-        vi.proximal_coef = -rr_conductance;
-        vi.distal_coef = rr_conductance;
+        vi.proximal_coef = rr_conductance;
+        vi.distal_coef = -rr_conductance;
     }
 
     return vi;
@@ -686,6 +705,7 @@ fvm_voltage_interpolant fvm_axial_current(const cable_cell& cell, const fvm_cv_d
 
 fvm_mechanism_data& append(fvm_mechanism_data& left, const fvm_mechanism_data& right) {
     using impl::append_offset;
+    using impl::append_divs;
 
     fvm_size_type target_offset = left.n_target;
 
@@ -728,7 +748,10 @@ fvm_mechanism_data& append(fvm_mechanism_data& left, const fvm_mechanism_data& r
             }
         }
     }
+
     left.n_target += right.n_target;
+    append_divs(left.target_divs, right.target_divs);
+    arb_assert(left.n_target==left.target_divs.back());
 
     return left;
 }
@@ -1166,6 +1189,7 @@ fvm_mechanism_data fvm_build_mechanism_data(const cable_cell_global_properties&
         }
     }
 
+    M.target_divs = {0u, M.n_target};
     return M;
 }
 
diff --git a/arbor/fvm_layout.hpp b/arbor/fvm_layout.hpp
index e3ade279..72221e6b 100644
--- a/arbor/fvm_layout.hpp
+++ b/arbor/fvm_layout.hpp
@@ -240,6 +240,7 @@ struct fvm_mechanism_config {
     std::vector<value_type> norm_area;
 
     // Synapse target number (point mechanisms only).
+    // For each instance index i, there are multiplicity[i] consecutive entries.
     std::vector<index_type> target;
 
     // (Non-global) parameters and parameter values across the mechanism instance.
@@ -275,8 +276,11 @@ struct fvm_mechanism_data {
     // Ion config, indexed by ion name.
     std::unordered_map<std::string, fvm_ion_config> ions;
 
-    // Total number of targets (point-mechanism points)
+    // Total number of targets (point-mechanism points).
     std::size_t n_target = 0;
+
+    // Partitions target numbers by cell.
+    std::vector<std::size_t> target_divs;
 };
 
 fvm_mechanism_data fvm_build_mechanism_data(const cable_cell_global_properties& gprop, const std::vector<cable_cell>& cells, const fvm_cv_discretization& D, const arb::execution_context& ctx={});
diff --git a/arbor/fvm_lowered_cell.hpp b/arbor/fvm_lowered_cell.hpp
index 99d3000e..7cd80f59 100644
--- a/arbor/fvm_lowered_cell.hpp
+++ b/arbor/fvm_lowered_cell.hpp
@@ -4,13 +4,17 @@
 #include <vector>
 
 #include <arbor/common_types.hpp>
+#include <arbor/cable_cell.hpp>
 #include <arbor/fvm_types.hpp>
+#include <arbor/morph/primitives.hpp>
 #include <arbor/recipe.hpp>
+#include <arbor/util/variant.hpp>
 
 #include "backends/event.hpp"
 #include "backends/threshold_crossing.hpp"
 #include "execution_context.hpp"
 #include "sampler_map.hpp"
+#include "util/meta.hpp"
 #include "util/range.hpp"
 
 namespace arb {
@@ -23,14 +27,101 @@ struct fvm_integration_result {
 
 // A sample for a probe may be derived from multiple 'raw' sampled
 // values from the backend.
-//
-// While supported probes are at this point all simple scalar values,
-// fvm_probe_info will be the class that represents the mapping
-// between a single sample result and the back-end raw probe handles.
+
+// An `fvm_probe_info` object represents the mapping between
+// a sample value (possibly non-scalar) presented to a sampler
+// function, and one or more probe handles that reference data
+// in the FVM back-end.
+
+struct fvm_probe_scalar {
+    probe_handle raw_handles[1] = {nullptr};
+    util::variant<mlocation, cable_probe_point_info> metadata;
+};
+
+struct fvm_probe_interpolated {
+    probe_handle raw_handles[2] = {nullptr, nullptr};
+    double coef[2];
+    mlocation metadata;
+};
+
+struct fvm_probe_multi {
+    std::vector<probe_handle> raw_handles;
+    util::variant<mcable_list, std::vector<cable_probe_point_info>> metadata;
+
+    void shrink_to_fit() {
+        raw_handles.shrink_to_fit();
+        util::visit([](auto& v) { v.shrink_to_fit(); }, metadata);
+    }
+};
+
+struct fvm_probe_weighted_multi {
+    std::vector<probe_handle> raw_handles;
+    std::vector<double> weight;
+    mcable_list metadata;
+
+    void shrink_to_fit() {
+        raw_handles.shrink_to_fit();
+        weight.shrink_to_fit();
+        metadata.shrink_to_fit();
+    }
+};
+
+// Trans-membrane currents require special handling!
+struct fvm_probe_membrane_currents {
+    std::vector<probe_handle> raw_handles; // Voltage per CV.
+    std::vector<mcable> metadata;          // Cables from each CV, in CV order.
+
+    std::vector<unsigned> cv_parent;       // Parent CV index for each CV.
+    std::vector<double> cv_parent_cond;    // Face conductance between CV and parent.
+    std::vector<double> weight;            // Area of cable : area of CV.
+    std::vector<unsigned> cv_cables_divs;  // Partitions metadata by CV index.
+
+    void shrink_to_fit() {
+        raw_handles.shrink_to_fit();
+        metadata.shrink_to_fit();
+        cv_parent.shrink_to_fit();
+        cv_parent_cond.shrink_to_fit();
+        weight.shrink_to_fit();
+        cv_cables_divs.shrink_to_fit();
+    }
+};
+
+struct missing_probe_info {
+    // dummy data...
+    std::array<probe_handle, 0> raw_handles;
+};
 
 struct fvm_probe_info {
-    // nullptr => nothing to probe
-    probe_handle raw_handle = nullptr;
+    fvm_probe_info() = default;
+    fvm_probe_info(fvm_probe_scalar p): info(std::move(p)) {}
+    fvm_probe_info(fvm_probe_interpolated p): info(std::move(p)) {}
+    fvm_probe_info(fvm_probe_multi p): info(std::move(p)) {}
+    fvm_probe_info(fvm_probe_weighted_multi p): info(std::move(p)) {}
+    fvm_probe_info(fvm_probe_membrane_currents p): info(std::move(p)) {}
+
+    util::variant<
+        missing_probe_info,
+        fvm_probe_scalar,
+        fvm_probe_interpolated,
+        fvm_probe_multi,
+        fvm_probe_weighted_multi,
+        fvm_probe_membrane_currents
+    > info = missing_probe_info{};
+
+    auto raw_handle_range() const {
+        return util::make_range(
+            util::visit(
+                [](auto& i) -> std::pair<const probe_handle*, const probe_handle*> {
+                    using util::data;
+                    using util::size;
+                    return {data(i.raw_handles), data(i.raw_handles)+size(i.raw_handles)};
+                },
+                info));
+    }
+
+    sample_size_type n_raw() const { return raw_handle_range().size(); }
+
+    explicit operator bool() const { return !util::get_if<missing_probe_info>(info); }
 };
 
 // Common base class for FVM implementation on host or gpu back-end.
@@ -43,7 +134,7 @@ struct fvm_lowered_cell {
         const recipe& rec,
         std::vector<fvm_index_type>& cell_to_intdom,
         std::vector<target_handle>& target_handles,
-        probe_association_map<probe_handle>& probe_map) = 0;
+        probe_association_map<fvm_probe_info>& probe_map) = 0;
 
     virtual fvm_integration_result integrate(
         fvm_value_type tfinal,
diff --git a/arbor/fvm_lowered_cell_impl.hpp b/arbor/fvm_lowered_cell_impl.hpp
index f35f446e..d289526e 100644
--- a/arbor/fvm_lowered_cell_impl.hpp
+++ b/arbor/fvm_lowered_cell_impl.hpp
@@ -20,6 +20,9 @@
 #include <arbor/common_types.hpp>
 #include <arbor/cable_cell_param.hpp>
 #include <arbor/recipe.hpp>
+#include <arbor/util/any.hpp>
+#include <arbor/util/any_visitor.hpp>
+#include <arbor/util/optional.hpp>
 
 #include "builtin_mechanisms.hpp"
 #include "execution_context.hpp"
@@ -54,7 +57,7 @@ public:
         const recipe& rec,
         std::vector<fvm_index_type>& cell_to_intdom,
         std::vector<target_handle>& target_handles,
-        probe_association_map<probe_handle>& probe_map) override;
+        probe_association_map<fvm_probe_info>& probe_map) override;
 
     fvm_integration_result integrate(
         value_type tfinal,
@@ -139,6 +142,7 @@ private:
 
     // Translate cell probe descriptions into probe handles etc.
     fvm_probe_info resolve_probe_address(
+        const std::vector<cable_cell>& cells,
         std::size_t cell_idx,
         const util::any& paddr,
         const fvm_cv_discretization& D,
@@ -352,7 +356,7 @@ void fvm_lowered_cell_impl<Backend>::initialize(
     const recipe& rec,
     std::vector<fvm_index_type>& cell_to_intdom,
     std::vector<target_handle>& target_handles,
-    probe_association_map<probe_handle>& probe_map)
+    probe_association_map<fvm_probe_info>& probe_map)
 {
     using util::any_cast;
     using util::count_along;
@@ -483,15 +487,16 @@ void fvm_lowered_cell_impl<Backend>::initialize(
                 layout.weight[i] = 1000/D.cv_area[cv];
 
                 // (builtin stimulus, for example, has no targets)
+                if (config.target.empty()) continue;
 
-                if (!config.target.empty()) {
-                    if(!config.multiplicity.empty()) {
-                        for (auto j: make_span(multiplicity_part[i])) {
-                            target_handles[config.target[j]] = target_handle(mech_id, i, cv_to_intdom[cv]);
-                        }
-                    } else {
-                        target_handles[config.target[i]] = target_handle(mech_id, i, cv_to_intdom[cv]);
-                    };
+                target_handle handle(mech_id, i, cv_to_intdom[cv]);
+                if (config.multiplicity.empty()) {
+                    target_handles[config.target[i]] = handle;
+                }
+                else {
+                    for (auto j: make_span(multiplicity_part[i])) {
+                        target_handles[config.target[j]] = handle;
+                    }
                 }
             }
             break;
@@ -539,10 +544,10 @@ void fvm_lowered_cell_impl<Backend>::initialize(
 
         for (cell_lid_type j: make_span(rec.num_probes(gid))) {
             probe_info pi = rec.get_probe({gid, j});
-            fvm_probe_info info = resolve_probe_address(cell_idx, pi.address, D, mech_data, target_handles, mechptr_by_name);
+            fvm_probe_info info = resolve_probe_address(cells, cell_idx, pi.address, D, mech_data, target_handles, mechptr_by_name);
 
-            if (info.raw_handle) {
-                probe_map.insert({pi.id, {info.raw_handle, pi.tag}});
+            if (info) {
+                probe_map.insert({pi.id, {std::move(info), pi.tag}});
             }
         }
     }
@@ -648,8 +653,56 @@ fvm_size_type fvm_lowered_cell_impl<Backend>::fvm_intdom(
     return intdom_id;
 }
 
+// Resolution of probe addresses into a specific fvm_probe_info draws upon data
+// from the cable cell, the discretization, the target handle map, and the
+// back-end shared state.
+//
+// `resolve_probe_address` collates this data into a `probe_resolution_data`
+// struct which is then passed on to the specific resolution procedure
+// determined by the type of the user-supplied probe address.
+
+template <typename Backend>
+struct probe_resolution_data {
+    typename Backend::shared_state* state;
+    const cable_cell& cell;
+    const std::size_t cell_idx;
+    const fvm_cv_discretization& D;
+    const fvm_mechanism_data& M;
+    const std::vector<target_handle>& handles;
+    const std::unordered_map<std::string, mechanism*>& mech_instance_by_name;
+
+    // Backend state data for a given mechanism and state variable.
+    const fvm_value_type* mechanism_state(const std::string& name, const std::string& state_var) const {
+        mechanism* m = util::value_by_key(mech_instance_by_name, name).value_or(nullptr);
+        if (!m) return nullptr;
+
+        const fvm_value_type* data = Backend::mechanism_field_data(m, state_var);
+        if (!data) throw cable_cell_error("no state variable '"+state_var+"' in mechanism '"+name+"'");
+
+        return data;
+    }
+
+    // Extent of density mechanism on cell.
+    mextent mechanism_support(const std::string& name) const {
+        auto& mech_map = cell.region_assignments().template get<mechanism_desc>();
+        auto opt_mm = util::value_by_key(mech_map, name);
+
+        return opt_mm? opt_mm->support(): mextent{};
+    };
+
+    // Index into ion data from location.
+    util::optional<fvm_index_type> ion_location_index(const std::string& ion, mlocation loc) const {
+        if (state->ion_data.count(ion)) {
+            return util::binary_search_index(M.ions.at(ion).cv,
+                fvm_index_type(D.geometry.location_cv(cell_idx, loc, cv_prefer::cv_nonempty)));
+        }
+        return util::nullopt;
+    }
+};
+
 template <typename Backend>
 fvm_probe_info fvm_lowered_cell_impl<Backend>::resolve_probe_address(
+    const std::vector<cable_cell>& cells,
     std::size_t cell_idx,
     const util::any& paddr,
     const fvm_cv_discretization& D,
@@ -657,102 +710,310 @@ fvm_probe_info fvm_lowered_cell_impl<Backend>::resolve_probe_address(
     const std::vector<target_handle>& handles,
     const std::unordered_map<std::string, mechanism*>& mech_instance_by_name)
 {
-    using util::any_cast;
-    using util::value_by_key;
-    using util::binary_search_index;
-    using namespace cv_prefer;
-
-    // Probe address can be one of a number of cable cell-specific
-    // probe types; dispatch on type and compute probe info accordingly.
-    //
-    // Mechanisms or ions not being instantiated at a particular address
-    // is not treated as an error; a request for a state variable that
-    // does not exist on a mechanism is, however.
-
-    auto location_cv = [&](auto* p, cv_prefer::type prefer) -> fvm_index_type {
-        return D.geometry.location_cv(cell_idx, p->location, prefer);
-    };
+    probe_resolution_data<Backend> prd{
+        state_.get(), cells[cell_idx], cell_idx, D, M, handles, mech_instance_by_name};
+
+    using V = util::any_visitor<
+        cable_probe_membrane_voltage,
+        cable_probe_membrane_voltage_cell,
+        cable_probe_axial_current,
+        cable_probe_total_ion_current_density,
+        cable_probe_total_ion_current_cell,
+        cable_probe_total_current_cell,
+        cable_probe_density_state,
+        cable_probe_density_state_cell,
+        cable_probe_point_state,
+        cable_probe_point_state_cell,
+        cable_probe_ion_current_density,
+        cable_probe_ion_current_cell,
+        cable_probe_ion_int_concentration,
+        cable_probe_ion_int_concentration_cell,
+        cable_probe_ion_ext_concentration,
+        cable_probe_ion_ext_concentration_cell>;
+
+    auto visitor = util::overload(
+        [&prd](auto& probe_addr) { return resolve_probe(probe_addr, prd); },
+        [] { return throw cable_cell_error("unrecognized probe type"), fvm_probe_info{}; });
+
+    return V::visit(visitor, paddr);
+}
 
-    struct mechanism_data {
-        const fvm_value_type* state_data = nullptr;
-        unsigned id;
-        explicit operator bool() const { return state_data; }
-    };
+template <typename B>
+fvm_probe_info resolve_probe(const cable_probe_membrane_voltage& p, probe_resolution_data<B>& R) {
+    const fvm_value_type* data = R.state->voltage.data();
+    fvm_voltage_interpolant in = fvm_interpolate_voltage(R.cell, R.D, R.cell_idx, p.location);
 
-    auto lookup_mechanism_data = [&](auto* p) -> mechanism_data {
-        if (mechanism* m = value_by_key(mech_instance_by_name, p->mechanism).value_or(nullptr)) {
-            const fvm_value_type* data = Backend::mechanism_field_data(m, p->state);
-            if (!data) {
-                throw cable_cell_error("no state variable '"+p->state+"' in mechanism '"+p->mechanism+"'");
-            }
-            return {data, m->mechanism_id()};
-        }
-        return {};
-    };
+    return fvm_probe_interpolated{
+        {data+in.proximal_cv, data+in.distal_cv},
+        {in.proximal_coef, in.distal_coef},
+        p.location};
+}
 
-    auto ion_location_index = [&](auto* p) -> util::optional<fvm_index_type> {
-        if (state_->ion_data.count(p->ion)) {
-            auto cv = location_cv(p, cv_nonempty);
-            return util::binary_search_index(M.ions.at(p->ion).cv, cv);
-        }
-        return util::nullopt;
-    };
+template <typename B>
+fvm_probe_info resolve_probe(const cable_probe_membrane_voltage_cell& p, probe_resolution_data<B>& R) {
+    fvm_probe_multi r;
+    mcable_list cables;
 
-    if (auto *p = any_cast<cell_probe_membrane_voltage>(&paddr)) {
-        const fvm_value_type* src = state_->voltage.data() + location_cv(p, cv_empty);
-        return fvm_probe_info{src};
-    }
-    else if (auto* p = any_cast<cell_probe_total_ionic_current_density>(&paddr)) {
-        const fvm_value_type* src = state_->current_density.data() + location_cv(p, cv_nonempty);
-        return fvm_probe_info{src};
+    for (auto cv: R.D.geometry.cell_cvs(R.cell_idx)) {
+        const double* ptr = R.state->voltage.data()+cv;
+        for (auto cable: R.D.geometry.cables(cv)) {
+            r.raw_handles.push_back(ptr);
+            cables.push_back(cable);
+        }
     }
-    else if (auto* p = any_cast<cell_probe_density_state>(&paddr)) {
-        if (auto data = lookup_mechanism_data(p)) {
-            auto cv = location_cv(p, cv_nonempty);
-            if (auto opt_i = binary_search_index(M.mechanisms.at(p->mechanism).cv, cv)) {
-                return fvm_probe_info{data.state_data+opt_i.value()};
+    r.metadata = std::move(cables);
+    r.shrink_to_fit();
+    return r;
+}
+
+template <typename B>
+fvm_probe_info resolve_probe(const cable_probe_axial_current& p, probe_resolution_data<B>& R) {
+    const fvm_value_type* data = R.state->voltage.data();
+    fvm_voltage_interpolant in = fvm_axial_current(R.cell, R.D, R.cell_idx, p.location);
+
+    return fvm_probe_interpolated{
+        {data+in.proximal_cv, data+in.distal_cv},
+        {in.proximal_coef, in.distal_coef},
+        p.location};
+}
+
+template <typename B>
+fvm_probe_info resolve_probe(const cable_probe_total_ion_current_density& p, probe_resolution_data<B>& R) {
+    return fvm_probe_scalar{
+        {R.state->current_density.data() + R.D.geometry.location_cv(R.cell_idx, p.location, cv_prefer::cv_nonempty)},
+        p.location};
+}
+
+template <typename B>
+fvm_probe_info resolve_probe(const cable_probe_total_ion_current_cell& p, probe_resolution_data<B>& R) {
+    fvm_probe_weighted_multi r;
+
+    for (auto cv: R.D.geometry.cell_cvs(R.cell_idx)) {
+        const double* ptr = R.state->current_density.data()+cv;
+        for (auto cable: R.D.geometry.cables(cv)) {
+            double area = R.cell.embedding().integrate_area(cable); // [µm²]
+            if (area>0) {
+                r.raw_handles.push_back(ptr);
+                r.weight.push_back(0.001*area); // Scale from [µm²·A/m²] to [nA].
+                r.metadata.push_back(cable);
             }
         }
-        return fvm_probe_info{};
     }
-    else if (auto* p = any_cast<cell_probe_point_state>(&paddr)) {
-        if (p->target>=handles.size()) {
-            return fvm_probe_info{};
-        }
+    r.shrink_to_fit();
+    return r;
+}
+
+template <typename B>
+fvm_probe_info resolve_probe(const cable_probe_total_current_cell& p, probe_resolution_data<B>& R) {
+    fvm_probe_membrane_currents r;
+
+    auto cell_cv_ival = R.D.geometry.cell_cv_interval(R.cell_idx);
+    auto cv0 = cell_cv_ival.first;
+
+    util::assign(r.cv_parent, util::transform_view(util::subrange_view(R.D.geometry.cv_parent, cell_cv_ival),
+        [cv0](auto cv) { return cv+1==0? cv: cv-cv0; }));
+    util::assign(r.cv_parent_cond, util::subrange_view(R.D.face_conductance, cell_cv_ival));
+
+    r.cv_cables_divs = {0};
+    for (auto cv: R.D.geometry.cell_cvs(R.cell_idx)) {
+        r.raw_handles.push_back(R.state->voltage.data()+cv);
+        double oo_cv_area = R.D.cv_area[cv]>0? 1./R.D.cv_area[cv]: 0;
 
-        if (auto data = lookup_mechanism_data(p)) {
-            // Confirm mechanism is actually at this target.
-            const auto& th = handles[p->target];
-            if (th.mech_id == data.id) {
-                return fvm_probe_info{data.state_data+th.mech_index};
+        for (auto cable: R.D.geometry.cables(cv)) {
+            double area = R.cell.embedding().integrate_area(cable); // [µm²]
+            if (area>0) {
+                r.weight.push_back(area*oo_cv_area);
+                r.metadata.push_back(cable);
             }
         }
-        return fvm_probe_info{};
+        r.cv_cables_divs.push_back(r.metadata.size());
     }
-    else if (auto* p = any_cast<cell_probe_ion_current_density>(&paddr)) {
-        if (auto opt_i = ion_location_index(p)) {
-            const fvm_value_type* data = state_->ion_data.at(p->ion).iX_.data();
-            return fvm_probe_info{data+opt_i.value()};
+    r.shrink_to_fit();
+    return r;
+}
+
+template <typename B>
+fvm_probe_info resolve_probe(const cable_probe_density_state& p, probe_resolution_data<B>& R) {
+    const fvm_value_type* data = R.mechanism_state(p.mechanism, p.state);
+    if (!data) return {};
+    if (!R.mechanism_support(p.mechanism).intersects(p.location)) return {};
+
+    fvm_index_type cv = R.D.geometry.location_cv(R.cell_idx, p.location, cv_prefer::cv_nonempty);
+    auto opt_i = util::binary_search_index(R.M.mechanisms.at(p.mechanism).cv, cv);
+    if (!opt_i) return {};
+
+    return fvm_probe_scalar{{data+*opt_i}, p.location};
+}
+
+template <typename B>
+fvm_probe_info resolve_probe(const cable_probe_density_state_cell& p, probe_resolution_data<B>& R) {
+    fvm_probe_multi r;
+
+    const fvm_value_type* data = R.mechanism_state(p.mechanism, p.state);
+    if (!data) return {};
+
+    mextent support = R.mechanism_support(p.mechanism);
+    auto& mech_cvs = R.M.mechanisms.at(p.mechanism).cv;
+    mcable_list cables;
+
+    for (auto i: util::count_along(mech_cvs)) {
+        auto cv = mech_cvs[i];
+        auto cv_cables = R.D.geometry.cables(cv);
+        mextent cv_extent = mcable_list(cv_cables.begin(), cv_cables.end());
+        for (auto cable: intersect(cv_extent, support)) {
+            if (cable.prox_pos==cable.dist_pos) continue;
+
+            r.raw_handles.push_back(data+i);
+            cables.push_back(cable);
         }
-        return fvm_probe_info{};
     }
-    else if (auto* p = any_cast<cell_probe_ion_int_concentration>(&paddr)) {
-        if (auto opt_i = ion_location_index(p)) {
-            const fvm_value_type* data = state_->ion_data.at(p->ion).Xi_.data();
-            return fvm_probe_info{data+opt_i.value()};
-        }
-        return fvm_probe_info{};
+    r.metadata = std::move(cables);
+    r.shrink_to_fit();
+    return r;
+}
+
+template <typename B>
+fvm_probe_info resolve_probe(const cable_probe_point_state& p, probe_resolution_data<B>& R) {
+    arb_assert(R.handles.size()==R.M.target_divs.back());
+    arb_assert(R.handles.size()==R.M.n_target);
+
+    const fvm_value_type* data = R.mechanism_state(p.mechanism, p.state);
+    if (!data) return {};
+
+    // Convert cell-local target number to cellgroup target number.
+    auto cg_target = p.target + R.M.target_divs[R.cell_idx];
+    if (cg_target>=R.M.target_divs.at(R.cell_idx+1)) return {};
+
+    if (R.handles[cg_target].mech_id!=R.mech_instance_by_name.at(p.mechanism)->mechanism_id()) return {};
+    auto mech_index = R.handles[cg_target].mech_index;
+
+    auto& multiplicity = R.M.mechanisms.at(p.mechanism).multiplicity;
+    auto& placed_instances = R.cell.synapses().at(p.mechanism);
+
+    auto opt_i = util::binary_search_index(placed_instances, p.target, [](auto& item) { return item.lid; });
+    if (!opt_i) throw arbor_internal_error("inconsistent mechanism state");
+    mlocation loc = placed_instances[*opt_i].loc;
+
+    cable_probe_point_info metadata{p.target, multiplicity.empty()? 1u: multiplicity.at(mech_index), loc};
+
+    return fvm_probe_scalar{{data+mech_index}, metadata};
+}
+
+template <typename B>
+fvm_probe_info resolve_probe(const cable_probe_point_state_cell& p, probe_resolution_data<B>& R) {
+    const fvm_value_type* data = R.mechanism_state(p.mechanism, p.state);
+    if (!data) return {};
+
+    unsigned id = R.mech_instance_by_name.at(p.mechanism)->mechanism_id();
+    auto& multiplicity = R.M.mechanisms.at(p.mechanism).multiplicity;
+    auto& placed_instances = R.cell.synapses().at(p.mechanism);
+
+    std::size_t cell_targets_base = R.M.target_divs[R.cell_idx];
+    std::size_t cell_targets_end = R.M.target_divs[R.cell_idx+1];
+
+    fvm_probe_multi r;
+    std::vector<cable_probe_point_info> metadata;
+
+    for (auto target: util::make_span(cell_targets_base, cell_targets_end)) {
+        if (R.handles[target].mech_id!=id) continue;
+
+        auto mech_index = R.handles[target].mech_index;
+        r.raw_handles.push_back(data+mech_index);
+
+        auto cell_target = target-cell_targets_base; // Convert to cell-local target index.
+
+        auto opt_i = util::binary_search_index(placed_instances, cell_target, [](auto& item) { return item.lid; });
+        if (!opt_i) throw arbor_internal_error("inconsistent mechanism state");
+        mlocation loc = placed_instances[*opt_i].loc;
+
+        metadata.push_back(cable_probe_point_info{
+            cell_lid_type(cell_target), multiplicity.empty()? 1u: multiplicity.at(mech_index), loc});
     }
-    else if (auto* p = any_cast<cell_probe_ion_ext_concentration>(&paddr)) {
-        if (auto opt_i = ion_location_index(p)) {
-            const fvm_value_type* data = state_->ion_data.at(p->ion).Xo_.data();
-            return fvm_probe_info{data+opt_i.value()};
+
+    r.metadata = std::move(metadata);
+    r.shrink_to_fit();
+    return r;
+}
+
+template <typename B>
+fvm_probe_info resolve_probe(const cable_probe_ion_current_density& p, probe_resolution_data<B>& R) {
+    auto opt_i = R.ion_location_index(p.ion, p.location);
+    if (!opt_i) return {};
+
+    return fvm_probe_scalar{{R.state->ion_data.at(p.ion).iX_.data()+*opt_i}, p.location};
+}
+
+template <typename B>
+fvm_probe_info resolve_probe(const cable_probe_ion_current_cell& p, probe_resolution_data<B>& R) {
+    if (!R.state->ion_data.count(p.ion)) return {};
+
+    auto& ion_cvs = R.M.ions.at(p.ion).cv;
+    const fvm_value_type* src = R.state->ion_data.at(p.ion).iX_.data();
+
+    fvm_probe_weighted_multi r;
+    for (auto cv: R.D.geometry.cell_cvs(R.cell_idx)) {
+        auto opt_i = util::binary_search_index(ion_cvs, cv);
+        if (!opt_i) continue;
+
+        const double* ptr = src+*opt_i;
+        for (auto cable: R.D.geometry.cables(cv)) {
+            double area = R.cell.embedding().integrate_area(cable); // [µm²]
+            if (area>0) {
+                r.raw_handles.push_back(ptr);
+                r.weight.push_back(0.001*area); // Scale from [µm²·A/m²] to [nA].
+                r.metadata.push_back(cable);
+            }
         }
-        return fvm_probe_info{};
     }
-    else {
-        throw cable_cell_error("unrecognized probe address type");
+    r.metadata.shrink_to_fit();
+    return r;
+}
+
+template <typename B>
+fvm_probe_info resolve_probe(const cable_probe_ion_int_concentration& p, probe_resolution_data<B>& R) {
+    auto opt_i = R.ion_location_index(p.ion, p.location);
+    if (!opt_i) return {};
+
+    return fvm_probe_scalar{{R.state->ion_data.at(p.ion).Xi_.data()+*opt_i}, p.location};
+}
+
+template <typename B>
+fvm_probe_info resolve_probe(const cable_probe_ion_ext_concentration& p, probe_resolution_data<B>& R) {
+    auto opt_i = R.ion_location_index(p.ion, p.location);
+    if (!opt_i) return {};
+
+    return fvm_probe_scalar{{R.state->ion_data.at(p.ion).Xo_.data()+*opt_i}, p.location};
+}
+
+// Common implementation for int and ext concentrations across whole cell:
+template <typename B>
+fvm_probe_info resolve_ion_conc_common(const std::vector<fvm_index_type>& ion_cvs, const fvm_value_type* src, probe_resolution_data<B>& R) {
+    fvm_probe_multi r;
+    mcable_list cables;
+
+    for (auto i: util::count_along(ion_cvs)) {
+        for (auto cable: R.D.geometry.cables(ion_cvs[i])) {
+            if (cable.prox_pos!=cable.dist_pos) {
+                r.raw_handles.push_back(src+i);
+                cables.push_back(cable);
+            }
+        }
     }
+    r.metadata = std::move(cables);
+    r.shrink_to_fit();
+    return r;
+}
+
+template <typename B>
+fvm_probe_info resolve_probe(const cable_probe_ion_int_concentration_cell& p, probe_resolution_data<B>& R) {
+    if (!R.state->ion_data.count(p.ion)) return {};
+    return resolve_ion_conc_common<B>(R.M.ions.at(p.ion).cv, R.state->ion_data.at(p.ion).Xi_.data(), R);
+}
+
+template <typename B>
+fvm_probe_info resolve_probe(const cable_probe_ion_ext_concentration_cell& p, probe_resolution_data<B>& R) {
+    if (!R.state->ion_data.count(p.ion)) return {};
+    return resolve_ion_conc_common<B>(R.M.ions.at(p.ion).cv, R.state->ion_data.at(p.ion).Xo_.data(), R);
 }
 
 } // namespace arb
diff --git a/arbor/include/arbor/cable_cell.hpp b/arbor/include/arbor/cable_cell.hpp
index ed9d3a92..d942bb47 100644
--- a/arbor/include/arbor/cable_cell.hpp
+++ b/arbor/include/arbor/cable_cell.hpp
@@ -29,51 +29,153 @@ struct lid_range {
         begin(b), end(e) {}
 };
 
-// Each kind of probe has its own type for representing its address,
-// as below:
+// `cable_sample_range` is simply a pair of `const double*` pointers describing the sequence
+// of double values associated with the cell-wide sample.
 
-// Voltage estimate [mV] at `location`, possibly interpolated.
-struct cell_probe_membrane_voltage {
+using cable_sample_range = std::pair<const double*, const double*>;
+
+
+// Each kind of probe has its own type for representing its address, as below.
+//
+// Probe address specifications can be for _scalar_ data, associated with a fixed
+// location or synapse on a cell, or _vector_ data, associated with multiple
+// sites or sub-sections of a cell.
+//
+// Sampler functions receive an `any_ptr` to sampled data. The underlying pointer
+// type is a const pointer to:
+//     * `double` for scalar data;
+//     * `cable_sample_rang*` for vector data (see definition above).
+//
+// The metadata associated with a probe is also passed to a sampler via an `any_ptr`;
+// the underlying pointer will be a const pointer to one of the following metadata types:
+//     * `mlocation` for most scalar queries;
+//     * `cable_probe_point_info` for point mechanism state queries;
+//     * `mcable_list` for most vector queries;
+//     * `std::vector<cable_probe_point_info>` for cell-wide point mechanism state queries.
+
+// Metadata for point process probes.
+struct cable_probe_point_info {
+    cell_lid_type target;   // Target number of point process instance on cell.
+    unsigned multiplicity;  // Number of combined instances at this site.
+    mlocation loc;          // Point on cell morphology where instance is placed.
+};
+
+// Voltage estimate [mV] at `location`, interpolated.
+// Sample value type: `double`
+// Sample metadata type: `mlocation`
+struct cable_probe_membrane_voltage {
     mlocation location;
 };
 
-// Total current density [A/m²] across membrane excluding capacitive current at `location`.
-struct cell_probe_total_ionic_current_density {
+// Voltage estimate [mV], reported against each cable in each control volume. Not interpolated.
+// Sample value type: `cable_sample_range`
+// Sample metadata type: `mcable_list`
+struct cable_probe_membrane_voltage_cell {};
+
+// Axial current estimate [nA] at `location`, interpolated.
+// Sample value type: `double`
+// Sample metadata type: `mlocation`
+struct cable_probe_axial_current {
     mlocation location;
 };
 
+// Total current density [A/m²] across membrane _excluding_ capacitive current at `location`.
+// Sample value type: `cable_sample_range`
+// Sample metadata type: `mlocation`
+struct cable_probe_total_ion_current_density {
+    mlocation location;
+};
+
+// Total ionic current [nA] across membrance _excluding_ capacitive current across components of the cell.
+// Sample value type: `cable_sample_range`
+// Sample metadata type: `mcable_list`
+struct cable_probe_total_ion_current_cell {};
+
+// Total membrance current [nA] across components of the cell.
+// Sample value type: `cable_sample_range`
+// Sample metadata type: `mcable_list`
+struct cable_probe_total_current_cell {};
+
 // Value of state variable `state` in density mechanism `mechanism` in CV at `location`.
-struct cell_probe_density_state {
+// Sample value type: `double`
+// Sample metadata type: `mlocation`
+struct cable_probe_density_state {
     mlocation location;
     std::string mechanism;
     std::string state;
 };
 
+// Value of state variable `state` in density mechanism `mechanism` across components of the cell.
+// Sample value type: `cable_sample_range`
+// Sample metadata type: `mcable_list`
+struct cable_probe_density_state_cell {
+    std::string mechanism;
+    std::string state;
+};
+
 // Value of state variable `key` in point mechanism `source` at target `target`.
-struct cell_probe_point_state {
+// Sample value type: `double`
+// Sample metadata type: `cable_probe_point_info`
+struct cable_probe_point_state {
     cell_lid_type target;
     std::string mechanism;
     std::string state;
 };
 
+// Value of state variable `key` in point mechanism `source` at every target with this mechanism.
+// Metadata has one entry of type cable_probe_point_info for each matched (possibly coalesced) instance.
+// Sample value type: `cable_sample_range`
+// Sample metadata type: `std::vector<cable_probe_point_info>`
+struct cable_probe_point_state_cell {
+    std::string mechanism;
+    std::string state;
+};
+
 // Current density [A/m²] across membrane attributed to the ion `source` at `location`.
-struct cell_probe_ion_current_density {
+// Sample value type: `double`
+// Sample metadata type: `mlocation`
+struct cable_probe_ion_current_density {
     mlocation location;
     std::string ion;
 };
 
-// Ionic internal concentration [mmol/L] of ion `source` at `location`, possibly interpolated.
-struct cell_probe_ion_int_concentration {
+// Total ionic current [nA] attributed to the ion `source` across components of the cell.
+// Sample value type: `cable_sample_range`
+// Sample metadata type: `mcable_list`
+struct cable_probe_ion_current_cell {
+    std::string ion;
+};
+
+// Ionic internal concentration [mmol/L] of ion `source` at `location`.
+// Sample value type: `double`
+// Sample metadata type: `mlocation`
+struct cable_probe_ion_int_concentration {
     mlocation location;
     std::string ion;
 };
 
-// Ionic external concentration [mmol/L] of ion `source` at `location`, possibly interpolated.
-struct cell_probe_ion_ext_concentration {
+// Ionic internal concentration [mmol/L] of ion `source` across components of the cell.
+// Sample value type: `cable_sample_range`
+// Sample metadata type: `mcable_list`
+struct cable_probe_ion_int_concentration_cell {
+    std::string ion;
+};
+
+// Ionic external concentration [mmol/L] of ion `source` at `location`.
+// Sample value type: `double`
+// Sample metadata type: `mlocation`
+struct cable_probe_ion_ext_concentration {
     mlocation location;
     std::string ion;
 };
 
+// Ionic external concentration [mmol/L] of ion `source` across components of the cell.
+// Sample value type: `cable_sample_range`
+// Sample metadata type: `mcable_list`
+struct cable_probe_ion_ext_concentration_cell {
+    std::string ion;
+};
+
 // Forward declare the implementation, for PIMPL.
 struct cable_cell_impl;
 
@@ -96,6 +198,7 @@ struct placed {
     T item;
 };
 
+// Note: lid fields of elements of mlocation_map used in cable_cell are strictly increasing.
 template <typename T>
 using mlocation_map = std::vector<placed<T>>;
 
diff --git a/arbor/include/arbor/sampling.hpp b/arbor/include/arbor/sampling.hpp
index fc29c99d..f917e0c6 100644
--- a/arbor/include/arbor/sampling.hpp
+++ b/arbor/include/arbor/sampling.hpp
@@ -21,7 +21,13 @@ struct sample_record {
     util::any_ptr data;
 };
 
-using sampler_function = std::function<void (cell_member_type, probe_tag, std::size_t, const sample_record*)>;
+using sampler_function = std::function<
+    void (cell_member_type,     // probe id
+          probe_tag,            // probe tag associated with probe id
+          util::any_ptr,        // pointer to constant metadata
+          std::size_t,          // number of sample records
+          const sample_record*  // pointer to first sample record
+         )>;
 
 using sampler_association_handle = std::size_t;
 
diff --git a/arbor/include/arbor/simple_sampler.hpp b/arbor/include/arbor/simple_sampler.hpp
index 2814eb25..a95ae8c8 100644
--- a/arbor/include/arbor/simple_sampler.hpp
+++ b/arbor/include/arbor/simple_sampler.hpp
@@ -12,6 +12,7 @@
 #include <arbor/common_types.hpp>
 #include <arbor/sampling.hpp>
 #include <arbor/util/any_ptr.hpp>
+#include <arbor/util/optional.hpp>
 
 namespace arb {
 
@@ -21,32 +22,96 @@ struct trace_entry {
     V v;
 };
 
+// Trace data wraps a std::vector of trace_entry, optionally with
+// a copy of the metadata associated with a probe.
+
+template <typename V, typename Meta = void>
+struct trace_data: public std::vector<trace_entry<V>> {
+    util::optional<Meta> metadata;
+};
+
+template <typename V>
+struct trace_data<V, void>: public std::vector<trace_entry<V>> {};
+
+// Add a bit of smarts for collecting variable-length samples which are
+// passed back as a pair of pointers describing a range; these can
+// be used to populate a trace of vectors.
+
+template <typename V>
+struct trace_push_back {
+    template <typename Meta>
+    static bool push_back(trace_data<V, Meta>& trace, const sample_record& rec) {
+        if (auto p = util::any_cast<const V*>(rec.data)) {
+            trace.push_back({rec.time, *p});
+            return true;
+        }
+        return false;
+    }
+};
+
 template <typename V>
-using trace_data = std::vector<trace_entry<V>>;
+struct trace_push_back<std::vector<V>> {
+    template <typename Meta>
+    static bool push_back(trace_data<std::vector<V>, Meta>& trace, const sample_record& rec) {
+        if (auto p = util::any_cast<const std::vector<V>*>(rec.data)) {
+            trace.push_back({rec.time, *p});
+            return true;
+        }
+        else if (auto p = util::any_cast<const std::pair<const V*, const V*>*>(rec.data)) {
+            trace.push_back({rec.time, std::vector<V>(p->first, p->second)});
+            return true;
+        }
+        return false;
+    }
+};
 
-template <typename V, typename = std::enable_if_t<std::is_trivially_copyable<V>::value>>
+template <typename V, typename Meta = void>
 class simple_sampler {
 public:
-    explicit simple_sampler(trace_data<V>& trace): trace_(trace) {}
+    explicit simple_sampler(trace_data<V, Meta>& trace): trace_(trace) {}
 
-    void operator()(cell_member_type probe_id, probe_tag tag, std::size_t n, const sample_record* recs) {
-        for (std::size_t i = 0; i<n; ++i) {
-            if (auto p = util::any_cast<const V*>(recs[i].data)) {
-                trace_.push_back({recs[i].time, *p});
+    void operator()(cell_member_type probe_id, probe_tag tag, util::any_ptr meta, std::size_t n, const sample_record* recs) {
+        // TODO: C++17 use if constexpr to test for Meta = void case.
+        if (meta && !trace_.metadata) {
+            if (auto m = util::any_cast<const Meta*>(meta)) {
+                trace_.metadata = *m;
             }
             else {
+                throw std::runtime_error("unexpected metadata type in simple_sampler");
+            }
+        }
+
+        for (std::size_t i = 0; i<n; ++i) {
+            if (!trace_push_back<V>::push_back(trace_, recs[i])) {
                 throw std::runtime_error("unexpected sample type in simple_sampler");
             }
         }
     }
 
 private:
-    trace_data<V>& trace_;
+    trace_data<V, Meta>& trace_;
 };
 
 template <typename V>
-inline simple_sampler<V> make_simple_sampler(trace_data<V>& trace) {
-    return simple_sampler<V>(trace);
+class simple_sampler<V, void> {
+public:
+    explicit simple_sampler(trace_data<V, void>& trace): trace_(trace) {}
+
+    void operator()(cell_member_type probe_id, probe_tag tag, util::any_ptr meta, std::size_t n, const sample_record* recs) {
+        for (std::size_t i = 0; i<n; ++i) {
+            if (!trace_push_back<V>::push_back(trace_, recs[i])) {
+                throw std::runtime_error("unexpected sample type in simple_sampler");
+            }
+        }
+    }
+
+private:
+    trace_data<V, void>& trace_;
+};
+
+template <typename V, typename Meta>
+inline simple_sampler<V, Meta> make_simple_sampler(trace_data<V, Meta>& trace) {
+    return simple_sampler<V, Meta>(trace);
 }
 
 } // namespace arb
diff --git a/arbor/include/arbor/util/any_visitor.hpp b/arbor/include/arbor/util/any_visitor.hpp
new file mode 100644
index 00000000..348bdaa8
--- /dev/null
+++ b/arbor/include/arbor/util/any_visitor.hpp
@@ -0,0 +1,155 @@
+#pragma once
+
+// Provides `overload` function for constructing a functor with overloaded
+// `operator()` from a number of functions or function objects.
+//
+// Provides the `any_visitor` class, which will call a provided functional
+// with the contained value in a `util::any` if it is one of a fixed set
+// of types.
+
+#include <utility>
+#include <type_traits>
+
+#include <arbor/util/any.hpp>
+
+namespace arb {
+namespace util {
+
+namespace impl {
+
+template <typename> using void_t = void; // TODO: C++17 use std::void_t.
+
+template <typename X, typename Y>
+struct propagate_qualifier { using type = Y; };
+
+template <typename X, typename Y>
+struct propagate_qualifier<const X, Y> { using type = const Y; };
+
+template <typename X, typename Y>
+struct propagate_qualifier<X&, Y> { using type = Y&; };
+
+template <typename X, typename Y>
+struct propagate_qualifier<const X&, Y> { using type = const Y&; };
+
+template <typename X, typename Y>
+using propagate_qualifier_t = typename propagate_qualifier<X, Y>::type;
+
+} // namespace impl
+
+// A type `any_visitor<A, B, ...>` has one public static method
+// `visit(f, a)` where `a` is a possibly const lvalue or rvalue util::any,
+// and `f` is a functional object or function pointer.
+//
+// If `a` contains a value of any of the types `A, B, ...`, `f` will
+// be called with that value. If `a` is an lvalue, it will be passed by
+// lvaue reference; otherwise it will be moved.
+//
+// If `a` contains no value, or a value not in the type list `A, B, ...`,
+// then it will evaluate `f()` if it is defined, or else throw a
+// `bad_any_cast` exception.
+
+template <typename, typename...> struct any_visitor;
+
+template <typename T>
+struct any_visitor<T> {
+    template <typename F, typename = void>
+    struct invoke_or_throw {
+        template <typename A>
+        static auto visit(F&& f, A&& a) {
+            using Q = impl::propagate_qualifier_t<A, T>;
+            return util::any_cast<T>(&a)?
+                   std::forward<F>(f)(util::any_cast<Q&&>(std::forward<A>(a))):
+                   throw ::arb::util::bad_any_cast();
+        }
+    };
+
+    template <typename F>
+    struct invoke_or_throw<F, impl::void_t<decltype(std::declval<F>()())>> {
+        template <typename A>
+        static auto visit(F&& f, A&& a) {
+            using Q = impl::propagate_qualifier_t<A, T>;
+            return util::any_cast<T>(&a)?
+                   std::forward<F>(f)(util::any_cast<Q&&>(std::forward<A>(a))):
+                   std::forward<F>(f)();
+        }
+    };
+
+    template <typename F, typename A,
+        typename = std::enable_if_t<std::is_same<util::any, std::decay_t<A>>::value>
+    >
+    static auto visit(F&& f, A&& a) {
+        return invoke_or_throw<F>::visit(std::forward<F>(f), std::forward<A>(a));
+    }
+};
+
+template <typename T, typename U, typename... Rest>
+struct any_visitor<T, U, Rest...> {
+    template <typename F, typename A,
+        typename = std::enable_if_t<std::is_same<util::any, std::decay_t<A>>::value>
+    >
+    static auto visit(F&& f, A&& a) {
+        using Q = impl::propagate_qualifier_t<A, T>;
+        return util::any_cast<T>(&a)?
+               std::forward<F>(f)(util::any_cast<Q&&>(std::forward<A>(a))):
+               any_visitor<U, Rest...>::visit(std::forward<F>(f), std::forward<A>(a));
+    }
+};
+
+namespace impl {
+
+template <typename F, typename... A>
+struct invocable_impl {
+    template <typename G, typename = void>
+    struct test: std::false_type {};
+
+    template <typename G>
+    struct test<G, void_t<decltype(std::declval<G>()(std::declval<A>()...))>>: std::true_type {};
+
+    using type = typename test<F>::type;
+};
+
+template <typename F, typename... A>
+using invocable = typename invocable_impl<F, A...>::type;
+
+template <typename, typename...> struct overload_impl {};
+
+template <typename F1>
+struct overload_impl<F1> {
+    F1 f_;
+
+    overload_impl(F1&& f1): f_(std::forward<F1>(f1)) {}
+
+    template <typename... A, std::enable_if_t<invocable<F1, A...>::value, int> = 0>
+    decltype(auto) operator()(A&&... a) { return f_(std::forward<A>(a)...); }
+};
+
+template <typename F1, typename F2, typename... Fn>
+struct overload_impl<F1, F2, Fn...>: overload_impl<F2, Fn...> {
+    F1 f_;
+
+    overload_impl(F1&& f1, F2&& f2, Fn&&... fn):
+        overload_impl<F2, Fn...>(std::forward<F2>(f2), std::forward<Fn>(fn)...),
+        f_(std::forward<F1>(f1)) {}
+
+    template <typename... A, std::enable_if_t<invocable<F1, A...>::value, int> = 0>
+    decltype(auto) operator()(A&&... a) { return f_(std::forward<A>(a)...); }
+
+    template <typename... A, std::enable_if_t<!invocable<F1, A...>::value, int> = 0>
+    decltype(auto) operator()(A&&... a) {
+        return overload_impl<F2, Fn...>::operator()(std::forward<A>(a)...);
+    }
+};
+
+} // namespace impl
+
+
+// `overload(f, g, h, ...)` returns a functional object whose `operator()` is overloaded
+// with those of `f`, `g`, `h`, ... in decreasing order of precedence.
+
+template <typename... Fn>
+auto overload(Fn&&... fn) {
+    return impl::overload_impl<Fn...>(std::forward<Fn>(fn)...);
+}
+
+} // namespace util
+} // namespace arb
diff --git a/arbor/include/arbor/util/variant.hpp b/arbor/include/arbor/util/variant.hpp
index 09700a1c..5b2715b8 100644
--- a/arbor/include/arbor/util/variant.hpp
+++ b/arbor/include/arbor/util/variant.hpp
@@ -431,7 +431,7 @@ struct variant {
     auto get_if() noexcept { return get_if<I>(); }
 
     template <std::size_t I, typename = std::enable_if_t<(I<sizeof...(T))>, typename X = type_select_t<I, T...>>
-    const X* get_if() const noexcept { return which_==I? data_ptr<>(): nullptr; }
+    const X* get_if() const noexcept { return which_==I? data_ptr<X>(): nullptr; }
 
     template <typename X, std::size_t I = type_index<X, T...>::value>
     auto get_if() const noexcept { return get_if<I>(); }
@@ -502,9 +502,6 @@ struct variant_alternative<I, variant<T...>> { using type = type_select_t<I, T..
 template <std::size_t I, typename... T>
 struct variant_alternative<I, const variant<T...>> { using type = std::add_const_t<type_select_t<I, T...>>; };
 
-template <typename Visitor, typename... Variant>
-using visit_return_t = decltype(std::declval<Visitor>()(std::declval<typename variant_alternative<0, std::remove_volatile_t<std::remove_reference_t<Variant>>>::type>()...));
-
 } // namespace detail
 
 template <typename... T>
@@ -560,7 +557,7 @@ decltype(auto) get_if(const variant<T...>& v) noexcept { return v.template get_i
 
 template <typename Visitor, typename Variant>
 decltype(auto) visit(Visitor&& f, Variant&& v) {
-    using R = detail::visit_return_t<Visitor&&, Variant&&>;
+    using R = decltype(f(get<0>(std::forward<Variant>(v))));
 
     if (v.valueless_by_exception()) throw bad_variant_access{};
     std::size_t i = v.index();
diff --git a/arbor/mc_cell_group.cpp b/arbor/mc_cell_group.cpp
index ce142817..0032d62a 100644
--- a/arbor/mc_cell_group.cpp
+++ b/arbor/mc_cell_group.cpp
@@ -4,6 +4,7 @@
 
 #include <arbor/assert.hpp>
 #include <arbor/common_types.hpp>
+#include <arbor/cable_cell.hpp>
 #include <arbor/sampling.hpp>
 #include <arbor/recipe.hpp>
 #include <arbor/spike.hpp>
@@ -79,6 +80,245 @@ void mc_cell_group::set_binning_policy(binning_kind policy, time_type bin_interv
     binners_.resize(gids_.size(), event_binner(policy, bin_interval));
 }
 
+// Probe-type specific sample data marshalling.
+
+struct sampler_call_info {
+    sampler_function sampler;
+    cell_member_type probe_id;
+    probe_tag tag;
+
+    // Offsets are into lowered cell sample time and event arrays.
+    sample_size_type begin_offset;
+    sample_size_type end_offset;
+};
+
+// Working space for computing and collating data for samplers.
+using fvm_probe_scratch = std::tuple<std::vector<double>, std::vector<cable_sample_range>>;
+
+template <typename VoidFn, typename... A>
+void tuple_foreach(VoidFn&& f, std::tuple<A...>& t) {
+    (void)(int []){(f(std::get<A>(t)), 0)...};
+}
+
+void reserve_scratch(fvm_probe_scratch& scratch, std::size_t n) {
+    tuple_foreach([n](auto& v) { v.reserve(n); }, scratch);
+}
+
+void run_samples(
+    const missing_probe_info&,
+    const sampler_call_info&,
+    const fvm_value_type*,
+    const fvm_value_type*,
+    std::vector<sample_record>&,
+    fvm_probe_scratch&)
+{
+    throw arbor_internal_error("invalid fvm_probe_info in sampler map");
+}
+
+void run_samples(
+    const fvm_probe_scalar& p,
+    const sampler_call_info& sc,
+    const fvm_value_type* raw_times,
+    const fvm_value_type* raw_samples,
+    std::vector<sample_record>& sample_records,
+    fvm_probe_scratch&)
+{
+    // Scalar probes do not need scratch space — provided that the user-presented
+    // sample type (double) matches the raw type (fvm_value_type).
+    static_assert(std::is_same<double, fvm_value_type>::value, "require sample value translation");
+
+    sample_size_type n_sample = sc.end_offset-sc.begin_offset;
+    sample_records.clear();
+    for (auto i = sc.begin_offset; i!=sc.end_offset; ++i) {
+       sample_records.push_back(sample_record{time_type(raw_times[i]), &raw_samples[i]});
+    }
+
+    // Metadata may be an mlocation or cell_lid_type (for point mechanism state probes).
+    util::visit(
+        [&](auto& metadata) { sc.sampler(sc.probe_id, sc.tag, &metadata, n_sample, sample_records.data()); },
+        p.metadata);
+}
+
+void run_samples(
+    const fvm_probe_interpolated& p,
+    const sampler_call_info& sc,
+    const fvm_value_type* raw_times,
+    const fvm_value_type* raw_samples,
+    std::vector<sample_record>& sample_records,
+    fvm_probe_scratch& scratch)
+{
+    constexpr sample_size_type n_raw_per_sample = 2;
+    sample_size_type n_sample = (sc.end_offset-sc.begin_offset)/n_raw_per_sample;
+    arb_assert((sc.end_offset-sc.begin_offset)==n_sample*n_raw_per_sample);
+
+    auto& tmp = std::get<std::vector<double>>(scratch);
+    tmp.clear();
+    sample_records.clear();
+
+    for (sample_size_type j = 0; j<n_sample; ++j) {
+        auto offset = j*n_raw_per_sample+sc.begin_offset;
+        tmp.push_back(p.coef[0]*raw_samples[offset] + p.coef[1]*raw_samples[offset+1]);
+    }
+
+    const auto& ctmp = tmp;
+    for (sample_size_type j = 0; j<n_sample; ++j) {
+        auto offset = j*n_raw_per_sample+sc.begin_offset;
+        sample_records.push_back(sample_record{time_type(raw_times[offset]), &ctmp[j]});
+    }
+
+    sc.sampler(sc.probe_id, sc.tag, &p.metadata, n_sample, sample_records.data());
+}
+
+void run_samples(
+    const fvm_probe_multi& p,
+    const sampler_call_info& sc,
+    const fvm_value_type* raw_times,
+    const fvm_value_type* raw_samples,
+    std::vector<sample_record>& sample_records,
+    fvm_probe_scratch& scratch)
+{
+    const sample_size_type n_raw_per_sample = p.raw_handles.size();
+    sample_size_type n_sample = (sc.end_offset-sc.begin_offset)/n_raw_per_sample;
+    arb_assert((sc.end_offset-sc.begin_offset)==n_sample*n_raw_per_sample);
+
+    auto& sample_ranges = std::get<std::vector<cable_sample_range>>(scratch);
+    sample_ranges.clear();
+    sample_records.clear();
+
+    for (sample_size_type j = 0; j<n_sample; ++j) {
+        auto offset = j*n_raw_per_sample+sc.begin_offset;
+        sample_ranges.push_back({raw_samples+offset, raw_samples+offset+n_raw_per_sample});
+    }
+
+    const auto& csample_ranges = sample_ranges;
+    for (sample_size_type j = 0; j<n_sample; ++j) {
+        auto offset = j*n_raw_per_sample+sc.begin_offset;
+        sample_records.push_back(sample_record{time_type(raw_times[offset]), &csample_ranges[j]});
+    }
+
+    // Metadata may be an mlocation_list or mcable_list.
+    util::visit(
+        [&](auto& metadata) { sc.sampler(sc.probe_id, sc.tag, &metadata, n_sample, sample_records.data()); },
+        p.metadata);
+}
+
+void run_samples(
+    const fvm_probe_weighted_multi& p,
+    const sampler_call_info& sc,
+    const fvm_value_type* raw_times,
+    const fvm_value_type* raw_samples,
+    std::vector<sample_record>& sample_records,
+    fvm_probe_scratch& scratch)
+{
+    const sample_size_type n_raw_per_sample = p.raw_handles.size();
+    sample_size_type n_sample = (sc.end_offset-sc.begin_offset)/n_raw_per_sample;
+    arb_assert((sc.end_offset-sc.begin_offset)==n_sample*n_raw_per_sample);
+    arb_assert((unsigned)n_raw_per_sample==p.weight.size());
+
+    auto& sample_ranges = std::get<std::vector<cable_sample_range>>(scratch);
+    sample_ranges.clear();
+    sample_records.clear();
+
+    auto& tmp = std::get<std::vector<double>>(scratch);
+    tmp.clear();
+    tmp.reserve(n_raw_per_sample*n_sample);
+
+    for (sample_size_type j = 0; j<n_sample; ++j) {
+        auto offset = j*n_raw_per_sample+sc.begin_offset;
+        for (sample_size_type i = 0; i<n_raw_per_sample; ++i) {
+            tmp.push_back(raw_samples[offset+i]*p.weight[i]);
+        }
+    }
+
+    const double* tmp_ptr = tmp.data();
+    for (sample_size_type j = 0; j<n_sample; ++j) {
+        sample_ranges.push_back({tmp_ptr, tmp_ptr+n_raw_per_sample});
+        tmp_ptr += n_raw_per_sample;
+    }
+
+    const auto& csample_ranges = sample_ranges;
+    for (sample_size_type j = 0; j<n_sample; ++j) {
+        auto offset = j*n_raw_per_sample+sc.begin_offset;
+        sample_records.push_back(sample_record{time_type(raw_times[offset]), &csample_ranges[j]});
+    }
+
+    // (Unlike fvm_probe_multi, we only have mcable_list metadata.)
+    sc.sampler(sc.probe_id, sc.tag, &p.metadata, n_sample, sample_records.data());
+}
+void run_samples(
+    const fvm_probe_membrane_currents& p,
+    const sampler_call_info& sc,
+    const fvm_value_type* raw_times,
+    const fvm_value_type* raw_samples,
+    std::vector<sample_record>& sample_records,
+    fvm_probe_scratch& scratch)
+{
+    const sample_size_type n_raw_per_sample = p.raw_handles.size();
+    sample_size_type n_sample = (sc.end_offset-sc.begin_offset)/n_raw_per_sample;
+    arb_assert((sc.end_offset-sc.begin_offset)==n_sample*n_raw_per_sample);
+
+    const auto n_cable = p.metadata.size();
+    const auto n_cv = p.cv_parent_cond.size();
+    const auto cables_by_cv = util::partition_view(p.cv_cables_divs);
+
+    auto& sample_ranges = std::get<std::vector<cable_sample_range>>(scratch);
+    sample_ranges.clear();
+
+    auto& tmp = std::get<std::vector<double>>(scratch);
+    tmp.assign(n_cable*n_sample, 0.);
+
+    sample_records.clear();
+
+    for (sample_size_type j = 0; j<n_sample; ++j) {
+        auto offset = j*n_raw_per_sample+sc.begin_offset;
+        auto tmp_base = tmp.data()+j*n_cable;
+
+        // Each CV voltage contributes to the current sum of its parent's cables
+        // and its own cables.
+
+        const double* v = raw_samples+offset;
+        for (auto cv: util::make_span(n_cv)) {
+            fvm_index_type parent_cv = p.cv_parent[cv];
+            if (parent_cv+1==0) continue;
+
+            double cond = p.cv_parent_cond[cv];
+
+            double cv_I = v[cv]*cond;
+            double parent_cv_I = v[parent_cv]*cond;
+
+            for (auto cable_i: util::make_span(cables_by_cv[cv])) {
+                tmp_base[cable_i] -= (cv_I-parent_cv_I)*p.weight[cable_i];
+            }
+
+            for (auto cable_i: util::make_span(cables_by_cv[parent_cv])) {
+                tmp_base[cable_i] += (cv_I-parent_cv_I)*p.weight[cable_i];
+            }
+        }
+
+        sample_ranges.push_back({tmp_base, tmp_base+n_cable});
+    }
+
+    const auto& csample_ranges = sample_ranges;
+    for (sample_size_type j = 0; j<n_sample; ++j) {
+        auto offset = j*n_raw_per_sample+sc.begin_offset;
+        sample_records.push_back(sample_record{time_type(raw_times[offset]), &csample_ranges[j]});
+    }
+
+    sc.sampler(sc.probe_id, sc.tag, &p.metadata, n_sample, sample_records.data());
+}
+
+// Generic run_samples dispatches on probe info variant type.
+void run_samples(
+    const fvm_probe_info& p,
+    const sampler_call_info& sc,
+    const fvm_value_type* raw_times,
+    const fvm_value_type* raw_samples,
+    std::vector<sample_record>& sample_records,
+    fvm_probe_scratch& scratch)
+{
+    util::visit([&](auto& x) {run_samples(x, sc, raw_times, raw_samples, sample_records, scratch); }, p.info);
+}
+
 void mc_cell_group::advance(epoch ep, time_type dt, const event_lane_subrange& event_lanes) {
     time_type tstart = lowered_->time();
 
@@ -133,8 +373,8 @@ void mc_cell_group::advance(epoch ep, time_type dt, const event_lane_subrange& e
     //
     // For each (schedule, sampler, probe set) in the sampler association
     // map that will be triggered in this integration interval, create
-    // sample events for the lowered cell, one for each scheduled sample
-    // time and probe in the probe set.
+    // sample events for the lowered cell, one or more for each scheduled
+    // sample time and probe in the probe set.
     //
     // Each event is associated with an offset into the sample data and
     // time buffers; these are assigned contiguously such that one call to
@@ -142,16 +382,6 @@ void mc_cell_group::advance(epoch ep, time_type dt, const event_lane_subrange& e
     // value as defined below, grouping together all the samples of the
     // same probe for this callback in this association.
 
-    struct sampler_call_info {
-        sampler_function sampler;
-        cell_member_type probe_id;
-        probe_tag tag;
-
-        // Offsets are into lowered cell sample time and event arrays.
-        sample_size_type begin_offset;
-        sample_size_type end_offset;
-    };
-
     PE(advance_samplesetup);
     std::vector<sampler_call_info> call_info;
 
@@ -171,14 +401,18 @@ void mc_cell_group::advance(epoch ep, time_type dt, const event_lane_subrange& e
         for (cell_member_type pid: sa.probe_ids) {
             auto cell_index = gid_index_map_.at(pid.gid);
             auto p = probe_map_[pid];
+            sample_size_type n_raw = p.handle.n_raw();
 
-            call_info.push_back({sa.sampler, pid, p.tag, n_samples, n_samples+n_times});
+            call_info.push_back({sa.sampler, pid, p.tag, n_samples, n_samples+n_times*n_raw});
 
             for (auto t: sample_times) {
-                sample_event ev{t, (cell_gid_type)cell_to_intdom_[cell_index], {p.handle, n_samples++}};
-                sample_events.push_back(ev);
+                for (probe_handle h: p.handle.raw_handle_range()) {
+                    sample_event ev{t, (cell_gid_type)cell_to_intdom_[cell_index], {h, n_samples++}};
+                    sample_events.push_back(ev);
+                }
             }
         }
+        arb_assert(n_samples==call_info.back().end_offset);
     }
 
     // Sample events must be ordered by time for the lowered cell.
@@ -197,13 +431,12 @@ void mc_cell_group::advance(epoch ep, time_type dt, const event_lane_subrange& e
     std::vector<sample_record> sample_records;
     sample_records.reserve(max_samples_per_call);
 
-    for (auto& sc: call_info) {
-        sample_records.clear();
-        for (auto i = sc.begin_offset; i!=sc.end_offset; ++i) {
-           sample_records.push_back(sample_record{time_type(result.sample_time[i]), &result.sample_value[i]});
-        }
+    fvm_probe_scratch scratch;
+    reserve_scratch(scratch, max_samples_per_call);
 
-        sc.sampler(sc.probe_id, sc.tag, sc.end_offset-sc.begin_offset, sample_records.data());
+    for (auto& sc: call_info) {
+        run_samples(probe_map_[sc.probe_id].handle, sc, result.sample_time.data(), result.sample_value.data(),
+            sample_records, scratch);
     }
     PL();
 
diff --git a/arbor/mc_cell_group.hpp b/arbor/mc_cell_group.hpp
index 70f255dc..d11da380 100644
--- a/arbor/mc_cell_group.hpp
+++ b/arbor/mc_cell_group.hpp
@@ -89,7 +89,7 @@ private:
     std::vector<target_handle> target_handles_;
 
     // Maps probe ids to probe handles (from lowered cell) and tags (from probe descriptions).
-    probe_association_map<probe_handle> probe_map_;
+    probe_association_map<fvm_probe_info> probe_map_;
 
     // Collection of samplers to be run against probes in this group.
     sampler_association_map sampler_map_;
diff --git a/arbor/morph/embed_pwlin.cpp b/arbor/morph/embed_pwlin.cpp
index ae3d0f21..f20bfb27 100644
--- a/arbor/morph/embed_pwlin.cpp
+++ b/arbor/morph/embed_pwlin.cpp
@@ -116,11 +116,11 @@ mcable_list data_cmp(const branch_pw_ratpoly<1, 0>& f, unsigned bid, double val,
 }
 
 struct embed_pwlin_data {
-    branch_pw_ratpoly<1, 0> length;
-    branch_pw_ratpoly<1, 0> directed_projection;
-    branch_pw_ratpoly<1, 0> radius;
-    branch_pw_ratpoly<2, 0> area;
-    branch_pw_ratpoly<1, 1> ixa;
+    branch_pw_ratpoly<1, 0> length; // [µm]
+    branch_pw_ratpoly<1, 0> directed_projection; // [µm]
+    branch_pw_ratpoly<1, 0> radius; // [µm]
+    branch_pw_ratpoly<2, 0> area; // [µm²]
+    branch_pw_ratpoly<1, 1> ixa;  // [1/µm]
 
     explicit embed_pwlin_data(msize_t n_branch):
         length(n_branch),
diff --git a/doc/cpp_cable_cell.rst b/doc/cpp_cable_cell.rst
index 56c42a42..8418546d 100644
--- a/doc/cpp_cable_cell.rst
+++ b/doc/cpp_cable_cell.rst
@@ -256,3 +256,306 @@ constants.
    gprop.catalogue = &mycat;
    gprop.default_parameters.reversal_potential_method["ca"] = "nernst1998/ca";
 
+
+Cable cell probes
+-----------------
+
+Various properties of a a cable cell can be sampled via one of the cable cell
+specific probe address described below. They fall into two classes: scalar
+probes are associated with a single real value, such as a membrane voltage
+or mechanism state value at a particular location; vector probes return
+multiple values corresponding to a quantity sampled over a whole cell.
+
+The sample data associated with a cable cell probe will either be a ``double``
+for scalar probes, or a ``cable_sample_range`` describing a half-open range
+of ``double`` values:
+
+.. code::
+
+   using cable_sample_range = std::pair<const double*, const double*>
+
+The probe metadata passed to the sampler will be a const pointer to:
+
+*   ``mlocation`` for most scalar probes;
+
+*   ``cable_probe_point_info`` for point mechanism state queries;
+
+*   ``mcable_list`` for most vector queries;
+
+*   ``std::vector<cable_probe_point_info>`` for cell-wide point mechanism state queries.
+
+The type ``cable_probe_point_info`` holds metadata for a single target on a cell:
+
+.. code::
+    
+    struct cable_probe_point_info {
+        // Target number of point process instance on cell.
+        cell_lid_type target;
+
+        // Number of combined instances at this site.
+        unsigned multiplicity;
+
+        // Point on cell morphology where instance is placed.
+        mlocation loc;
+    };
+
+Note that the ``multiplicity`` will always be 1 if synapse coalescing is
+disabled.
+
+Cable cell probes that contingently do not correspond to a valid measurable
+quantity are ignored: samplers attached to them will receive no values.
+Mechanism state queries however will throw a ``cable_cell_error`` exception
+at simulation initialization if the requested state variable does not exist
+on the mechanism.
+
+Membrane voltage
+^^^^^^^^^^^^^^^^
+
+.. code::
+
+    struct cable_probe_membrane_voltage {
+        mlocation location;
+    };
+
+Queries cell membrane potential at the specified location.
+
+*  Sample value: ``double``. Membrane potential in millivolts.
+
+*  Metadata: ``mlocation``. Location as given in the probe address.
+
+
+.. code::
+
+    struct cable_probe_membrane_voltage_cell {};
+
+Queries cell membrane potential across whole cell.
+
+*  Sample value: ``cable_sample_range``. Each value is the
+   average membrane potential in millivolts across an unbranched
+   component of the cell, as determined by the discretization.
+
+*  Metadata: ``mcable_list``. Each cable in the cable list describes
+   the unbranched component for the corresponding sample value.
+
+Axial current
+^^^^^^^^^^^^^
+
+.. code::
+
+    struct cable_probe_axial_current {
+        mlocation location;
+    };
+
+Estimate intracellular current at given location in the distal direction.
+
+*  Sample value: ``double``. Current in nanoamperes.
+
+*  Metadata: ``mlocation``. Location as given in the probe address.
+
+
+Transmembrane current
+^^^^^^^^^^^^^^^^^^^^^
+
+.. code::
+
+    struct cable_probe_ion_current_density {
+        mlocation location;
+        std::string ion;
+    };
+
+Membrance current density attributed to a particular ion at a given location.
+
+*  Sample value: ``double``. Current density in amperes per square metre.
+
+*  Metadata: ``mlocation``. Location as given in the probe address.
+
+
+.. code::
+
+    struct cable_probe_ion_current_cell {
+        std::string ion;
+    };
+
+Membrane current attributed to a particular ion across components of the cell.
+
+*  Sample value: ``cable_sample_range``. Each value is the current in
+   nanoamperes across an unbranched component of the cell, as determined
+   by the discretization.
+
+*  Metadata: ``mcable_list``. Each cable in the cable list describes
+   the unbranched component for the corresponding sample value.
+
+
+.. code::
+
+    struct cable_probe_total_ion_current_density {
+        mlocation location;
+    };
+
+Membrane current density at gvien location _excluding_ capacitive currents.
+
+*  Sample value: ``double``. Current density in amperes per square metre.
+
+*  Metadata: ``mlocation``. Location as given in the probe address.
+
+
+.. code::
+
+    struct cable_probe_total_ion_current_cell {};
+
+Membrane current _excluding_ capacitive currents across components of the cell.
+
+*  Sample value: ``cable_sample_range``. Each value is the current in
+   nanoamperes across an unbranched component of the cell, as determined
+   by the discretization.
+
+*  Metadata: ``mcable_list``. Each cable in the cable list describes
+   the unbranched component for the corresponding sample value.
+
+
+.. code::
+
+    struct cable_probe_total_current_cell {};
+
+Total membrance current across components of the cell.
+
+*  Sample value: ``cable_sample_range``. Each value is the current in
+   nanoamperes across an unbranched component of the cell, as determined
+   by the discretization.
+
+*  Metadata: ``mcable_list``. Each cable in the cable list describes
+   the unbranched component for the corresponding sample value.
+
+
+Ion concentration
+^^^^^^^^^^^^^^^^^
+
+.. code::
+
+    struct cable_probe_ion_int_concentration {
+        mlocation location;
+        std::string ion;
+    };
+
+Ionic internal concentration of ion at given location.
+
+*  Sample value: ``double``. Ion concentration in millimoles per litre.
+
+*  Metadata: ``mlocation``. Location as given in the probe address.
+
+
+.. code::
+
+    struct cable_probe_ion_int_concentration_cell {
+        std::string ion;
+    };
+
+Ionic external concentration of ion across components of the cell.
+
+*  Sample value: ``cable_sample_range``. Each value is the concentration in
+   millimoles per lire across an unbranched component of the cell, as determined
+   by the discretization.
+
+*  Metadata: ``mcable_list``. Each cable in the cable list describes
+   the unbranched component for the corresponding sample value.
+
+
+.. code::
+
+    struct cable_probe_ion_ext_concentration {
+        mlocation location;
+        std::string ion;
+    };
+
+Ionic internal concentration of ion at given location.
+
+*  Sample value: ``double``. Ion concentration in millimoles per litre.
+
+*  Metadata: ``mlocation``. Location as given in the probe address.
+
+
+.. code::
+
+    struct cable_probe_ion_ext_concentration_cell {
+        std::string ion;
+    };
+
+Ionic external concentration of ion across components of the cell.
+
+*  Sample value: ``cable_sample_range``. Each value is the concentration in
+   millimoles per lire across an unbranched component of the cell, as determined
+   by the discretization.
+
+*  Metadata: ``mcable_list``. Each cable in the cable list describes
+   the unbranched component for the corresponding sample value.
+
+
+
+Mechanism state
+^^^^^^^^^^^^^^^
+
+.. code::
+
+    struct cable_probe_density_state {
+        mlocation location;
+        std::string mechanism;
+        std::string state;
+    };
+
+
+Value of state variable in a density mechanism in at given location.
+If the mechanism is not defined at the location, the probe is ignored.
+
+*  Sample value: ``double``. State variable value.
+
+*  Metadata: ``mlocation``. Location as given in the probe address.
+
+
+.. code::
+
+    struct cable_probe_density_state_cell {
+        std::string mechanism;
+        std::string state;
+    };
+
+Value of state variable in adensity mechanism across components of the cell.
+
+*  Sample value: ``cable_sample_range``. State variable values from the
+   mechanism across unbranched components of the cell, as determined
+   by the discretization and mechanism extent.
+
+*  Metadata: ``mcable_list``. Each cable in the cable list describes
+   the unbranched component for the corresponding sample value.
+
+
+.. code::
+
+    struct cable_probe_point_state {
+        cell_lid_type target;
+        std::string mechanism;
+        std::string state;
+    };
+
+Value of state variable in a point mechanism associated with the given target.
+If the mechanism is not associated with this target, the probe is ignored.
+
+*  Sample value: ``double``. State variable value.
+
+*  Metadata: ``cable_probe_point_info``. Target number, multiplicity and location.
+
+
+.. code::
+
+    struct cable_probe_point_state_cell {
+        std::string mechanism;
+        std::string state;
+    };
+
+Value of state variable in a point mechanism for each of the targets in the cell
+with which it is associated.
+
+*  Sample value: ``cable_sample_range``. State variable values at each associated
+   target.
+
+*  Metadata: ``std::vector<cable_probe_point_info>``. Target metadata for each
+   associated target.
diff --git a/doc/sampling_api.rst b/doc/sampling_api.rst
index 1c025511..da4a80b6 100644
--- a/doc/sampling_api.rst
+++ b/doc/sampling_api.rst
@@ -77,14 +77,19 @@ will be passed to a sampler function or function object:
     .. code-block:: cpp
 
             using sampler_function =
-                std::function<void (cell_member_type, probe_tag, size_t, const sample_record*)>;
+                std::function<void (cell_member_type, probe_tag, any_ptr, 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.
+where the parameters are respectively the probe id, the tag, a typed
+pointer to probe metadata, the number of samples, and finally a pointer
+to the sequence of sample records.
 
 The ``probe_tag`` is the key given in the ``probe_info`` returned by
 the recipe.
 
+The ``any_ptr`` value points to const probe-specific metadata; the type
+of the metadata will depend upon the probe address specified in the
+``probe_info`` provided by the recipe.
+
 One ``sample_record`` struct contains one sample of the probe data at a
 given simulation time point:
 
@@ -117,7 +122,7 @@ function. A simple sampler implementation for ``double`` data might be:
 
                 explicit scalar_sample(sample_data& samples): samples(samples) {}
 
-                void operator()(cell_member_type id, probe_tag, size_t n, const sample_record* records) {
+                void operator()(cell_member_type id, probe_tag, any_ptr, size_t n, const sample_record* records) {
                     for (size_t i=0; i<n; ++i) {
                         const auto& rec = records[i];
 
diff --git a/example/CMakeLists.txt b/example/CMakeLists.txt
index aeed60d2..0c33de29 100644
--- a/example/CMakeLists.txt
+++ b/example/CMakeLists.txt
@@ -9,3 +9,4 @@ add_subdirectory(bench)
 add_subdirectory(ring)
 add_subdirectory(gap_junctions)
 add_subdirectory(single)
+add_subdirectory(probe-demo)
diff --git a/example/dryrun/dryrun.cpp b/example/dryrun/dryrun.cpp
index c8354c26..e40891c7 100644
--- a/example/dryrun/dryrun.cpp
+++ b/example/dryrun/dryrun.cpp
@@ -3,13 +3,13 @@
  *
  */
 
+#include <cassert>
 #include <fstream>
 #include <iomanip>
 #include <iostream>
 
 #include <nlohmann/json.hpp>
 
-#include <arbor/assert_macro.hpp>
 #include <arbor/common_types.hpp>
 #include <arbor/context.hpp>
 #include <arbor/cable_cell.hpp>
@@ -129,7 +129,7 @@ public:
         // Measure at the soma.
         arb::mlocation loc{0, 0.0};
 
-        return arb::probe_info{id, 0, arb::cell_probe_membrane_voltage{loc}};
+        return arb::probe_info{id, 0, arb::cable_probe_membrane_voltage{loc}};
     }
 
 private:
@@ -164,7 +164,7 @@ int main(int argc, char** argv) {
             }
         }
 #endif
-        arb_assert(arb::num_ranks(ctx)==params.num_ranks);
+        assert(arb::num_ranks(ctx)==params.num_ranks);
 
 
 #ifdef ARB_PROFILE_ENABLED
diff --git a/example/gap_junctions/gap_junctions.cpp b/example/gap_junctions/gap_junctions.cpp
index 4168aa76..4f3001fc 100644
--- a/example/gap_junctions/gap_junctions.cpp
+++ b/example/gap_junctions/gap_junctions.cpp
@@ -101,7 +101,7 @@ public:
     arb::probe_info get_probe(cell_member_type id) const override {
         // Measure at the soma.
         arb::mlocation loc{0, 1.};
-        return arb::probe_info{id, 0, arb::cell_probe_membrane_voltage{loc}};
+        return arb::probe_info{id, 0, arb::cable_probe_membrane_voltage{loc}};
     }
 
     arb::util::any get_global_properties(cell_kind k) const override {
diff --git a/example/generators/generators.cpp b/example/generators/generators.cpp
index bc392b43..9b62067f 100644
--- a/example/generators/generators.cpp
+++ b/example/generators/generators.cpp
@@ -6,6 +6,7 @@
  * event generators, one inhibitory, and one excitatory, are attached.
  */
 
+#include <cassert>
 #include <fstream>
 #include <iomanip>
 #include <iostream>
@@ -65,7 +66,7 @@ public:
     }
 
     cell_kind get_cell_kind(cell_gid_type gid) const override {
-        arb_assert(gid==0); // There is only one cell in the model
+        assert(gid==0); // There is only one cell in the model
         return cell_kind::cable;
     }
 
@@ -77,13 +78,13 @@ public:
 
     // The cell has one target synapse, which receives both inhibitory and exchitatory inputs.
     cell_size_type num_targets(cell_gid_type gid) const override {
-        arb_assert(gid==0); // There is only one cell in the model
+        assert(gid==0); // There is only one cell in the model
         return 1;
     }
 
     // Return two generators attached to the one cell.
     std::vector<arb::event_generator> event_generators(cell_gid_type gid) const override {
-        arb_assert(gid==0); // There is only one cell in the model
+        assert(gid==0); // There is only one cell in the model
 
         using RNG = std::mt19937_64;
 
@@ -116,17 +117,17 @@ public:
 
     // There is one probe (for measuring voltage at the soma) on the cell
     cell_size_type num_probes(cell_gid_type gid)  const override {
-        arb_assert(gid==0); // There is only one cell in the model
+        assert(gid==0); // There is only one cell in the model
         return 1;
     }
 
     arb::probe_info get_probe(cell_member_type id) const override {
-        arb_assert(id.gid==0);     // There is one cell,
-        arb_assert(id.index==0);   // with one probe.
+        assert(id.gid==0);     // There is one cell,
+        assert(id.index==0);   // with one probe.
 
         // Measure at the soma
         arb::mlocation loc{0, 0.0};
-        return arb::probe_info{id, 0, arb::cell_probe_membrane_voltage{loc}};
+        return arb::probe_info{id, 0, arb::cable_probe_membrane_voltage{loc}};
     }
 };
 
diff --git a/example/generators/readme.md b/example/generators/readme.md
index d8858740..fd32e77d 100644
--- a/example/generators/readme.md
+++ b/example/generators/readme.md
@@ -153,15 +153,15 @@ In our case, the cell has one probe, which refers to the voltage at the soma.
     }
 
     arb::probe_info get_probe(cell_member_type id) const override {
-        // Get the appropriate kind for measuring voltage
-        cell_probe_address::probe_kind kind = cell_probe_address::membrane_voltage;
-
         // The location at which we measure: position 0 on branch 0.
         // The cell has only one branch, branch 0, which is the soma.
-        arb::mlocation loc(0, 0.0);
+        arb::mlocation loc{0, 0.0};
+
+        // The thing we are measuring is the membrane potential.
+        arb::cable_probe_membrane_voltage address{loc};
 
-        // Put this together into a `probe_info`
-        return arb::probe_info{id, kind, cell_probe_address{loc, kind}};
+        // Put this together into a `probe_info`; the tag value 0 is not used.
+        return arb::probe_info{id, 0, address};
     }
 ```
 
diff --git a/example/probe-demo/CMakeLists.txt b/example/probe-demo/CMakeLists.txt
new file mode 100644
index 00000000..e7cf7ad7
--- /dev/null
+++ b/example/probe-demo/CMakeLists.txt
@@ -0,0 +1,3 @@
+add_executable(probe-demo EXCLUDE_FROM_ALL probe-demo.cpp)
+add_dependencies(examples probe-demo)
+target_link_libraries(probe-demo PRIVATE arbor ext-tinyopt)
diff --git a/example/probe-demo/probe-demo.cpp b/example/probe-demo/probe-demo.cpp
new file mode 100644
index 00000000..00bafdc8
--- /dev/null
+++ b/example/probe-demo/probe-demo.cpp
@@ -0,0 +1,247 @@
+#include <functional>
+#include <iomanip>
+#include <iostream>
+#include <stdexcept>
+#include <string>
+#include <tuple>
+#include <utility>
+#include <vector>
+
+#include <arbor/load_balance.hpp>
+#include <arbor/cable_cell.hpp>
+#include <arbor/morph/morphology.hpp>
+#include <arbor/morph/region.hpp>
+#include <arbor/simulation.hpp>
+#include <arbor/sampling.hpp>
+#include <arbor/util/any.hpp>
+#include <arbor/util/any_ptr.hpp>
+#include <tinyopt/smolopt.h>
+
+// Simulate a cell modelled as a simple cable with HH dynamics,
+// emitting the results of a user specified probe over time.
+
+using arb::util::any;
+using arb::util::any_cast;
+using arb::util::any_ptr;
+
+const char* help_msg =
+    "[OPTION]... PROBE\n"
+    "\n"
+    " --dt=TIME           set simulation dt to TIME [ms]\n"
+    " --until=TIME        simulate until TIME [ms]\n"
+    " -n, --n-cv=N        discretize with N CVs\n"
+    " -t, --sample=TIME   take a sample every TIME [ms]\n"
+    " -x, --at=X          take sample at relative position X along cable\n"
+    " -h, --help          print extended usage information and exit\n"
+    "\n"
+    "Simulate a simple 1 mm cable with HH dynamics, taking samples according\n"
+    "to PROBE (see below). Unless otherwise specified, the simulation will\n"
+    "use 10 CVs and run for 100 ms with a time step of 0.025 ms.\n"
+    "\n"
+    "Samples are by default taken every 1 ms; for probes that test a specific\n"
+    "point on the cell, that point is 0.5 along the cable (i.e. 500 µm) unless\n"
+    "specified with the --at option.\n"
+    "\n"
+    "PROBE is one of:\n"
+    "\n"
+    "    v           membrane potential [mV] at X\n"
+    "    i_axial     axial (distal) current [nA] at X\n"
+    "    j_ion       total ionic membrane current density [A/m²] at X\n"
+    "    j_na        sodium ion membrane current density [A/m²] at X\n"
+    "    j_k         potassium ion membrane current density [A/m²] at X\n"
+    "    c_na        internal sodium concentration [mmol/L] at X\n"
+    "    c_k         internal potassium concentration [mmol/L] at X\n"
+    "    hh_m        HH state variable m at X\n"
+    "    hh_h        HH state variable h at X\n"
+    "    hh_n        HH state variable n at X\n"
+    "\n"
+    "where X is the relative position along the cable as described above, or else:\n"
+    "\n"
+    "    all_v       membrane potential [mV] in each CV\n"
+    "    all_i_ion   total ionic membrane current [nA] in each CV\n"
+    "    all_i_na    sodium ion membrane current [nA] in each CV\n"
+    "    all_i_k     potassium ion membrane current [nA] in each CV\n"
+    "    all_i       total membrane current [nA] in each CV\n"
+    "    all_c_na    internal sodium concentration [mmol/L] in each CV\n"
+    "    all_c_k     internal potassium concentration [mmol/L] in each CV\n"
+    "    all_hh_m    HH state variable m in each CV\n"
+    "    all_hh_h    HH state variable h in each CV\n"
+    "    all_hh_n    HH state variable n in each CV\n";
+
+struct options {
+    double sim_end = 100.0;   // [ms]
+    double sim_dt = 0.025;    // [ms]
+    double sample_dt = 1.0;   // [ms]
+    unsigned n_cv = 10;
+
+    bool scalar_probe = true;
+    any probe_addr;
+    std::string value_name;
+};
+
+const char* argv0 = "";
+bool parse_options(options&, int& argc, char** argv);
+void vector_sampler(arb::cell_member_type, arb::probe_tag, any_ptr, std::size_t, const arb::sample_record*);
+void scalar_sampler(arb::cell_member_type, arb::probe_tag, any_ptr, std::size_t, const arb::sample_record*);
+
+struct cable_recipe: public arb::recipe {
+    arb::cable_cell_global_properties gprop;
+    any probe_addr;
+
+    explicit cable_recipe(any probe_addr, unsigned n_cv):
+        probe_addr(std::move(probe_addr))
+    {
+        gprop.default_parameters = arb::neuron_parameter_defaults;
+        gprop.default_parameters.discretization = arb::cv_policy_fixed_per_branch(n_cv);
+    }
+
+    arb::cell_size_type num_cells() const override { return 1; }
+    arb::cell_size_type num_probes(arb::cell_gid_type) const override { return 1; }
+    arb::cell_size_type num_targets(arb::cell_gid_type) const override { return 0; }
+
+    arb::probe_info get_probe(arb::cell_member_type probe_id) const override {
+        return {probe_id, 0, probe_addr}; // (tag value 0 is not used)
+    }
+
+    arb::cell_kind get_cell_kind(arb::cell_gid_type) const override {
+        return arb::cell_kind::cable;
+    }
+
+    arb::util::any get_global_properties(arb::cell_kind) const override {
+        return gprop;
+    }
+
+    arb::util::unique_any get_cell_description(arb::cell_gid_type) const override {
+        using namespace arb;
+        const double length = 1000; // [µm]
+        const double diam = 1; // [µm]
+
+        sample_tree samples({msample{0, 0, 0, 0.5*diam}, msample{length, 0, 0, 0.5*diam}}, {mnpos, 0u});
+        cable_cell c(samples);
+
+        c.paint(reg::all(), "hh"); // HH mechanism over whole cell.
+        c.place(mlocation{0, 0.}, i_clamp{0., INFINITY, 1.}); // Inject a 1 nA current indefinitely.
+        return c;
+    }
+};
+
+int main(int argc, char** argv) {
+    argv0 = argv[0];
+    try {
+        options opt;
+        if (!parse_options(opt, argc, argv)) {
+            return 0;
+        }
+
+        cable_recipe R(opt.probe_addr, opt.n_cv);
+
+        auto context = arb::make_context();
+        arb::simulation sim(R, arb::partition_load_balance(R, context), context);
+
+        sim.add_sampler(arb::all_probes, arb::regular_schedule(opt.sample_dt),
+                opt.scalar_probe? scalar_sampler: vector_sampler);
+
+        // CSV header for sample output:
+        std::cout << "t, " << (opt.scalar_probe? "x, ": "x0, x1, ") << opt.value_name << '\n';
+
+        sim.run(opt.sim_end, opt.sim_dt);
+    }
+    catch (to::option_error& e) {
+        to::usage(argv0, "[OPTIONS]... PROBE\nTry '--help' for more information.", e.what());
+        return 1;
+    }
+    catch (std::exception& e) {
+        std::cerr << "caught exception: " << e.what() << "\n";
+        return 2;
+    }
+}
+
+void scalar_sampler(arb::cell_member_type, arb::probe_tag, any_ptr meta, std::size_t n, const arb::sample_record* samples) {
+    auto* loc = any_cast<const arb::mlocation*>(meta);
+    assert(loc);
+
+    std::cout << std::fixed << std::setprecision(4);
+    for (std::size_t i = 0; i<n; ++i) {
+        auto* value = any_cast<const double*>(samples[i].data);
+        assert(value);
+
+        std::cout << samples[i].time << ", " << loc->pos << ", " << *value << '\n';
+    }
+}
+
+void vector_sampler(arb::cell_member_type, arb::probe_tag, any_ptr meta, std::size_t n, const arb::sample_record* samples) {
+    auto* cables_ptr = any_cast<const arb::mcable_list*>(meta);
+    assert(cables_ptr);
+    unsigned n_cable = cables_ptr->size();
+
+    std::cout << std::fixed << std::setprecision(4);
+    for (std::size_t i = 0; i<n; ++i) {
+        auto* value_range = any_cast<const arb::cable_sample_range*>(samples[i].data);
+        assert(value_range);
+        assert(n_cable==value_range->second-value_range->first);
+
+        for (unsigned j = 0; j<n_cable; ++j) {
+            arb::mcable where = (*cables_ptr)[j];
+            std::cout << samples[i].time << ", "
+                      << where.prox_pos << ", "
+                      << where.dist_pos << ", "
+                      << value_range->first[j] << '\n';
+        }
+    }
+}
+
+bool parse_options(options& opt, int& argc, char** argv) {
+    using std::get;
+    using namespace to;
+
+    auto do_help = [&]() { usage(argv0, help_msg); };
+
+    // Map probe argument to output variable name, scalarity, and a lambda that makes specific probe address from a location.
+    std::pair<const char*, std::tuple<const char*, bool, std::function<any (double)>>> probe_tbl[] {
+        // located probes
+        {"v",         {"v",       true,  [](double x) { return arb::cable_probe_membrane_voltage{{0, x}}; }}},
+        {"i_axial",   {"i_axial", true,  [](double x) { return arb::cable_probe_axial_current{{0, x}}; }}},
+        {"j_ion",     {"j_ion",   true,  [](double x) { return arb::cable_probe_total_ion_current_density{{0, x}}; }}},
+        {"j_na",      {"j_na",    true,  [](double x) { return arb::cable_probe_ion_current_density{{0, x}, "na"}; }}},
+        {"j_k",       {"j_k",     true,  [](double x) { return arb::cable_probe_ion_current_density{{0, x}, "k"}; }}},
+        {"c_na",      {"c_na",    true,  [](double x) { return arb::cable_probe_ion_int_concentration{{0, x}, "na"}; }}},
+        {"c_k",       {"c_k",     true,  [](double x) { return arb::cable_probe_ion_int_concentration{{0, x}, "k"}; }}},
+        {"hh_m",      {"hh_m",    true,  [](double x) { return arb::cable_probe_density_state{{0, x}, "hh", "m"}; }}},
+        {"hh_h",      {"hh_h",    true,  [](double x) { return arb::cable_probe_density_state{{0, x}, "hh", "h"}; }}},
+        {"hh_n",      {"hh_n",    true,  [](double x) { return arb::cable_probe_density_state{{0, x}, "hh", "n"}; }}},
+        // all-of-cell probes
+        {"all_v",     {"v",       false, [](double)   { return arb::cable_probe_membrane_voltage_cell{}; }}},
+        {"all_i_ion", {"i_ion",   false, [](double)   { return arb::cable_probe_total_ion_current_cell{}; }}},
+        {"all_i_na",  {"i_na",    false, [](double)   { return arb::cable_probe_ion_current_cell{"na"}; }}},
+        {"all_i_k",   {"i_k",     false, [](double)   { return arb::cable_probe_ion_current_cell{"k"}; }}},
+        {"all_i",     {"i",       false, [](double)   { return arb::cable_probe_total_current_cell{}; }}},
+        {"all_c_na",  {"c_na",    false, [](double)   { return arb::cable_probe_ion_int_concentration_cell{"na"}; }}},
+        {"all_c_k",   {"c_k",     false, [](double)   { return arb::cable_probe_ion_int_concentration_cell{"k"}; }}},
+        {"all_hh_m",  {"hh_m",    false, [](double)   { return arb::cable_probe_density_state_cell{"hh", "m"}; }}},
+        {"all_hh_h",  {"hh_h",    false, [](double)   { return arb::cable_probe_density_state_cell{"hh", "h"}; }}},
+        {"all_hh_n",  {"hh_n",    false, [](double)   { return arb::cable_probe_density_state_cell{"hh", "n"}; }}}
+    };
+
+    std::tuple<const char*, bool, std::function<any (double)>> probe_spec;
+    double probe_pos = 0.5;
+
+    to::option cli_opts[] = {
+        { to::action(do_help), to::flag, to::exit, "-h", "--help" },
+        { {probe_spec, to::keywords(probe_tbl)}, to::single },
+        { opt.sim_dt,    "--dt" },
+        { opt.sim_end,   "--until" },
+        { opt.sample_dt, "-t", "--sample" },
+        { probe_pos,     "-x", "--at" },
+        { opt.n_cv,      "-n", "--n-cv" }
+    };
+
+    if (!to::run(cli_opts, argc, argv+1)) return false;
+    if (!get<2>(probe_spec)) throw to::user_option_error("missing PROBE");
+    if (argv[1]) throw to::user_option_error("unrecognized option");
+
+    opt.value_name = get<0>(probe_spec);
+    opt.scalar_probe = get<1>(probe_spec);
+    opt.probe_addr = get<2>(probe_spec)(probe_pos);
+    return true;
+}
+
diff --git a/example/ring/ring.cpp b/example/ring/ring.cpp
index 561b09df..8f7acde0 100644
--- a/example/ring/ring.cpp
+++ b/example/ring/ring.cpp
@@ -116,7 +116,7 @@ public:
     arb::probe_info get_probe(cell_member_type id) const override {
         // Measure at the soma.
         arb::mlocation loc{0, 0.0};
-        return arb::probe_info{id, 0, arb::cell_probe_membrane_voltage{loc}};
+        return arb::probe_info{id, 0, arb::cable_probe_membrane_voltage{loc}};
     }
 
     arb::util::any get_global_properties(arb::cell_kind) const override {
diff --git a/example/single/single.cpp b/example/single/single.cpp
index 4e62819a..07d3cdac 100644
--- a/example/single/single.cpp
+++ b/example/single/single.cpp
@@ -39,7 +39,7 @@ struct single_recipe: public arb::recipe {
 
     arb::probe_info get_probe(arb::cell_member_type probe_id) const override {
         arb::mlocation mid_soma = {0, 0.5};
-        arb::cell_probe_membrane_voltage probe = {mid_soma};
+        arb::cable_probe_membrane_voltage probe = {mid_soma};
 
         // Probe info consists of: the probe id, a tag value to distinguish this probe
         // from others for any attached sampler (unused), and the cell probe address.
diff --git a/python/recipe.cpp b/python/recipe.cpp
index 0ce022d9..1497307d 100644
--- a/python/recipe.cpp
+++ b/python/recipe.cpp
@@ -31,10 +31,10 @@ arb::util::unique_any py_recipe_shim::get_cell_description(arb::cell_gid_type gi
 
 arb::probe_info cable_probe(std::string kind, arb::cell_member_type id, arb::mlocation loc) {
     if (kind == "voltage") {
-        return arb::probe_info{id, 0, arb::cell_probe_membrane_voltage{loc}};
+        return arb::probe_info{id, 0, arb::cable_probe_membrane_voltage{loc}};
     }
     else if (kind == "ionic current density") {
-        return arb::probe_info{id, 0, arb::cell_probe_total_ionic_current_density{loc}};
+        return arb::probe_info{id, 0, arb::cable_probe_total_ion_current_density{loc}};
     }
     else throw pyarb_error(util::pprintf("unrecognized probe kind: {}", kind));
 };
diff --git a/python/sampling.cpp b/python/sampling.cpp
index e281c2c2..d3a18b56 100644
--- a/python/sampling.cpp
+++ b/python/sampling.cpp
@@ -60,7 +60,7 @@ struct sample_callback {
         sample_store(state)
     {}
 
-    void operator() (arb::cell_member_type probe_id, arb::probe_tag tag, std::size_t n, const arb::sample_record* recs) {
+    void operator() (arb::cell_member_type probe_id, arb::probe_tag tag, arb::util::any_ptr meta, std::size_t n, const arb::sample_record* recs) {
         auto& v = sample_store->probe_buffer(probe_id);
         for (std::size_t i = 0; i<n; ++i) {
             if (auto p = arb::util::any_cast<const double*>(recs[i].data)) {
diff --git a/python/single_cell_model.cpp b/python/single_cell_model.cpp
index ae69e3e2..bde7da44 100644
--- a/python/single_cell_model.cpp
+++ b/python/single_cell_model.cpp
@@ -41,7 +41,7 @@ struct trace_callback {
 
     trace_callback(trace& t): trace_(t) {}
 
-    void operator()(arb::cell_member_type probe_id, arb::probe_tag tag, std::size_t n, const arb::sample_record* recs) {
+    void operator()(arb::cell_member_type probe_id, arb::probe_tag tag, arb::util::any_ptr meta, std::size_t n, const arb::sample_record* recs) {
         // Push each (time, value) pair from the last epoch into trace_.
         for (std::size_t i=0; i<n; ++i) {
             if (auto p = arb::util::any_cast<const double*>(recs[i].data)) {
@@ -117,7 +117,7 @@ struct single_cell_recipe: arb::recipe {
 
         // For now only voltage can be selected for measurement.
         const auto& loc = probes_[probe_id.index].site;
-        return arb::probe_info{probe_id, 0, arb::cell_probe_membrane_voltage{loc}};
+        return arb::probe_info{probe_id, 0, arb::cable_probe_membrane_voltage{loc}};
     }
 
     // gap junctions
diff --git a/test/unit/CMakeLists.txt b/test/unit/CMakeLists.txt
index 398c981d..bd48463e 100644
--- a/test/unit/CMakeLists.txt
+++ b/test/unit/CMakeLists.txt
@@ -1,8 +1,10 @@
 # Build mechanisms used solely in unit tests.
 
 set(test_mechanisms
+    ca_linear
     celsius_test
     diam_test
+    param_as_state
     test_linear_state
     test_linear_init
     test_linear_init_shuffle
@@ -79,6 +81,7 @@ endforeach()
 set(unit_sources
     test_algorithms.cpp
     test_any.cpp
+    test_any_visitor.cpp
     test_backend.cpp
     test_counter.cpp
     test_cv_geom.cpp
diff --git a/test/unit/common_morphologies.hpp b/test/unit/common_morphologies.hpp
index 666fd0d8..a3b63bf4 100644
--- a/test/unit/common_morphologies.hpp
+++ b/test/unit/common_morphologies.hpp
@@ -26,13 +26,16 @@ static const arb::morphology m_sph_b1{arb::sample_tree(make_morph_samples(1), {a
 // regular root, one branch
 static const arb::morphology m_reg_b1{arb::sample_tree(make_morph_samples(2), {arb::mnpos, 0u}), false};
 
-// spherical root, six branches
+// spherical root, six branches:
+// branch 0 (spherical root) has child branches 1 and 2; branch 2 has child branches 3, 4 and 5.
 static const arb::morphology m_sph_b6{arb::sample_tree(make_morph_samples(8), {arb::mnpos, 0u, 1u, 0u, 3u, 4u, 4u, 4u}), true};
 
 // regular root, six branches
+// branch 0 has child branches 1 and 2; branch 2 has child branches 3, 4 and 5.
 static const arb::morphology m_reg_b6{arb::sample_tree(make_morph_samples(7), {arb::mnpos, 0u, 1u, 1u, 2u, 2u, 2u}), false};
 
 // regular root, six branches, mutiple top level branches.
+// branch 0 has child branches 1 and 2; branch 3 has child branches 4 and 5.
 static const arb::morphology m_mlt_b6{arb::sample_tree(make_morph_samples(7), {arb::mnpos, 0u, 1u, 1u, 0u, 4u, 4u}), false};
 
 static std::pair<const char*, arb::morphology> test_morphologies[] = {
diff --git a/test/unit/mod/ca_linear.mod b/test/unit/mod/ca_linear.mod
new file mode 100644
index 00000000..1cd854cd
--- /dev/null
+++ b/test/unit/mod/ca_linear.mod
@@ -0,0 +1,19 @@
+: Test density mechanism for passive calcium current.
+
+NEURON {
+    SUFFIX ca_linear
+    USEION ca WRITE ica VALENCE 2
+    RANGE g
+}
+
+PARAMETER {
+    g = .001 (S/cm2)
+}
+
+ASSIGNED {}
+
+INITIAL {}
+
+BREAKPOINT  {
+    ica = g*v
+}
diff --git a/test/unit/mod/param_as_state.mod b/test/unit/mod/param_as_state.mod
new file mode 100644
index 00000000..463d6be0
--- /dev/null
+++ b/test/unit/mod/param_as_state.mod
@@ -0,0 +1,24 @@
+: Copy range parameter to state variable exactly.
+
+NEURON {
+    SUFFIX param_as_state
+    RANGE p
+}
+
+PARAMETER {
+    p = 1
+}
+
+ASSIGNED {}
+
+STATE {
+    s
+}
+
+INITIAL {
+    s = p
+}
+
+BREAKPOINT  {
+}
+
diff --git a/test/unit/test_any_visitor.cpp b/test/unit/test_any_visitor.cpp
new file mode 100644
index 00000000..6c22ec07
--- /dev/null
+++ b/test/unit/test_any_visitor.cpp
@@ -0,0 +1,190 @@
+#include <string>
+#include <type_traits>
+#include <typeinfo>
+
+#include <arbor/util/any.hpp>
+#include <arbor/util/any_visitor.hpp>
+
+#include "../gtest.h"
+#include "common.hpp"
+
+using namespace std::string_literals;
+using arb::util::any;
+using arb::util::any_cast;
+using arb::util::any_visitor;
+using arb::util::bad_any_cast;
+using arb::util::overload;
+
+TEST(any_visitor, simple) {
+    enum { A0, B0, C0 };
+    struct A { int value = A0; };
+    struct B { int value = B0; };
+    struct C { int value = C0; };
+
+    using V = any_visitor<A, B, C>;
+
+    auto get_value = [](auto y) { return y.value; };
+
+    EXPECT_EQ(A0, V::visit(get_value, any(A{})));
+    EXPECT_EQ(B0, V::visit(get_value, any(B{})));
+    EXPECT_EQ(C0, V::visit(get_value, any(C{})));
+}
+
+TEST(any_visitor, heterogeneous) {
+    struct r_base { int value; };
+    struct r_derived: r_base { int other; };
+
+    struct D { r_base item; };
+    struct E { r_derived item; };
+
+    using DE_visitor = any_visitor<D, E>;
+    using E_visitor = any_visitor<E>;
+
+    auto get_item = [](auto y) { return y.item; };
+
+    // Return type is common type across possible returns of visiting functor.
+
+    {
+        auto result = E_visitor::visit(get_item, any(E{}));
+        EXPECT_TRUE((std::is_same<decltype(result), r_derived>::value));
+    }
+    {
+        auto result = DE_visitor::visit(get_item, any(E{}));
+        EXPECT_TRUE((std::is_same<decltype(result), r_base>::value));
+    }
+}
+
+TEST(any_visitor, unmatched) {
+    enum { A0, B0, C0, D0 };
+    struct A { int value = A0; };
+    struct B { int value = B0; };
+    struct C { int value = C0; };
+    struct D { int value = D0; };
+
+    using V = any_visitor<A, B, C>;
+
+    struct catch_unmatched {
+        bool operator()(A) const { return true; }
+        bool operator()(B) const { return true; }
+        bool operator()(C) const { return true; }
+        bool operator()() const { return false; }
+    } f;
+
+    EXPECT_EQ(true,  V::visit(f, any(A{})));
+    EXPECT_EQ(true,  V::visit(f, any(B{})));
+    EXPECT_EQ(true,  V::visit(f, any(C{})));
+    EXPECT_EQ(false, V::visit(f, any(D{})));
+
+    auto get_value = [](auto y) { return y.value; };
+
+    EXPECT_NO_THROW(V::visit(get_value, any(A{})));
+    EXPECT_NO_THROW(V::visit(get_value, any(A{})));
+    EXPECT_NO_THROW(V::visit(get_value, any(A{})));
+    EXPECT_THROW(V::visit(get_value, any(D{})), bad_any_cast);
+}
+
+TEST(any_visitor, preserve_visiting_ref) {
+    struct check_ref {
+        check_ref(int& result): result(result) {}
+        int& result;
+
+        void operator()(...) & { result = 1; };
+        void operator()(...) && { result = 2; };
+    };
+
+    struct A {};
+    struct B {};
+
+    int result = 0;
+    check_ref lref(result);
+
+    any_visitor<A, B>::visit(lref, any(B{}));
+    EXPECT_EQ(1, result);
+
+    result = 0;
+    any_visitor<A, B>::visit(check_ref(result), any(B{}));
+    EXPECT_EQ(2, result);
+}
+
+TEST(any_visitor, preserve_any_qual) {
+    struct A { int x = 0; };
+    struct B { int x = 0; };
+
+    struct check_qual {
+       int operator()(const A&) { return 0; }
+       int operator()(A&) { return 1; }
+       int operator()(A&&) { return 2; }
+
+       int operator()(const B&) { return 3; }
+       int operator()(B&) { return 4; }
+       int operator()(B&&) { return 5; }
+    };
+
+    using V = any_visitor<A, B>;
+    check_qual q;
+
+    any a(A{});
+    const any const_a(A{});
+    EXPECT_EQ(0, V::visit(q, const_a));
+    EXPECT_EQ(1, V::visit(q, a));
+    EXPECT_EQ(2, V::visit(q, any(A{})));
+
+    any b(B{});
+    const any const_b(B{});
+    EXPECT_EQ(3, V::visit(q, const_b));
+    EXPECT_EQ(4, V::visit(q, b));
+    EXPECT_EQ(5, V::visit(q, any(B{})));
+
+    any v(B{10});
+    V::visit([](auto& u) { ++u.x; }, v);
+    EXPECT_EQ(11, any_cast<B>(v).x);
+}
+
+TEST(overload, simple) {
+    struct A { int value; };
+    struct B { double value; };
+
+    auto f = overload([](A a) { return a.value+10; },
+                      [](B b) { return b.value+20; });
+
+    EXPECT_TRUE((std::is_same<decltype(f(A{})), int>::value));
+    EXPECT_TRUE((std::is_same<decltype(f(B{})), double>::value));
+
+    EXPECT_EQ(12,   f(A{2}));
+    EXPECT_EQ(22.5, f(B{2.5}));
+}
+
+namespace {
+int f1(int) { return 1; }
+int f2(int) { return 2; }
+}
+
+TEST(overload, precedence) {
+    // Overloaded functional object will match the first invocable operator(), not the best match.
+
+    EXPECT_EQ(1, overload(f1, f2)(0));
+    EXPECT_EQ(2, overload(f2, f1)(0));
+
+    EXPECT_EQ(1, overload([](int) { return 1; }, [](double) { return 2; })(0));
+    EXPECT_EQ(1, overload([](int) { return 1; }, [](double) { return 2; })(2.3));
+}
+
+TEST(overload, qualified_match) {
+    struct A {};
+
+    auto f = overload(
+        [](A&&) { return 1; },
+        [](const A&&) { return 2; },
+        [](A&) { return 3; },
+        [](const A&) { return 4; });
+
+    A a;
+    const A const_a;
+
+    EXPECT_EQ(1, f(std::move(a)));
+    EXPECT_EQ(2, f(std::move(const_a)));
+    EXPECT_EQ(3, f(a));
+    EXPECT_EQ(4, f(const_a));
+}
+
+
diff --git a/test/unit/test_cv_layout.cpp b/test/unit/test_cv_layout.cpp
index 62989fd8..af731d0f 100644
--- a/test/unit/test_cv_layout.cpp
+++ b/test/unit/test_cv_layout.cpp
@@ -174,12 +174,12 @@ TEST(cv_layout, zero_size_cv) {
     ASSERT_TRUE(util::equal(mcable_list{mcable{1, 0, 1}}, D.geometry.cables(cv_b)));
     ASSERT_TRUE(util::equal(mcable_list{mcable{2, 0, 1}}, D.geometry.cables(cv_c)));
 
-    // All non-conductance values for zero-size cv_x should be zero.
+    // All non-conductance values for zero-size cv_x should be zero, or value of parent.
     EXPECT_EQ(0., D.cv_area[cv_x]);
     EXPECT_EQ(0., D.cv_capacitance[cv_x]);
-    EXPECT_EQ(0., D.init_membrane_potential[cv_x]);
-    EXPECT_EQ(0., D.temperature_K[cv_x]);
     EXPECT_EQ(0., D.diam_um[cv_x]);
+    EXPECT_EQ(D.init_membrane_potential[cv_a], D.init_membrane_potential[cv_x]);
+    EXPECT_EQ(D.temperature_K[cv_a], D.temperature_K[cv_x]);
 
     // Face conductance for zero-size cv_x:
     double l_x = cell.embedding().branch_length(0);
diff --git a/test/unit/test_fvm_layout.cpp b/test/unit/test_fvm_layout.cpp
index 54403866..68314cc3 100644
--- a/test/unit/test_fvm_layout.cpp
+++ b/test/unit/test_fvm_layout.cpp
@@ -990,8 +990,8 @@ TEST(fvm_layout, iinterp) {
                     EXPECT_TRUE(I.proximal_cv>=D.geometry.cell_cv_interval(cell_idx).first);
 
                     double fc = D.face_conductance.at(I.distal_cv);
-                    EXPECT_DOUBLE_EQ(-fc, I.proximal_coef);
-                    EXPECT_DOUBLE_EQ(+fc, I.distal_coef);
+                    EXPECT_DOUBLE_EQ(+fc, I.proximal_coef);
+                    EXPECT_DOUBLE_EQ(-fc, I.distal_coef);
                 }
             }
         }
@@ -1039,8 +1039,8 @@ TEST(fvm_layout, iinterp) {
 
         EXPECT_EQ(0, I.proximal_cv);
         EXPECT_EQ(1, I.distal_cv);
-        EXPECT_EQ(-fc1, I.proximal_coef);
-        EXPECT_EQ(+fc1, I.distal_coef);
+        EXPECT_EQ(+fc1, I.proximal_coef);
+        EXPECT_EQ(-fc1, I.distal_coef);
     }
 
     // Branch 2:
@@ -1053,7 +1053,7 @@ TEST(fvm_layout, iinterp) {
 
         EXPECT_EQ(0, I.proximal_cv);
         EXPECT_EQ(2, I.distal_cv);
-        EXPECT_EQ(-fc2, I.proximal_coef);
-        EXPECT_EQ(+fc2, I.distal_coef);
+        EXPECT_EQ(+fc2, I.proximal_coef);
+        EXPECT_EQ(-fc2, I.distal_coef);
     }
 }
diff --git a/test/unit/test_fvm_lowered.cpp b/test/unit/test_fvm_lowered.cpp
index 69a8e30d..c10c138f 100644
--- a/test/unit/test_fvm_lowered.cpp
+++ b/test/unit/test_fvm_lowered.cpp
@@ -225,7 +225,7 @@ TEST(fvm_lowered, matrix_init)
 
     std::vector<target_handle> targets;
     std::vector<fvm_index_type> cell_to_intdom;
-    probe_association_map<probe_handle> probe_map;
+    probe_association_map<fvm_probe_info> probe_map;
 
     fvm_cell fvcell(context);
     fvcell.initialize({0}, cable1d_recipe(cell), cell_to_intdom, targets, probe_map);
@@ -278,7 +278,7 @@ TEST(fvm_lowered, target_handles) {
 
     std::vector<target_handle> targets;
     std::vector<fvm_index_type> cell_to_intdom;
-    probe_association_map<probe_handle> probe_map;
+    probe_association_map<fvm_probe_info> probe_map;
 
     auto test_target_handles = [&](fvm_cell& cell) {
         mechanism *expsyn = find_mechanism(cell, "expsyn");
@@ -361,7 +361,7 @@ TEST(fvm_lowered, stimulus) {
     const auto& A = D.cv_area;
 
     std::vector<target_handle> targets;
-    probe_association_map<probe_handle> probe_map;
+    probe_association_map<fvm_probe_info> probe_map;
 
     fvm_cell fvcell(context);
     fvcell.initialize({0}, cable1d_recipe(cells), cell_to_intdom, targets, probe_map);
@@ -449,7 +449,7 @@ TEST(fvm_lowered, derived_mechs) {
     cable1d_recipe rec(cells);
     rec.catalogue().derive("custom_kin1", "test_kin1", {{"tau", 20.0}});
 
-    cell_probe_total_ionic_current_density where{{1, 0.3}};
+    cable_probe_total_ion_current_density where{{1, 0.3}};
     rec.add_probe(0, 0, where);
     rec.add_probe(1, 0, where);
     rec.add_probe(2, 0, where);
@@ -459,7 +459,7 @@ TEST(fvm_lowered, derived_mechs) {
 
         std::vector<target_handle> targets;
         std::vector<fvm_index_type> cell_to_intdom;
-        probe_association_map<probe_handle> probe_map;
+        probe_association_map<fvm_probe_info> probe_map;
 
         arb::execution_context context(resources);
         fvm_cell fvcell(context);
@@ -490,12 +490,13 @@ TEST(fvm_lowered, derived_mechs) {
 
         std::vector<double> samples[3];
 
-        sampler_function sampler = [&](cell_member_type pid, probe_tag, std::size_t n, const sample_record* records) {
-            for (std::size_t i = 0; i<n; ++i) {
-                double v = *util::any_cast<const double*>(records[i].data);
-                samples[pid.gid].push_back(v);
-            }
-        };
+        sampler_function sampler =
+            [&](cell_member_type pid, probe_tag, util::any_ptr, std::size_t n, const sample_record* records) {
+                for (std::size_t i = 0; i<n; ++i) {
+                    double v = *util::any_cast<const double*>(records[i].data);
+                    samples[pid.gid].push_back(v);
+                }
+            };
 
         float times[] = {10.f, 20.f};
 
@@ -530,7 +531,7 @@ TEST(fvm_lowered, read_valence) {
 
     std::vector<target_handle> targets;
     std::vector<fvm_index_type> cell_to_intdom;
-    probe_association_map<probe_handle> probe_map;
+    probe_association_map<fvm_probe_info> probe_map;
 
     {
         std::vector<cable_cell> cells(1);
@@ -684,7 +685,7 @@ TEST(fvm_lowered, ionic_currents) {
 
     std::vector<target_handle> targets;
     std::vector<fvm_index_type> cell_to_intdom;
-    probe_association_map<probe_handle> probe_map;
+    probe_association_map<fvm_probe_info> probe_map;
 
     fvm_cell fvcell(context);
     fvcell.initialize({0}, rec, cell_to_intdom, targets, probe_map);
@@ -729,7 +730,7 @@ TEST(fvm_lowered, point_ionic_current) {
 
     std::vector<target_handle> targets;
     std::vector<fvm_index_type> cell_to_intdom;
-    probe_association_map<probe_handle> probe_map;
+    probe_association_map<fvm_probe_info> probe_map;
 
     fvm_cell fvcell(context);
     fvcell.initialize({0}, rec, cell_to_intdom, targets, probe_map);
@@ -809,7 +810,7 @@ TEST(fvm_lowered, weighted_write_ion) {
 
     std::vector<target_handle> targets;
     std::vector<fvm_index_type> cell_to_intdom;
-    probe_association_map<probe_handle> probe_map;
+    probe_association_map<fvm_probe_info> probe_map;
 
     fvm_cell fvcell(context);
     fvcell.initialize({0}, rec, cell_to_intdom, targets, probe_map);
diff --git a/test/unit/test_probe.cpp b/test/unit/test_probe.cpp
index de0acb73..85731963 100644
--- a/test/unit/test_probe.cpp
+++ b/test/unit/test_probe.cpp
@@ -1,22 +1,35 @@
 #include "../gtest.h"
 
+#include <cmath>
+#include <vector>
+
 #include <arbor/cable_cell.hpp>
 #include <arbor/common_types.hpp>
+#include <arbor/load_balance.hpp>
+#include <arbor/mechanism.hpp>
 #include <arbor/mechcat.hpp>
 #include <arbor/mechinfo.hpp>
+#include <arbor/sampling.hpp>
+#include <arbor/schedule.hpp>
+#include <arbor/simple_sampler.hpp>
+#include <arbor/simulation.hpp>
 #include <arbor/version.hpp>
 #include <arborenv/gpu_env.hpp>
 
 #include "backends/event.hpp"
 #include "backends/multicore/fvm.hpp"
+#include "backends/multicore/mechanism.hpp"
 #ifdef ARB_GPU_ENABLED
 #include "backends/gpu/fvm.hpp"
+#include "backends/gpu/mechanism.hpp"
 #endif
 #include "fvm_lowered_cell_impl.hpp"
 #include "memory/gpu_wrappers.hpp"
+#include "util/filter.hpp"
 #include "util/rangeutil.hpp"
 
 #include "common.hpp"
+#include "common_morphologies.hpp"
 #include "unit_test_catalogue.hpp"
 #include "../common_cells.hpp"
 #include "../simple_recipes.hpp"
@@ -27,7 +40,6 @@ using multicore_fvm_cell = fvm_lowered_cell_impl<multicore::backend>;
 using multicore_shared_state = multicore::backend::shared_state;
 ACCESS_BIND(std::unique_ptr<multicore_shared_state> multicore_fvm_cell::*, multicore_fvm_state_ptr, &multicore_fvm_cell::state_);
 
-
 template <typename Backend>
 struct backend_access {
     using fvm_cell = multicore_fvm_cell;
@@ -36,7 +48,8 @@ struct backend_access {
         return *(cell.*multicore_fvm_state_ptr).get();
     }
 
-    static fvm_value_type deref(const fvm_value_type* p) { return *p; }
+    template <typename fvm_type>
+    static fvm_type deref(const fvm_type* p) { return *p; }
 };
 
 #ifdef ARB_GPU_ENABLED
@@ -53,8 +66,9 @@ struct backend_access<gpu::backend> {
         return *(cell.*gpu_fvm_state_ptr).get();
     }
 
-    static fvm_value_type deref(const fvm_value_type* p) {
-        fvm_value_type r;
+    template <typename fvm_type>
+    static fvm_type deref(const fvm_type* p) {
+        fvm_type r;
         memory::gpu_memcpy_d2h(&r, p, sizeof(r));
         return r;
     }
@@ -62,12 +76,25 @@ struct backend_access<gpu::backend> {
 
 #endif
 
+// Used in a number of tests below, produce a simple Y-shaped 3-branch cell morphology with
+// linearly tapered branches.
+
+static morphology make_y_morphology() {
+    return morphology(sample_tree(
+        {msample{{0., 0., 0., 1.}, 0},
+         msample{{100., 0., 0., 0.8}, 0},
+         msample{{100., 100., 0., 0.5}, 0},
+         msample{{100., 0, 100., 0.4}, 0}},
+        {mnpos, 0u, 1u, 1u}));
+}
+
 template <typename Backend>
 void run_v_i_probe_test(const context& ctx) {
     using fvm_cell = typename backend_access<Backend>::fvm_cell;
     auto deref = [](const fvm_value_type* p) { return backend_access<Backend>::deref(p); };
 
     cable_cell bs = make_cell_ball_and_stick(false);
+    bs.default_parameters.discretization = cv_policy_fixed_per_branch(1);
 
     i_clamp stim(0, 100, 0.3);
     bs.place(mlocation{1, 1}, stim);
@@ -78,13 +105,13 @@ void run_v_i_probe_test(const context& ctx) {
     mlocation loc1{1, 1};
     mlocation loc2{1, 0.3};
 
-    rec.add_probe(0, 10, cell_probe_membrane_voltage{loc0});
-    rec.add_probe(0, 20, cell_probe_membrane_voltage{loc1});
-    rec.add_probe(0, 30, cell_probe_total_ionic_current_density{loc2});
+    rec.add_probe(0, 10, cable_probe_membrane_voltage{loc0});
+    rec.add_probe(0, 20, cable_probe_membrane_voltage{loc1});
+    rec.add_probe(0, 30, cable_probe_total_ion_current_density{loc2});
 
     std::vector<target_handle> targets;
     std::vector<fvm_index_type> cell_to_intdom;
-    probe_association_map<probe_handle> probe_map;
+    probe_association_map<fvm_probe_info> probe_map;
 
     fvm_cell lcell(*ctx);
     lcell.initialize({0}, rec, cell_to_intdom, targets, probe_map);
@@ -96,45 +123,130 @@ void run_v_i_probe_test(const context& ctx) {
     EXPECT_EQ(20, probe_map.at({0, 1}).tag);
     EXPECT_EQ(30, probe_map.at({0, 2}).tag);
 
-    probe_handle p0 = probe_map.at({0, 0}).handle;
-    probe_handle p1 = probe_map.at({0, 1}).handle;
-    probe_handle p2 = probe_map.at({0, 2}).handle;
+    auto get_probe_handle = [&](cell_member_type x, unsigned i = 0) {
+        return probe_map.at(x).handle.raw_handle_range()[i];
+    };
+
+    // Voltage probes are interpolated, so expect fvm_probe_info
+    // to wrap an fvm_probe_interpolated; ion current density is
+    // a scalar, so should wrap fvm_probe_scalar.
+
+    ASSERT_TRUE(util::get_if<fvm_probe_interpolated>(probe_map.at({0, 0}).handle.info));
+    ASSERT_TRUE(util::get_if<fvm_probe_interpolated>(probe_map.at({0, 1}).handle.info));
+    ASSERT_TRUE(util::get_if<fvm_probe_scalar>(probe_map.at({0, 2}).handle.info));
 
-    // Expect initial probe values to be the resting potential
-    // for the voltage probes (cell membrane potential should
-    // be constant), and zero for the current probe.
+    probe_handle p0a = get_probe_handle({0, 0}, 0);
+    probe_handle p0b = get_probe_handle({0, 0}, 1);
+    probe_handle p1a = get_probe_handle({0, 1}, 0);
+    probe_handle p1b = get_probe_handle({0, 1}, 1);
+    probe_handle p2 = get_probe_handle({0, 2});
+
+    // Ball-and-stick cell with default discretization policy should
+    // have three CVs, one for branch 0, one trivial one covering the
+    // branch point, and one for branch 1.
+    //
+    // Consequently, expect the interpolated voltage probe handles
+    // to be on CVs 0 and 1 for probe 0,0 on branch 0,
+    // and on CVs 1 and 2 for probe 0,1 on branch 1.
 
     auto& state = backend_access<Backend>::state(lcell);
     auto& voltage = state.voltage;
 
+    EXPECT_EQ(voltage.data(),   p0a);
+    EXPECT_EQ(voltage.data()+1, p0b);
+    EXPECT_EQ(voltage.data()+1, p1a);
+    EXPECT_EQ(voltage.data()+2, p1b);
+
+    // Expect initial raw probe handle values to be the resting potential for
+    // the voltage probes (cell membrane potential should be constant), and
+    // zero for the current probe.
+
     fvm_value_type resting = voltage[0];
     EXPECT_NE(0.0, resting);
 
-    // (Probe handles are just pointers in this implementation).
-    EXPECT_EQ(resting, deref(p0));
-    EXPECT_EQ(resting, deref(p1));
+    EXPECT_EQ(resting, deref(p0a));
+    EXPECT_EQ(resting, deref(p0b));
+    EXPECT_EQ(resting, deref(p1a));
+    EXPECT_EQ(resting, deref(p1a));
     EXPECT_EQ(0.0, deref(p2));
 
     // After an integration step, expect voltage probe values
-    // to differ from resting, and between each other, and
-    // for there to be a non-zero current.
-    //
-    // First probe, at (0,0), should match voltage in first
-    // compartment.
+    // to differ from resting, and for there to be a non-zero current.
 
     lcell.integrate(0.01, 0.0025, {}, {});
 
-    EXPECT_NE(resting, deref(p0));
-    EXPECT_NE(resting, deref(p1));
-    EXPECT_NE(deref(p0), deref(p1));
+    EXPECT_NE(resting, deref(p0a));
+    EXPECT_NE(resting, deref(p0b));
+    EXPECT_NE(resting, deref(p1a));
+    EXPECT_NE(resting, deref(p1b));
     EXPECT_NE(0.0, deref(p2));
+}
+
+template <typename Backend>
+void run_v_cable_probe_test(const context& ctx) {
+    using fvm_cell = typename backend_access<Backend>::fvm_cell;
+
+    // Take the per-cable voltage over a Y-shaped cell with and without
+    // interior forks in the discretization. The metadata will be used
+    // to determine the corresponding CVs for each cable, and the raw
+    // pointer to backend data checked against the expected CV offset.
+
+    cable_cell cell(make_y_morphology());
+
+    std::pair<const char*, cv_policy> test_policies[] = {
+        {"trivial fork", cv_policy_fixed_per_branch(3, cv_policy_flag::none)},
+        {"interior fork", cv_policy_fixed_per_branch(3, cv_policy_flag::interior_forks)},
+    };
+
+    for (auto& testcase: test_policies) {
+        SCOPED_TRACE(testcase.first);
+        cell.default_parameters.discretization = testcase.second;
+
+        cable1d_recipe rec(cell, false);
+        rec.add_probe(0, 0, cable_probe_membrane_voltage_cell{});
+
+        std::vector<target_handle> targets;
+        std::vector<fvm_index_type> cell_to_intdom;
+        probe_association_map<fvm_probe_info> probe_map;
+
+        fvm_cell lcell(*ctx);
+        lcell.initialize({0}, rec, cell_to_intdom, targets, probe_map);
+
+        ASSERT_EQ(1u, probe_map.size());
+
+        const fvm_probe_multi* h_ptr = util::get_if<fvm_probe_multi>(probe_map.at({0, 0}).handle.info);
+        ASSERT_TRUE(h_ptr);
+        auto& h = *h_ptr;
+
+        const mcable_list* cl_ptr = util::get_if<mcable_list>(h_ptr->metadata);
+        ASSERT_TRUE(cl_ptr);
+        auto& cl = *cl_ptr;
+
+        ASSERT_EQ(h.raw_handles.size(), cl.size());
 
-    fvm_value_type v = voltage[0];
-    EXPECT_EQ(v, deref(p0));
+        // Independetly discretize the cell so we can follow cable–CV relationship.
+
+        cv_geometry geom = cv_geometry_from_ends(cell, testcase.second.cv_boundary_points(cell));
+
+        // For each cable in metadata, get CV from geom and confirm raw handle is
+        // state voltage + CV.
+
+        auto& state = backend_access<Backend>::state(lcell);
+        auto& voltage = state.voltage;
+
+        for (auto i: util::count_along(*cl_ptr)) {
+            mlocation cable_mid{cl[i].branch, 0.5*(cl[i].prox_pos+cl[i].dist_pos)};
+            auto cv = geom.location_cv(0, cable_mid, cv_prefer::cv_empty);
+
+            EXPECT_EQ(voltage.data()+cv, h.raw_handles[i]);
+        }
+    }
 }
 
 template <typename Backend>
 void run_expsyn_g_probe_test(const context& ctx, bool coalesce_synapses = false) {
+    SCOPED_TRACE(coalesce_synapses? "coalesced": "uncoalesced");
+
     using fvm_cell = typename backend_access<Backend>::fvm_cell;
     auto deref = [](const fvm_value_type* p) { return backend_access<Backend>::deref(p); };
 
@@ -151,24 +263,30 @@ void run_expsyn_g_probe_test(const context& ctx, bool coalesce_synapses = false)
     bs.default_parameters.discretization = cv_policy_fixed_per_branch(2);
 
     cable1d_recipe rec(bs, coalesce_synapses);
-    rec.add_probe(0, 10, cell_probe_point_state{0u, "expsyn", "g"});
-    rec.add_probe(0, 20, cell_probe_point_state{1u, "expsyn", "g"});
+    rec.add_probe(0, 10, cable_probe_point_state{0u, "expsyn", "g"});
+    rec.add_probe(0, 20, cable_probe_point_state{1u, "expsyn", "g"});
 
     std::vector<target_handle> targets;
     std::vector<fvm_index_type> cell_to_intdom;
-    probe_association_map<probe_handle> probe_map;
+    probe_association_map<fvm_probe_info> probe_map;
 
     fvm_cell lcell(*ctx);
     lcell.initialize({0}, rec, cell_to_intdom, targets, probe_map);
 
     EXPECT_EQ(2u, rec.num_probes(0));
     EXPECT_EQ(2u, probe_map.size());
+    ASSERT_EQ(1u, probe_map.count({0, 0}));
+    ASSERT_EQ(1u, probe_map.count({0, 1}));
 
     EXPECT_EQ(10, probe_map.at({0, 0}).tag);
     EXPECT_EQ(20, probe_map.at({0, 1}).tag);
 
-    probe_handle p0 = probe_map.at({0, 0}).handle;
-    probe_handle p1 = probe_map.at({0, 1}).handle;
+    auto probe_scalar_handle = [&](cell_member_type x) {
+        return probe_map.at(x).handle.raw_handle_range()[0];
+    };
+
+    probe_handle p0 = probe_scalar_handle({0, 0});
+    probe_handle p1 = probe_scalar_handle({0, 1});
 
     // Expect initial probe values to be intial synapse g == 0.
 
@@ -209,6 +327,127 @@ void run_expsyn_g_probe_test(const context& ctx, bool coalesce_synapses = false)
     }
 }
 
+template <typename Backend>
+void run_expsyn_g_cable_probe_test(const context& ctx, bool coalesce_synapses = false) {
+    SCOPED_TRACE(coalesce_synapses? "coalesced": "uncoalesced");
+
+    using fvm_cell = typename backend_access<Backend>::fvm_cell;
+    auto deref = [](const auto* p) { return backend_access<Backend>::deref(p); };
+
+    // Place a mixture of expsyn and exp2syn synapses over two cells.
+    // Confirm that a whole-cell expsyn g state probe gives:
+    //  * A metadata element for each placed expsyn probe.
+    //  * A multiplicity that matches the number of probes sharing a CV if synapses
+    //    were coalesced.
+    //  * A raw handle that sees an event sent to the corresponding target,
+
+    cv_policy policy = cv_policy_fixed_per_branch(3);
+
+    cable_cell cell(make_y_morphology());
+    cell.default_parameters.discretization = policy;
+
+    std::unordered_map<cell_lid_type, mlocation> expsyn_target_loc_map;
+
+    for (unsigned bid = 0; bid<3u; ++bid) {
+        for (unsigned j = 0; j<10; ++j) {
+            mlocation expsyn_loc{bid, 0.1*j};
+            lid_range target_lids = cell.place(expsyn_loc, "expsyn");
+
+            ASSERT_EQ(1u, target_lids.end-target_lids.begin);
+            expsyn_target_loc_map[target_lids.begin] = expsyn_loc;
+
+            cell.place(mlocation{bid, 0.1*j+0.05}, "exp2syn");
+        }
+    }
+    const unsigned n_expsyn = 30;
+
+    std::vector<cable_cell> cells(2, cell);
+    cable1d_recipe rec(cells, coalesce_synapses);
+
+    rec.add_probe(0, 0, cable_probe_point_state_cell{"expsyn", "g"});
+    rec.add_probe(1, 0, cable_probe_point_state_cell{"expsyn", "g"});
+
+    std::vector<target_handle> targets;
+    std::vector<fvm_index_type> cell_to_intdom;
+    probe_association_map<fvm_probe_info> probe_map;
+
+    fvm_cell lcell(*ctx);
+    lcell.initialize({0, 1}, rec, cell_to_intdom, targets, probe_map);
+
+    // Send an event to each expsyn synapse with a weight = target+100*cell_gid, and
+    // integrate for a tiny time step.
+
+    std::vector<deliverable_event> events;
+    for (unsigned i: {0u, 1u}) {
+        // Cells have the same number of targets, so the offset for cell 1 is exactly...
+        cell_local_size_type cell_offset = i==0? 0: targets.size()/2;
+
+        for (auto target_id: util::keys(expsyn_target_loc_map)) {
+            deliverable_event ev{0., targets.at(target_id+cell_offset), float(target_id+100*i)};
+            events.push_back(ev);
+        }
+    }
+    (void)lcell.integrate(1e-5, 1e-5, events, {});
+
+    // Independently get cv geometry to compute CV indices.
+
+    cv_geometry geom = cv_geometry_from_ends(cells[0], policy.cv_boundary_points(cells[0]));
+    append(geom, cv_geometry_from_ends(cells[1], policy.cv_boundary_points(cells[1])));
+
+    ASSERT_EQ(2u, probe_map.size());
+    for (unsigned i: {0u, 1u}) {
+        const auto* h_ptr = util::get_if<fvm_probe_multi>(probe_map.at({i, 0}).handle.info);
+        ASSERT_TRUE(h_ptr);
+
+        const auto* m_ptr = util::get_if<std::vector<cable_probe_point_info>>(h_ptr->metadata);
+        ASSERT_TRUE(m_ptr);
+
+        const fvm_probe_multi& h = *h_ptr;
+        const std::vector<cable_probe_point_info> m = *m_ptr;
+
+        ASSERT_EQ(h.raw_handles.size(), m.size());
+        ASSERT_EQ(n_expsyn, m.size());
+
+        std::vector<double> expected_coalesced_cv_value(geom.size());
+        std::vector<double> expected_uncoalesced_value(targets.size());
+
+        std::vector<double> target_cv(targets.size(), (unsigned)-1);
+        std::unordered_map<fvm_size_type, unsigned> cv_expsyn_count;
+
+        for (unsigned j = 0; j<n_expsyn; ++j) {
+            ASSERT_EQ(1u, expsyn_target_loc_map.count(m[j].target));
+            EXPECT_EQ(expsyn_target_loc_map.at(m[j].target), m[j].loc);
+
+            auto cv = geom.location_cv(i, m[j].loc, cv_prefer::cv_nonempty);
+            target_cv[j] = cv;
+            ++cv_expsyn_count[cv];
+
+            double event_weight = m[j].target+100*i;
+            expected_uncoalesced_value[j] = event_weight;
+            expected_coalesced_cv_value[cv] += event_weight;
+        }
+
+        for (unsigned j = 0; j<n_expsyn; ++j) {
+            if (coalesce_synapses) {
+                EXPECT_EQ(cv_expsyn_count.at(target_cv[j]), m[j].multiplicity);
+            }
+            else {
+                EXPECT_EQ(1u, m[j].multiplicity);
+            }
+        }
+
+        for (unsigned j = 0; j<n_expsyn; ++j) {
+            double expected_value = coalesce_synapses?
+                expected_coalesced_cv_value[target_cv[j]]:
+                expected_uncoalesced_value[j];
+
+            double value = deref(h.raw_handles[j]);
+
+            EXPECT_NEAR(expected_value, value, 0.01); // g values will have decayed a little.
+        }
+    }
+}
+
 template <typename Backend>
 void run_ion_density_probe_test(const context& ctx) {
     using fvm_cell = typename backend_access<Backend>::fvm_cell;
@@ -244,34 +483,39 @@ void run_ion_density_probe_test(const context& ctx) {
     rec.catalogue() = cat;
 
     // Probe (0, 0): ca internal on CV 0.
-    rec.add_probe(0, 0, cell_probe_ion_int_concentration{loc0, "ca"});
+    rec.add_probe(0, 0, cable_probe_ion_int_concentration{loc0, "ca"});
     // Probe (0, 1): ca internal on CV 1.
-    rec.add_probe(0, 0, cell_probe_ion_int_concentration{loc1, "ca"});
+    rec.add_probe(0, 0, cable_probe_ion_int_concentration{loc1, "ca"});
     // Probe (0, 2): ca internal on CV 2.
-    rec.add_probe(0, 0, cell_probe_ion_int_concentration{loc2, "ca"});
+    rec.add_probe(0, 0, cable_probe_ion_int_concentration{loc2, "ca"});
 
     // Probe (0, 3): ca external on CV 0.
-    rec.add_probe(0, 0, cell_probe_ion_ext_concentration{loc0, "ca"});
+    rec.add_probe(0, 0, cable_probe_ion_ext_concentration{loc0, "ca"});
     // Probe (0, 4): ca external on CV 1.
-    rec.add_probe(0, 0, cell_probe_ion_ext_concentration{loc1, "ca"});
+    rec.add_probe(0, 0, cable_probe_ion_ext_concentration{loc1, "ca"});
     // Probe (0, 5): ca external on CV 2.
-    rec.add_probe(0, 0, cell_probe_ion_ext_concentration{loc2, "ca"});
- 
+    rec.add_probe(0, 0, cable_probe_ion_ext_concentration{loc2, "ca"});
+
     // Probe (0, 6): na internal on CV 0.
-    rec.add_probe(0, 0, cell_probe_ion_int_concentration{loc0, "na"});
+    rec.add_probe(0, 0, cable_probe_ion_int_concentration{loc0, "na"});
     // Probe (0, 7): na internal on CV 2.
-    rec.add_probe(0, 0, cell_probe_ion_int_concentration{loc2, "na"});
+    rec.add_probe(0, 0, cable_probe_ion_int_concentration{loc2, "na"});
 
     // Probe (0, 8): write_ca2 state 's' in CV 0.
-    rec.add_probe(0, 0, cell_probe_density_state{loc0, "write_ca2", "s"});
+    rec.add_probe(0, 0, cable_probe_density_state{loc0, "write_ca2", "s"});
     // Probe (0, 9): write_ca2 state 's' in CV 1.
-    rec.add_probe(0, 0, cell_probe_density_state{loc1, "write_ca2", "s"});
+    rec.add_probe(0, 0, cable_probe_density_state{loc1, "write_ca2", "s"});
     // Probe (0, 10): write_ca2 state 's' in CV 2.
-    rec.add_probe(0, 0, cell_probe_density_state{loc2, "write_ca2", "s"});
+    rec.add_probe(0, 0, cable_probe_density_state{loc2, "write_ca2", "s"});
+
+    // Probe (0, 11): na internal on whole cell.
+    rec.add_probe(0, 0, cable_probe_ion_int_concentration_cell{"na"});
+    // Probe (0, 12): ca external on whole cell.
+    rec.add_probe(0, 0, cable_probe_ion_ext_concentration_cell{"ca"});
 
     std::vector<target_handle> targets;
     std::vector<fvm_index_type> cell_to_intdom;
-    probe_association_map<probe_handle> probe_map;
+    probe_association_map<fvm_probe_info> probe_map;
 
     fvm_cell lcell(*ctx);
     lcell.initialize({0}, rec, cell_to_intdom, targets, probe_map);
@@ -281,20 +525,24 @@ void run_ion_density_probe_test(const context& ctx) {
     // CV 0, and so probe (0, 8) should have been discarded. All other probes
     // should be in the map.
 
-    EXPECT_EQ(11u, rec.num_probes(0));
-    EXPECT_EQ(9u, probe_map.size());
+    EXPECT_EQ(13u, rec.num_probes(0));
+    EXPECT_EQ(11u, probe_map.size());
+
+    auto probe_scalar_handle = [&](cell_member_type x) {
+        return util::get<fvm_probe_scalar>(probe_map.at(x).handle.info).raw_handles[0];
+    };
 
-    probe_handle ca_int_cv0 = probe_map.at({0, 0}).handle;
-    probe_handle ca_int_cv1 = probe_map.at({0, 1}).handle;
-    probe_handle ca_int_cv2 = probe_map.at({0, 2}).handle;
-    probe_handle ca_ext_cv0 = probe_map.at({0, 3}).handle;
-    probe_handle ca_ext_cv1 = probe_map.at({0, 4}).handle;
-    probe_handle ca_ext_cv2 = probe_map.at({0, 5}).handle;
+    probe_handle ca_int_cv0 = probe_scalar_handle({0, 0});
+    probe_handle ca_int_cv1 = probe_scalar_handle({0, 1});
+    probe_handle ca_int_cv2 = probe_scalar_handle({0, 2});
+    probe_handle ca_ext_cv0 = probe_scalar_handle({0, 3});
+    probe_handle ca_ext_cv1 = probe_scalar_handle({0, 4});
+    probe_handle ca_ext_cv2 = probe_scalar_handle({0, 5});
     EXPECT_EQ(0u, probe_map.count({0, 6}));
-    probe_handle na_int_cv2 = probe_map.at({0, 7}).handle;
+    probe_handle na_int_cv2 = probe_scalar_handle({0, 7});
     EXPECT_EQ(0u, probe_map.count({0, 8}));
-    probe_handle write_ca2_s_cv1 = probe_map.at({0, 9}).handle;
-    probe_handle write_ca2_s_cv2 = probe_map.at({0, 10}).handle;
+    probe_handle write_ca2_s_cv1 = probe_scalar_handle({0, 9});
+    probe_handle write_ca2_s_cv2 = probe_scalar_handle({0, 10});
 
     // Ion concentrations should have been written in initialization.
     // For CV 1, calcium concentration should be mean of the two values
@@ -316,129 +564,484 @@ void run_ion_density_probe_test(const context& ctx) {
     EXPECT_EQ(2.75, deref(write_ca2_s_cv1));
     EXPECT_EQ(2.75, deref(write_ca2_s_cv2));
     EXPECT_NE(write_ca2_s_cv1, write_ca2_s_cv2);
+
+    // For the all cell sodium concentration probe, check that metadata
+    // cables comprise a cable for all of CV 1 and a cable for all of CV 2:
+    // half coverage of CV 1 by sodium ion-requiring mechanism implies
+    // ion values are valid across whole CV.
+    //
+    // Implementation detail has that the cables (and corresponding raw handles) are
+    // sorted by CV in the fvm_probe_weighted_multi object; this is assumed
+    // below.
+
+    auto* p_ptr = util::get_if<fvm_probe_multi>(probe_map.at({0, 11}).handle.info);
+    ASSERT_TRUE(p_ptr);
+    fvm_probe_multi& na_int_all_info = *p_ptr;
+
+    auto* m_ptr = util::get_if<mcable_list>(na_int_all_info.metadata);
+    ASSERT_TRUE(m_ptr);
+    mcable_list na_int_all_metadata = *m_ptr;
+
+    ASSERT_EQ(2u, na_int_all_metadata.size());
+    ASSERT_EQ(2u, na_int_all_info.raw_handles.size());
+
+    EXPECT_DOUBLE_EQ(1./3., na_int_all_metadata[0].prox_pos);
+    EXPECT_DOUBLE_EQ(2./3., na_int_all_metadata[0].dist_pos);
+    EXPECT_DOUBLE_EQ(2./3., na_int_all_metadata[1].prox_pos);
+    EXPECT_DOUBLE_EQ(1.,    na_int_all_metadata[1].dist_pos);
+    EXPECT_EQ(na_int_cv2,   na_int_all_info.raw_handles[1]);
+    EXPECT_EQ(na_int_cv2-1, na_int_all_info.raw_handles[0]);
+
+    p_ptr = util::get_if<fvm_probe_multi>(probe_map.at({0, 12}).handle.info);
+    ASSERT_TRUE(p_ptr);
+    fvm_probe_multi& ca_ext_all_info = *p_ptr;
+
+    m_ptr = util::get_if<mcable_list>(ca_ext_all_info.metadata);
+    ASSERT_TRUE(m_ptr);
+    mcable_list ca_ext_all_metadata = *m_ptr;
+
+    ASSERT_EQ(3u, ca_ext_all_metadata.size());
+    ASSERT_EQ(3u, ca_ext_all_info.raw_handles.size());
+
+    EXPECT_DOUBLE_EQ(0.,    ca_ext_all_metadata[0].prox_pos);
+    EXPECT_DOUBLE_EQ(1./3., ca_ext_all_metadata[1].prox_pos);
+    EXPECT_DOUBLE_EQ(2./3., ca_ext_all_metadata[2].prox_pos);
+    EXPECT_EQ(ca_ext_cv0,   ca_ext_all_info.raw_handles[0]);
+    EXPECT_EQ(ca_ext_cv1,   ca_ext_all_info.raw_handles[1]);
+    EXPECT_EQ(ca_ext_cv2,   ca_ext_all_info.raw_handles[2]);
 }
 
 template <typename Backend>
-void run_ion_current_probe_test(const context& ctx) {
+void run_partial_density_probe_test(const context& ctx) {
     using fvm_cell = typename backend_access<Backend>::fvm_cell;
     auto deref = [](const fvm_value_type* p) { return backend_access<Backend>::deref(p); };
 
-    // Use test mechanism fixed_ica_current, and a derived mechanism for sodium, to
-    // write to specific ion currents.
+    // Use test mechanism param_as_state to query averaged state values in CVs with
+    // partial coverage by the mechanism.
 
     auto cat = make_unit_test_catalogue();
-    cat.derive("fixed_ina_current", "fixed_ica_current", {}, {{"ca", "na"}});
 
     cable_cell cells[2];
 
-    // Simple constant diameter cable, 3 CVs.
+    // Each cell is a simple constant diameter cable, with 3 CVs each.
 
-    cells[0] = cable_cell(sample_tree({msample{{0., 0., 0., 1.}, 0}, msample{{100., 0., 0., 1.}, 0}}, {mnpos, 0u}));
+    morphology m(sample_tree({msample{{0., 0., 0., 1.}, 0}, msample{{100., 0., 0., 1.}, 0}}, {mnpos, 0u}));
+    cells[0] = cable_cell(m);
     cells[0].default_parameters.discretization = cv_policy_fixed_per_branch(3);
 
-    // Calcium ions everywhere, half with current density jca0, half with jca1.
-    // Sodium ions only on distal half, with current densitry jna1.
-
-    const double jca0 = 1.5; // [A/m²]
-    const double jca1 = 2.0;
-    const double jna1 = 2.5;
-
-    // Scaling factor 0.1 is to convert our current densities in [A/m²] to NMODL units [mA/cm²].
-
-    cells[0].paint(mcable{0, 0., 0.5}, mechanism_desc("fixed_ica_current").set("current_density", 0.1*jca0));
-    cells[0].paint(mcable{0, 0.5, 1.}, mechanism_desc("fixed_ica_current").set("current_density", 0.1*jca1));
-    cells[0].paint(mcable{0, 0.5, 1.}, mechanism_desc("fixed_ina_current").set("current_density", 0.1*jna1));
-
-    // Make a second cable cell, with same layout but 3 times the current.
-
-    cells[1] = cable_cell(sample_tree({msample{{0., 0., 0., 1.}, 0}, msample{{100., 0., 0., 1.}, 0}}, {mnpos, 0u}));
+    cells[1] = cable_cell(m);
     cells[1].default_parameters.discretization = cv_policy_fixed_per_branch(3);
 
-    cells[1].paint(mcable{0, 0., 0.5}, mechanism_desc("fixed_ica_current").set("current_density", 0.3*jca0));
-    cells[1].paint(mcable{0, 0.5, 1.}, mechanism_desc("fixed_ica_current").set("current_density", 0.3*jca1));
-    cells[1].paint(mcable{0, 0.5, 1.}, mechanism_desc("fixed_ina_current").set("current_density", 0.3*jna1));
-
-    // Place probes in each CV on cell 0, plus one in the last CV on cell 1.
-
-    mlocation loc0{0, 0.1};
-    mlocation loc1{0, 0.5};
-    mlocation loc2{0, 0.9};
+    // Paint the mechanism on every second 10% interval of each cell.
+    // Expected values on a CV are the weighted mean of the parameter values
+    // over the intersections of the support and the CV.
+    //
+    // Cell 0:       [0.0, 0.1], [0.2, 0.3] [0.4, 0.5] [0.6, 0.7] [0.8, 0.9].
+    // Param values:     2           3          4          5          6
+    // Expected values:
+    //    CV 0:   2.5
+    //    CV 1:   4.4
+    //    CV 2:   5.75
+    //
+    // Cell 1:       [0.1, 0.2], [0.3, 0.4] [0.5, 0.6] [0.7, 0.8] [0.9, 1.0].
+    // Param values:     7           8          9         10         11
+    // Expected values:
+    //    CV 3:   7.25
+    //    CV 4:   8.6
+    //    CV 5:  10.5
+
+    auto mk_mech = [](double param) { return mechanism_desc("param_as_state").set("p", param); };
+
+    cells[0].paint(mcable{0, 0.0, 0.1}, mk_mech(2));
+    cells[0].paint(mcable{0, 0.2, 0.3}, mk_mech(3));
+    cells[0].paint(mcable{0, 0.4, 0.5}, mk_mech(4));
+    cells[0].paint(mcable{0, 0.6, 0.7}, mk_mech(5));
+    cells[0].paint(mcable{0, 0.8, 0.9}, mk_mech(6));
+
+    cells[1].paint(mcable{0, 0.1, 0.2}, mk_mech(7));
+    cells[1].paint(mcable{0, 0.3, 0.4}, mk_mech(8));
+    cells[1].paint(mcable{0, 0.5, 0.6}, mk_mech(9));
+    cells[1].paint(mcable{0, 0.7, 0.8}, mk_mech(10));
+    cells[1].paint(mcable{0, 0.9, 1.0}, mk_mech(11));
+
+    // Place probes in the middle of each 10% interval, i.e. at 0.05, 0.15, etc.
+    struct test_probe {
+        double pos;
+        double expected[2]; // Expected value in each cell, NAN => n/a
+    } test_probes[] = {
+        { 0.05, { 2.5,   NAN  }},  // CV 0, 3
+        { 0.15, { NAN,   7.25 }},  // CV 0, 3
+        { 0.25, { 2.5,   NAN  }},  // CV 0, 3
+        { 0.35, { NAN,   8.6  }},  // CV 1, 4
+        { 0.45, { 4.4,   NAN  }},  // CV 1, 4
+        { 0.55, { NAN,   8.6  }},  // CV 1, 4
+        { 0.65, { 4.4,   NAN  }},  // CV 1, 4
+        { 0.75, { NAN,  10.5  }},  // CV 2, 5
+        { 0.85, { 5.75,  NAN  }},  // CV 2, 5
+        { 0.95, { NAN,  10.5  }}   // CV 2, 5
+    };
 
     cable1d_recipe rec(cells);
     rec.catalogue() = cat;
 
-    // Probe (0, 0): ica on CV 0.
-    rec.add_probe(0, 0, cell_probe_ion_current_density{loc0, "ca"});
-    // Probe (0, 1): ica on CV 1.
-    rec.add_probe(0, 0, cell_probe_ion_current_density{loc1, "ca"});
-    // Probe (0, 2): ica on CV 2.
-    rec.add_probe(0, 0, cell_probe_ion_current_density{loc2, "ca"});
-
-    // Probe (0, 3): ina on CV 0.
-    rec.add_probe(0, 0, cell_probe_ion_current_density{loc0, "na"});
-    // Probe (0, 4): ina on CV 1.
-    rec.add_probe(0, 0, cell_probe_ion_current_density{loc1, "na"});
-    // Probe (0, 5): ina on CV 2.
-    rec.add_probe(0, 0, cell_probe_ion_current_density{loc2, "na"});
-
-    // Probe (0, 6): total ion current density on CV 0.
-    rec.add_probe(0, 0, cell_probe_total_ionic_current_density{loc0});
-    // Probe (0, 7): total ion current density on CV 1.
-    rec.add_probe(0, 0, cell_probe_total_ionic_current_density{loc1});
-    // Probe (0, 8): total ion current density on CV 2.
-    rec.add_probe(0, 0, cell_probe_total_ionic_current_density{loc2});
-
-    // Probe (1, 0): ica on CV 5 (CV 2 of cell 1).
-    rec.add_probe(1, 0, cell_probe_ion_current_density{loc2, "ca"});
-    // Probe (1, 1): total ion current density on CV 5 (CV 2 of cell 1).
-    rec.add_probe(1, 0, cell_probe_total_ionic_current_density{loc2});
+    for (auto tp: test_probes) {
+        rec.add_probe(0, 0, cable_probe_density_state{mlocation{0, tp.pos}, "param_as_state", "s"});
+        rec.add_probe(1, 0, cable_probe_density_state{mlocation{0, tp.pos}, "param_as_state", "s"});
+    }
 
     std::vector<target_handle> targets;
     std::vector<fvm_index_type> cell_to_intdom;
-    probe_association_map<probe_handle> probe_map;
+    probe_association_map<fvm_probe_info> probe_map;
 
     fvm_cell lcell(*ctx);
     lcell.initialize({0, 1}, rec, cell_to_intdom, targets, probe_map);
 
-    // Should be no sodium ion instantiated on CV 0, so probe (0, 3) should
-    // have been silently discared.
+    // There should be 10 probes on each cell, but only 10 in total in the probe map,
+    // as only those probes that are in the mechanism support should have an entry.
 
-    EXPECT_EQ(9u, rec.num_probes(0));
-    EXPECT_EQ(2u, rec.num_probes(1));
+    EXPECT_EQ(10u, rec.num_probes(0));
+    EXPECT_EQ(10u, rec.num_probes(1));
     EXPECT_EQ(10u, probe_map.size());
 
-    probe_handle ica_cv0 = probe_map.at({0, 0}).handle;
-    probe_handle ica_cv1 = probe_map.at({0, 1}).handle;
-    probe_handle ica_cv2 = probe_map.at({0, 2}).handle;
-    EXPECT_EQ(0u, probe_map.count({0, 3}));
-    probe_handle ina_cv1 = probe_map.at({0, 4}).handle;
-    probe_handle ina_cv2 = probe_map.at({0, 5}).handle;
-    probe_handle i_cv0 = probe_map.at({0, 6}).handle;
-    probe_handle i_cv1 = probe_map.at({0, 7}).handle;
-    probe_handle i_cv2 = probe_map.at({0, 8}).handle;
+    auto probe_scalar_handle = [&](cell_member_type x) {
+        return util::get<fvm_probe_scalar>(probe_map.at(x).handle.info).raw_handles[0];
+    };
 
-    probe_handle ica_cv5 = probe_map.at({1, 0}).handle;
-    probe_handle i_cv5 = probe_map.at({1, 1}).handle;
+    // Check probe values against expected values.
+
+    cell_lid_type probe_lid = 0;
+    for (auto tp: test_probes) {
+        for (cell_gid_type gid: {0, 1}) {
+            cell_member_type probe_id{gid, probe_lid};
+            if (std::isnan(tp.expected[gid])) {
+                EXPECT_EQ(0u, probe_map.count(probe_id));
+            }
+            else {
+                probe_handle h = probe_scalar_handle(probe_id);
+                EXPECT_DOUBLE_EQ(tp.expected[gid], deref(h));
+            }
+        }
+        ++probe_lid;
+    }
+}
 
-    // Integrate cell for a bit, and check that currents add up as we expect.
+template <typename Backend>
+void run_axial_and_ion_current_probe_sampled_test(const context& ctx) {
+    // On a passive cable in steady-state, the capacitive membrane current will be zero,
+    // and the axial currents should balance the ionic membrane currents in any CV.
+    //
+    // Membrane currents not associated with an ion are not visible, so we use a custom
+    // mechanism 'ca_linear' that presents a passive, constant conductance membrane current
+    // as a calcium ion current.
 
-    lcell.integrate(0.01, 0.0025, {}, {});
+    auto cat = make_unit_test_catalogue();
+
+    // Cell is a tapered cable with 3 CVs.
+
+    morphology m(sample_tree({msample{{0., 0., 0., 1.}, 0}, msample{{100., 0., 0., 0.8}, 0}}, {mnpos, 0u}));
+    cable_cell cell(m);
+
+    const unsigned n_cv = 3;
+    cv_policy policy = cv_policy_fixed_per_branch(n_cv);
+    cell.default_parameters.discretization = policy;
+
+    cell.place(mlocation{0, 0}, i_clamp(0, INFINITY, 0.3));
+
+    // The time constant will be membrane capacitance / membrane conductance.
+    // For τ = 0.1 ms, set conductance to 0.01 S/cm² and membrance capacitance
+    // to 0.01 F/m².
+
+    cell.paint(reg::all(), mechanism_desc("ca_linear").set("g", 0.01)); // [S/cm²]
+    cell.default_parameters.membrane_capacitance = 0.01; // [F/m²]
+    const double tau = 0.1; // [ms]
+
+    cable1d_recipe rec(cell);
+    rec.catalogue() = cat;
+
+    // Place axial current probes at CV boundaries and make a cell-wide probe for
+    // total ionic membrane current.
 
-    EXPECT_DOUBLE_EQ(jca0, deref(ica_cv0));
-    EXPECT_DOUBLE_EQ((jca0+jca1)/2, deref(ica_cv1));
-    EXPECT_DOUBLE_EQ(jca1, deref(ica_cv2));
+    mlocation_list cv_boundaries;
+    util::assign(cv_boundaries,
+        util::filter(thingify(policy.cv_boundary_points(cell), cell.provider()),
+            [](mlocation loc) { return loc.pos!=0 && loc.pos!=1; }));
 
-    EXPECT_DOUBLE_EQ(jna1/2, deref(ina_cv1));
-    EXPECT_DOUBLE_EQ(jna1, deref(ina_cv2));
+    ASSERT_EQ(n_cv-1, cv_boundaries.size());
+    const unsigned n_axial_probe = n_cv-1;
 
-    EXPECT_DOUBLE_EQ(jca0, deref(i_cv0));
-    EXPECT_DOUBLE_EQ(jna1/2+jca0/2+jca1/2, deref(i_cv1));
-    EXPECT_DOUBLE_EQ(jna1+jca1, deref(i_cv2));
+    for (mlocation loc: cv_boundaries) {
+        // Probe ids will be be (0, 0), (0, 1), etc.
+        rec.add_probe(0, 0, cable_probe_axial_current{loc});
+    }
+
+    // Use tag 1 to indicate it's a whole-cell probe.
+    rec.add_probe(0, 1, cable_probe_total_ion_current_cell{});
+
+    partition_hint_map phints = {
+       {cell_kind::cable, {partition_hint::max_size, partition_hint::max_size, true}}
+    };
+    simulation sim(rec, partition_load_balance(rec, ctx, phints), ctx);
+
+    // Take a sample at 20 tau, and run sim for just a bit longer.
+
+    std::vector<double> i_axial(n_axial_probe);
+    std::vector<double> i_memb;
+
+    sim.add_sampler(all_probes, explicit_schedule({20*tau}),
+        [&](cell_member_type probe_id, probe_tag tag, util::any_ptr metadata,
+           std::size_t n_sample, const sample_record* samples)
+        {
+            // Expect exactly one sample.
+            ASSERT_EQ(1u, n_sample);
+
+            if (tag==1) { // (whole cell probe)
+                const mcable_list* m = util::any_cast<const mcable_list*>(metadata);
+                ASSERT_NE(nullptr, m);
+                // Metadata should comprise one cable per CV.
+                ASSERT_EQ(n_cv, m->size());
+
+                const cable_sample_range* s = util::any_cast<const cable_sample_range*>(samples[0].data);
+                ASSERT_NE(nullptr, s);
+                ASSERT_EQ(s->first+n_cv, s->second);
+
+                for (const double* p = s->first; p!=s->second; ++p) {
+                    i_memb.push_back(*p);
+                }
+            }
+            else { // axial current probe
+                // Probe id tells us which axial current this is.
+                ASSERT_LT(probe_id.index, n_axial_probe);
+
+                const mlocation* m = util::any_cast<const mlocation*>(metadata);
+                ASSERT_NE(nullptr, m);
+
+                const double* s = util::any_cast<const double*>(samples[0].data);
+                ASSERT_NE(nullptr, s);
+
+                i_axial.at(probe_id.index) = *s;
+            }
+        });
+
+    const double dt = 0.025; // [ms]
+    sim.run(20*tau+dt, dt);
+
+    ASSERT_EQ(n_cv, i_memb.size());
+
+    for (unsigned i = 0; i<n_cv; ++i) {
+        // Axial currents are in the distal (increasing CV index) direction,
+        // while membrane currents are from intra- to extra-cellular medium.
+        //
+        // Net outward flux from CV is axial current on distal side, minus
+        // axial current on proximal side, plus membrane current, and should
+        // sum to zero.
+
+        double net_axial_flux = i<n_axial_probe? i_axial[i]: 0;
+        net_axial_flux -= i>0? i_axial[i-1]: 0;
+
+        EXPECT_TRUE(testing::near_relative(net_axial_flux, -i_memb[i], 1e-6));
+    }
+}
+
+
+// Run given cells taking samples from the provied probes on one of the cells.
+// Use default mechanism catalogue augmented by unit test specific mechanisms.
+// (Timestep fixed at 0.025 ms).
+
+template <typename SampleData, typename SampleMeta = void>
+auto run_simple_samplers(
+    const arb::context& ctx,
+    double t_end,
+    const std::vector<cable_cell>& cells,
+    cell_gid_type probe_cell,
+    const std::vector<util::any>& probe_addrs,
+    const std::vector<double>& when)
+{
+    cable1d_recipe rec(cells, false);
+    rec.catalogue() = make_unit_test_catalogue(global_default_catalogue());
+    unsigned n_probe = probe_addrs.size();
+
+    for (auto& addr: probe_addrs) {
+        rec.add_probe(probe_cell, 0, addr);
+    }
+
+    partition_hint_map phints = {
+       {cell_kind::cable, {partition_hint::max_size, partition_hint::max_size, true}}
+    };
+    simulation sim(rec, partition_load_balance(rec, ctx, phints), ctx);
+
+    std::vector<trace_data<SampleData, SampleMeta>> traces(n_probe);
+    for (unsigned i = 0; i<n_probe; ++i) {
+        sim.add_sampler(one_probe({probe_cell, i}), explicit_schedule(when), make_simple_sampler(traces[i]));
+    }
+
+    sim.run(t_end, 0.025);
+    return traces;
+}
+
+template <typename SampleData, typename SampleMeta = void>
+auto run_simple_sampler(
+    const arb::context& ctx,
+    double t_end,
+    const std::vector<cable_cell>& cells,
+    cell_gid_type probe_cell,
+    const util::any& probe_addr,
+    const std::vector<double>& when)
+{
+    return run_simple_samplers<SampleData, SampleMeta>(ctx, t_end, cells, probe_cell, {probe_addr}, when).at(0);
+}
+
+template <typename Backend>
+void run_v_sampled_test(const context& ctx) {
+    cable_cell bs = make_cell_ball_and_stick(false);
+    bs.default_parameters.discretization = cv_policy_fixed_per_branch(1);
+
+    std::vector<cable_cell> cells = {bs, bs};
+
+    // Add stims, up to 0.5 ms on cell 0, up to 1.0 ms on cell 1, so that
+    // samples at the same point on each cell will give the same value at
+    // 0.3 ms, but different at 0.6 ms.
+
+    cells[0].place(mlocation{1, 1}, i_clamp(0, 0.5, 1.));
+    cells[1].place(mlocation{1, 1}, i_clamp(0, 1.0, 1.));
+    mlocation probe_loc{1, 0.2};
+
+    const double t_end = 1.; // [ms]
+    std::vector<double> when = {0.3, 0.6}; // Sample at 0.3 and 0.6 ms.
+
+    auto trace0 = run_simple_sampler<double, mlocation>(ctx, t_end, cells, 0, cable_probe_membrane_voltage{probe_loc}, when);
+    EXPECT_EQ(2u, trace0.size());
+    EXPECT_EQ(probe_loc, trace0.metadata.value());
+
+    auto trace1 = run_simple_sampler<double, mlocation>(ctx, t_end, cells, 1, cable_probe_membrane_voltage{probe_loc}, when);
+    EXPECT_EQ(2u, trace1.size());
+    EXPECT_EQ(probe_loc, trace1.metadata.value());
+
+    EXPECT_EQ(trace0[0].t, trace1[0].t);
+    EXPECT_EQ(trace0[0].v, trace1[0].v);
+
+    EXPECT_EQ(trace0[1].t, trace1[1].t);
+    EXPECT_NE(trace0[1].v, trace1[1].v);
+}
+
+template <typename Backend>
+void run_total_current_test(const context& ctx) {
+    // Model two passive Y-shaped cells with a similar but not identical
+    // time constant Ï„.
+    //
+    // Sample each cell's total membrane currents at the same time,
+    // approximately equal to the time constants Ï„.
+    //
+    // Net current flux in each cell should be zero, but currents should
+    // differ between the cells.
+
+    cable_cell cell(make_y_morphology());
+
+    const unsigned n_cv_per_branch = 3;
+    const unsigned n_branch = 3;
+
+    // The time constant will be membrane capacitance / membrane conductance.
+    // For τ = 0.1 ms, set conductance to 0.01 S/cm² and membrance capacitance
+    // to 0.01 F/m².
+
+    const double tau = 0.1;     // [ms]
+    cell.place(mlocation{0, 0}, i_clamp(0, INFINITY, 0.3));
+
+    cell.paint(reg::all(), mechanism_desc("ca_linear").set("g", 0.01)); // [S/cm²]
+    cell.default_parameters.membrane_capacitance = 0.01; // [F/m²]
+
+    std::vector<cable_cell> cells = {cell, cell};
+
+    // Tweak membrane capacitance on cells[1] so as to change dynamics a bit.
+    cells[1].default_parameters.membrane_capacitance = 0.009; // [F/m²]
+
+    // We'll run each set of tests twice: once with a trivial (zero-volume) CV
+    // at the fork points, and once with a non-trivial CV centred on the fork
+    // point.
+
+    trace_data<std::vector<double>, mcable_list> traces[2];
+    trace_data<std::vector<double>, mcable_list> ion_traces[2];
+
+    // Run the cells sampling at Ï„ and 20Ï„ for both total membrane
+    // current and total membrane ionic current.
+
+    auto run_cells = [&](bool interior_forks) {
+        auto flags = interior_forks? cv_policy_flag::interior_forks: cv_policy_flag::none;
+        cv_policy policy = cv_policy_fixed_per_branch(n_cv_per_branch, flags);
+        for (auto& c: cells) { c.default_parameters.discretization = policy; }
 
-    // Currents on cell 1 should be 3 times those on cell 0.
+        for (unsigned i = 0; i<2; ++i) {
+            SCOPED_TRACE(i);
 
-    EXPECT_DOUBLE_EQ(jca1*3, deref(ica_cv5));
-    EXPECT_DOUBLE_EQ((jna1+jca1)*3, deref(i_cv5));
+            const double t_end = 21*tau; // [ms]
+
+            traces[i] = run_simple_sampler<std::vector<double>, mcable_list>(ctx, t_end, cells, i,
+                    cable_probe_total_current_cell{}, {tau, 20*tau});
+
+            ion_traces[i] = run_simple_sampler<std::vector<double>, mcable_list>(ctx, t_end, cells, i,
+                    cable_probe_total_ion_current_cell{}, {tau, 20*tau});
+
+            ASSERT_EQ(2u, traces[i].size());
+            ASSERT_EQ(2u, ion_traces[i].size());
+
+            // Check metadata size:
+            //  * With trivial forks, should have n_cv_per_branch*n_branch cables; zero-length cables
+            //    associated with the trivial CVs at forks should not be included.
+            //  * With nontrivial forks, we'll have an extra cable at the head of each branch, which
+            //    for all but the root branch will be a component cable of the CV on the fork.
+            //
+            // Total membrane current and total ionic mebrane current should have the
+            // same support and same metadata.
+
+            ASSERT_EQ((n_cv_per_branch+(int)interior_forks)*n_branch, traces[i].metadata.value().size());
+            EXPECT_EQ(ion_traces[i].metadata, traces[i].metadata);
+            EXPECT_EQ(ion_traces[i][0].v.size(), traces[i][0].v.size());
+            EXPECT_EQ(ion_traces[i][1].v.size(), traces[i][1].v.size());
+
+            // Check total membrane currents are individually non-zero, but sum is, both
+            // at t=Ï„ (j=0) and t=20Ï„ (j=1).
+
+            for (unsigned j: {0u, 1u}) {
+                double max_abs_i_memb = 0;
+                double sum_i_memb = 0;
+                for (auto i_memb: traces[i][j].v) {
+                    EXPECT_NE(0.0, i_memb);
+                    max_abs_i_memb = std::max(max_abs_i_memb, std::abs(i_memb));
+                    sum_i_memb += i_memb;
+                }
+
+                EXPECT_NEAR(0.0, sum_i_memb, 1e-6*max_abs_i_memb);
+            }
+
+            // Confirm that total and ion currents differ at Ï„ but are close at 20Ï„.
+
+            for (unsigned k = 0; k<traces[i].size(); ++k) {
+                const double rtol_large = 1e-3;
+                EXPECT_FALSE(testing::near_relative(traces[i][0].v.at(k), ion_traces[i][0].v.at(k), rtol_large));
+            }
+
+            for (unsigned k = 0; k<traces[i].size(); ++k) {
+                const double rtol_small = 1e-6;
+                EXPECT_TRUE(testing::near_relative(traces[i][1].v.at(k), ion_traces[i][1].v.at(k), rtol_small));
+            }
+
+        }
+
+        // Total membrane currents should differ between the two cells at t=Ï„.
+
+        for (unsigned k = 0; k<traces[0][0].v.size(); ++k) {
+            EXPECT_NE(traces[0][0].v.at(k), traces[1][0].v.at(k));
+        }
+    };
+
+    {
+        SCOPED_TRACE("trivial fork CV");
+        run_cells(false);
+    }
+
+    {
+        SCOPED_TRACE("non-trival fork CV");
+        run_cells(true);
+    }
 }
 
 TEST(probe, multicore_v_i) {
@@ -446,22 +1049,46 @@ TEST(probe, multicore_v_i) {
     run_v_i_probe_test<multicore::backend>(ctx);
 }
 
+TEST(probe, multicore_v_cell) {
+    context ctx = make_context();
+    run_v_cable_probe_test<multicore::backend>(ctx);
+}
+
+TEST(probe, multicore_v_sampled) {
+    context ctx = make_context();
+    run_v_sampled_test<multicore::backend>(ctx);
+}
+
 TEST(probe, multicore_expsyn_g) {
     context ctx = make_context();
-    SCOPED_TRACE("uncoalesced synapses");
     run_expsyn_g_probe_test<multicore::backend>(ctx, false);
-    SCOPED_TRACE("coalesced synapses");
     run_expsyn_g_probe_test<multicore::backend>(ctx, true);
 }
 
+TEST(probe, multicore_expsyn_g_cell) {
+    context ctx = make_context();
+    run_expsyn_g_cable_probe_test<multicore::backend>(ctx, false);
+    run_expsyn_g_cable_probe_test<multicore::backend>(ctx, true);
+}
+
 TEST(probe, multicore_ion_conc) {
     context ctx = make_context();
     run_ion_density_probe_test<multicore::backend>(ctx);
 }
 
-TEST(probe, multicore_ion_currents) {
+TEST(probe, multicore_axial_and_ion_current) {
+    context ctx = make_context();
+    run_axial_and_ion_current_probe_sampled_test<multicore::backend>(ctx);
+}
+
+TEST(probe, multicore_partial_density) {
+    context ctx = make_context();
+    run_partial_density_probe_test<multicore::backend>(ctx);
+}
+
+TEST(probe, multicore_total_current) {
     context ctx = make_context();
-    run_ion_current_probe_test<multicore::backend>(ctx);
+    run_total_current_test<multicore::backend>(ctx);
 }
 
 #ifdef ARB_GPU_ENABLED
@@ -472,16 +1099,34 @@ TEST(probe, gpu_v_i) {
     }
 }
 
+TEST(probe, gpu_v_cell) {
+    context ctx = make_context(proc_allocation{1, arbenv::default_gpu()});
+    if (has_gpu(ctx)) {
+        run_v_cable_probe_test<gpu::backend>(ctx);
+    }
+}
+
+TEST(probe, gpu_v_sampled) {
+    context ctx = make_context(proc_allocation{1, arbenv::default_gpu()});
+    run_v_sampled_test<multicore::backend>(ctx);
+}
+
 TEST(probe, gpu_expsyn_g) {
     context ctx = make_context(proc_allocation{1, arbenv::default_gpu()});
     if (has_gpu(ctx)) {
-        SCOPED_TRACE("uncoalesced synapses");
         run_expsyn_g_probe_test<gpu::backend>(ctx, false);
-        SCOPED_TRACE("coalesced synapses");
         run_expsyn_g_probe_test<gpu::backend>(ctx, true);
     }
 }
 
+TEST(probe, gpu_expsyn_g_cell) {
+    context ctx = make_context(proc_allocation{1, arbenv::default_gpu()});
+    if (has_gpu(ctx)) {
+        run_expsyn_g_cable_probe_test<gpu::backend>(ctx, false);
+        run_expsyn_g_cable_probe_test<gpu::backend>(ctx, true);
+    }
+}
+
 TEST(probe, gpu_ion_conc) {
     context ctx = make_context(proc_allocation{1, arbenv::default_gpu()});
     if (has_gpu(ctx)) {
@@ -489,11 +1134,24 @@ TEST(probe, gpu_ion_conc) {
     }
 }
 
-TEST(probe, gpu_ion_currents) {
+TEST(probe, gpu_axial_and_ion_current) {
     context ctx = make_context(proc_allocation{1, arbenv::default_gpu()});
     if (has_gpu(ctx)) {
-        run_ion_current_probe_test<gpu::backend>(ctx);
+        run_axial_and_ion_current_probe_sampled_test<gpu::backend>(ctx);
+    }
+}
+
+TEST(probe, gpu_partial_density) {
+    context ctx = make_context(proc_allocation{1, arbenv::default_gpu()});
+    if (has_gpu(ctx)) {
+        run_partial_density_probe_test<multicore::backend>(ctx);
     }
 }
-#endif
 
+TEST(probe, gpu_total_current) {
+    context ctx = make_context(proc_allocation{1, arbenv::default_gpu()});
+    if (has_gpu(ctx)) {
+        run_total_current_test<gpu::backend>(ctx);
+    }
+}
+#endif
diff --git a/test/unit/test_schedule.cpp b/test/unit/test_schedule.cpp
index c49a1a0e..e03707fe 100644
--- a/test/unit/test_schedule.cpp
+++ b/test/unit/test_schedule.cpp
@@ -186,7 +186,7 @@ struct skew_adaptor {
 
     explicit skew_adaptor(double power): power_(power) {}
     result_type operator()() {
-        constexpr double scale = max()-min();
+        constexpr double scale = (double)(max()-min());
         constexpr double ooscale = 1./scale;
 
         double x = ooscale*(G_()-min());
diff --git a/test/unit/unit_test_catalogue.cpp b/test/unit/unit_test_catalogue.cpp
index 8d01b374..aeb7f39d 100644
--- a/test/unit/unit_test_catalogue.cpp
+++ b/test/unit/unit_test_catalogue.cpp
@@ -7,8 +7,10 @@
 #include "backends/multicore/fvm.hpp"
 
 #include "unit_test_catalogue.hpp"
+#include "mechanisms/ca_linear.hpp"
 #include "mechanisms/celsius_test.hpp"
 #include "mechanisms/diam_test.hpp"
+#include "mechanisms/param_as_state.hpp"
 #include "mechanisms/test0_kin_diff.hpp"
 #include "mechanisms/test_linear_state.hpp"
 #include "mechanisms/test_linear_init.hpp"
@@ -50,11 +52,13 @@ c.register_implementation(#x, testing::make_mechanism_##x<gpu::backend>());
 
 using namespace arb;
 
-mechanism_catalogue make_unit_test_catalogue() {
-    mechanism_catalogue cat;
+mechanism_catalogue make_unit_test_catalogue(const mechanism_catalogue& from) {
+    mechanism_catalogue cat(from);
 
+    ADD_MECH(cat, ca_linear)
     ADD_MECH(cat, celsius_test)
     ADD_MECH(cat, diam_test)
+    ADD_MECH(cat, param_as_state)
     ADD_MECH(cat, test_linear_state)
     ADD_MECH(cat, test_linear_init)
     ADD_MECH(cat, test_linear_init_shuffle)
diff --git a/test/unit/unit_test_catalogue.hpp b/test/unit/unit_test_catalogue.hpp
index 6516ffa2..68435023 100644
--- a/test/unit/unit_test_catalogue.hpp
+++ b/test/unit/unit_test_catalogue.hpp
@@ -2,4 +2,4 @@
 
 #include <arbor/mechcat.hpp>
 
-arb::mechanism_catalogue make_unit_test_catalogue();
+arb::mechanism_catalogue make_unit_test_catalogue(const arb::mechanism_catalogue& from = {});
-- 
GitLab