diff --git a/arbor/CMakeLists.txt b/arbor/CMakeLists.txt
index 24e73acd1a904f874c61252fe01c46735c95871d..f0d0924be86d7c48b4e15fea08c81d80fb51bcf4 100644
--- a/arbor/CMakeLists.txt
+++ b/arbor/CMakeLists.txt
@@ -3,6 +3,7 @@
 set(arbor_sources
     arbexcept.cpp
     assert.cpp
+    backends/multicore/fvm.cpp
     backends/multicore/mechanism.cpp
     backends/multicore/shared_state.cpp
     backends/multicore/stimulus.cpp
@@ -60,6 +61,7 @@ set(arbor_sources
 
 if(ARB_WITH_CUDA)
     list(APPEND arbor_sources
+        backends/gpu/fvm.cpp
         backends/gpu/mechanism.cpp
         backends/gpu/mechanism.cu
         backends/gpu/shared_state.cpp
diff --git a/arbor/backends/gpu/fvm.cpp b/arbor/backends/gpu/fvm.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..8c07e4b0922f353ef438fc10302c00bf43a4987b
--- /dev/null
+++ b/arbor/backends/gpu/fvm.cpp
@@ -0,0 +1,18 @@
+#include <string>
+
+#include <arbor/mechanism.hpp>
+#include "fvm.hpp"
+#include "mechanism.hpp"
+
+// Provides implementation of backend::mechanism_field_data.
+
+namespace arb {
+namespace gpu {
+
+fvm_value_type* backend::mechanism_field_data(arb::mechanism* mptr, const std::string& field) {
+    arb::gpu::mechanism* m = dynamic_cast<arb::gpu::mechanism*>(mptr);
+    return m? m->field_data(field): nullptr;
+}
+
+} // namespace gpu
+} // namespace arb
diff --git a/arbor/backends/gpu/fvm.hpp b/arbor/backends/gpu/fvm.hpp
index f19a71bd1f8a400028a289c25ebdc0ebc4f2dc0c..b40b0a63a77c3f9d4b744148a9174708a303a8d3 100644
--- a/arbor/backends/gpu/fvm.hpp
+++ b/arbor/backends/gpu/fvm.hpp
@@ -72,6 +72,8 @@ struct backend {
             thresholds,
             context);
     }
+
+    static value_type* mechanism_field_data(arb::mechanism* mptr, const std::string& field);
 };
 
 } // namespace gpu
diff --git a/arbor/backends/gpu/mechanism.cpp b/arbor/backends/gpu/mechanism.cpp
index 81d5b29d845b54ce9c9f774c72e92ceb88ec0241..e6c0b46e71f29605ebd06ec0d97b858ba0d0e4b1 100644
--- a/arbor/backends/gpu/mechanism.cpp
+++ b/arbor/backends/gpu/mechanism.cpp
@@ -195,6 +195,14 @@ void mechanism::set_parameter(const std::string& key, const std::vector<fvm_valu
     }
 }
 
+fvm_value_type* mechanism::field_data(const std::string& field_var) {
+    if (auto opt_ptr = value_by_key(field_table(), field_var)) {
+        return *opt_ptr.value();
+    }
+
+    return nullptr;
+}
+
 void multiply_in_place(fvm_value_type* s, const fvm_index_type* p, int n);
 
 void mechanism::initialize() {
diff --git a/arbor/backends/gpu/mechanism.hpp b/arbor/backends/gpu/mechanism.hpp
index 42c7e57a8085cb02761e7b44d7bfeeaa17788061..402507d690afe8af7638eb9035ef5e4d3464d749 100644
--- a/arbor/backends/gpu/mechanism.hpp
+++ b/arbor/backends/gpu/mechanism.hpp
@@ -55,6 +55,10 @@ public:
 
     void set_parameter(const std::string& key, const std::vector<fvm_value_type>& values) override;
 
+    // Peek into mechanism state variable; implements arb::gpu::backend::mechanism_field_data.
+    // Returns pointer to GPU memory corresponding to state variable data.
+    fvm_value_type* field_data(const std::string& state_var);
+
     void initialize() override;
 
 protected:
diff --git a/arbor/backends/multicore/fvm.cpp b/arbor/backends/multicore/fvm.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..2432d43d62be2d8a9bba346152d595020a1cb635
--- /dev/null
+++ b/arbor/backends/multicore/fvm.cpp
@@ -0,0 +1,18 @@
+#include <string>
+
+#include <arbor/mechanism.hpp>
+#include "fvm.hpp"
+#include "mechanism.hpp"
+
+// Provides implementation of backend::mechanism_field_data.
+
+namespace arb {
+namespace multicore {
+
+fvm_value_type* backend::mechanism_field_data(arb::mechanism* mptr, const std::string& field) {
+    arb::multicore::mechanism* m = dynamic_cast<arb::multicore::mechanism*>(mptr);
+    return m? m->field_data(field): nullptr;
+}
+
+} // namespace multicore
+} // namespace arb
diff --git a/arbor/backends/multicore/fvm.hpp b/arbor/backends/multicore/fvm.hpp
index 1f9c019ddeff696d121ad5bf156d1fe7ffc5a61c..46767b3490e9bb287a4bc9965b15356614284350 100644
--- a/arbor/backends/multicore/fvm.hpp
+++ b/arbor/backends/multicore/fvm.hpp
@@ -3,6 +3,8 @@
 #include <string>
 #include <vector>
 
+#include <arbor/mechanism.hpp>
+
 #include "backends/event.hpp"
 #include "backends/multicore/matrix_state.hpp"
 #include "backends/multicore/multi_event_stream.hpp"
@@ -59,6 +61,8 @@ struct backend {
             thresholds,
             context);
     }
+
+    static fvm_value_type* mechanism_field_data(arb::mechanism* mptr, const std::string& field);
 };
 
 } // namespace multicore
diff --git a/arbor/backends/multicore/mechanism.cpp b/arbor/backends/multicore/mechanism.cpp
index 22b9e3c521258b62fdc69656d79974bb313a0112..87597207a70e29dcc0591e06792ff7b46255a352 100644
--- a/arbor/backends/multicore/mechanism.cpp
+++ b/arbor/backends/multicore/mechanism.cpp
@@ -15,6 +15,7 @@
 #include "util/maputil.hpp"
 #include "util/padded_alloc.hpp"
 #include "util/range.hpp"
+#include "util/rangeutil.hpp"
 
 #include "backends/multicore/mechanism.hpp"
 #include "backends/multicore/multicore_common.hpp"
@@ -212,5 +213,14 @@ void mechanism::initialize() {
     }
 }
 
+fvm_value_type* mechanism::field_data(const std::string& field_var) {
+    if (auto opt_ptr = value_by_key(field_table(), field_var)) {
+        return *opt_ptr.value();
+    }
+
+    return nullptr;
+}
+
+
 } // namespace multicore
 } // namespace arb
diff --git a/arbor/backends/multicore/mechanism.hpp b/arbor/backends/multicore/mechanism.hpp
index bf7970b01e85841a056b40e9fda181fc8ea486b2..0d1357304e357f9b56f7f1c5736a6957b1f4b7d1 100644
--- a/arbor/backends/multicore/mechanism.hpp
+++ b/arbor/backends/multicore/mechanism.hpp
@@ -65,6 +65,9 @@ public:
 
     void set_parameter(const std::string& key, const std::vector<fvm_value_type>& values) override;
 
+    // Peek into mechanism state variable; implements arb::multicore::backend::mechanism_field_data.
+    fvm_value_type* field_data(const std::string& state_var);
+
 protected:
     size_type width_ = 0;        // Instance width (number of CVs/sites)
     size_type width_padded_ = 0; // Width rounded up to multiple of pad/alignment.
diff --git a/arbor/fvm_lowered_cell.hpp b/arbor/fvm_lowered_cell.hpp
index 5f77ca1e4edda257e218289cce7b3161c64061da..99d3000ed777180d007c765e781a029b0f851ad2 100644
--- a/arbor/fvm_lowered_cell.hpp
+++ b/arbor/fvm_lowered_cell.hpp
@@ -21,6 +21,18 @@ struct fvm_integration_result {
     util::range<const fvm_value_type*> sample_value;
 };
 
+// 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.
+
+struct fvm_probe_info {
+    // nullptr => nothing to probe
+    probe_handle raw_handle = nullptr;
+};
+
 // Common base class for FVM implementation on host or gpu back-end.
 
 struct fvm_lowered_cell {
diff --git a/arbor/fvm_lowered_cell_impl.hpp b/arbor/fvm_lowered_cell_impl.hpp
index 0f54936bb7220964aa3c8d1ef0b1d577ff52ea95..f35f446eea592b4397049c8d32538418a0b7ab9a 100644
--- a/arbor/fvm_lowered_cell_impl.hpp
+++ b/arbor/fvm_lowered_cell_impl.hpp
@@ -136,6 +136,15 @@ private:
     void set_gpu() {
         if (context_.gpu->has_gpu()) context_.gpu->set_gpu();
     }
+
+    // Translate cell probe descriptions into probe handles etc.
+    fvm_probe_info resolve_probe_address(
+        std::size_t cell_idx,
+        const util::any& paddr,
+        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);
 };
 
 template <typename Backend>
@@ -316,16 +325,16 @@ fvm_integration_result fvm_lowered_cell_impl<Backend>::integrate(
     };
 }
 
-template <typename B>
-void fvm_lowered_cell_impl<B>::update_ion_state() {
+template <typename Backend>
+void fvm_lowered_cell_impl<Backend>::update_ion_state() {
     state_->ions_init_concentration();
     for (auto& m: mechanisms_) {
         m->write_ions();
     }
 }
 
-template <typename B>
-void fvm_lowered_cell_impl<B>::assert_voltage_bounded(fvm_value_type bound) {
+template <typename Backend>
+void fvm_lowered_cell_impl<Backend>::assert_voltage_bounded(fvm_value_type bound) {
     auto v_minmax = state_->voltage_bounds();
     if (v_minmax.first>=-bound && v_minmax.second<=bound) {
         return;
@@ -337,8 +346,8 @@ void fvm_lowered_cell_impl<B>::assert_voltage_bounded(fvm_value_type bound) {
         v_minmax.first<-bound? v_minmax.first: v_minmax.second);
 }
 
-template <typename B>
-void fvm_lowered_cell_impl<B>::initialize(
+template <typename Backend>
+void fvm_lowered_cell_impl<Backend>::initialize(
     const std::vector<cell_gid_type>& gids,
     const recipe& rec,
     std::vector<fvm_index_type>& cell_to_intdom,
@@ -444,6 +453,9 @@ void fvm_lowered_cell_impl<B>::initialize(
 
     target_handles.resize(mech_data.n_target);
 
+    // Keep track of mechanisms by name for probe lookup.
+    std::unordered_map<std::string, mechanism*> mechptr_by_name;
+
     unsigned mech_id = 0;
     for (auto& m: mech_data.mechanisms) {
         auto& name = m.first;
@@ -498,6 +510,7 @@ void fvm_lowered_cell_impl<B>::initialize(
 
         auto minst = mech_instance(name);
         minst.mech->instantiate(mech_id++, *state_, minst.overrides, layout);
+        mechptr_by_name[name] = minst.mech.get();
 
         for (auto& pv: config.param_values) {
             minst.mech->set_parameter(pv.first, pv.second);
@@ -526,25 +539,11 @@ void fvm_lowered_cell_impl<B>::initialize(
 
         for (cell_lid_type j: make_span(rec.num_probes(gid))) {
             probe_info pi = rec.get_probe({gid, j});
-            auto where = any_cast<cell_probe_address>(pi.address);
-
-            fvm_size_type cv;
-            probe_handle handle;
-
-            switch (where.kind) {
-            case cell_probe_address::membrane_voltage:
-                cv = D.geometry.location_cv(cell_idx, where.location, cv_prefer::cv_empty);
-                handle = state_->voltage.data()+cv;
-                break;
-            case cell_probe_address::membrane_current:
-                cv = D.geometry.location_cv(cell_idx, where.location, cv_prefer::cv_nonempty);
-                handle = state_->current_density.data()+cv;
-                break;
-            default:
-                throw arbor_internal_error("fvm_lowered_cell: unrecognized probeKind");
-            }
+            fvm_probe_info info = resolve_probe_address(cell_idx, pi.address, D, mech_data, target_handles, mechptr_by_name);
 
-            probe_map.insert({pi.id, {handle, pi.tag}});
+            if (info.raw_handle) {
+                probe_map.insert({pi.id, {info.raw_handle, pi.tag}});
+            }
         }
     }
 
@@ -554,8 +553,8 @@ void fvm_lowered_cell_impl<B>::initialize(
 }
 
 // Get vector of gap_junctions
-template <typename B>
-std::vector<fvm_gap_junction> fvm_lowered_cell_impl<B>::fvm_gap_junctions(
+template <typename Backend>
+std::vector<fvm_gap_junction> fvm_lowered_cell_impl<Backend>::fvm_gap_junctions(
         const std::vector<cable_cell>& cells,
         const std::vector<cell_gid_type>& gids,
         const recipe& rec, const fvm_cv_discretization& D) {
@@ -599,8 +598,8 @@ std::vector<fvm_gap_junction> fvm_lowered_cell_impl<B>::fvm_gap_junctions(
     return v;
 }
 
-template <typename B>
-fvm_size_type fvm_lowered_cell_impl<B>::fvm_intdom(
+template <typename Backend>
+fvm_size_type fvm_lowered_cell_impl<Backend>::fvm_intdom(
         const recipe& rec,
         const std::vector<cell_gid_type>& gids,
         std::vector<fvm_index_type>& cell_to_intdom) {
@@ -649,4 +648,111 @@ fvm_size_type fvm_lowered_cell_impl<B>::fvm_intdom(
     return intdom_id;
 }
 
+template <typename Backend>
+fvm_probe_info fvm_lowered_cell_impl<Backend>::resolve_probe_address(
+    std::size_t cell_idx,
+    const util::any& paddr,
+    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)
+{
+    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);
+    };
+
+    struct mechanism_data {
+        const fvm_value_type* state_data = nullptr;
+        unsigned id;
+        explicit operator bool() const { return state_data; }
+    };
+
+    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 {};
+    };
+
+    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;
+    };
+
+    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};
+    }
+    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()};
+            }
+        }
+        return fvm_probe_info{};
+    }
+    else if (auto* p = any_cast<cell_probe_point_state>(&paddr)) {
+        if (p->target>=handles.size()) {
+            return fvm_probe_info{};
+        }
+
+        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};
+            }
+        }
+        return fvm_probe_info{};
+    }
+    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()};
+        }
+        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{};
+    }
+    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()};
+        }
+        return fvm_probe_info{};
+    }
+    else {
+        throw cable_cell_error("unrecognized probe address type");
+    }
+}
+
 } // namespace arb
diff --git a/arbor/include/arbor/cable_cell.hpp b/arbor/include/arbor/cable_cell.hpp
index fd876e1e2182f406f2057588fda10d6a26677128..ed9d3a927a8bd2681dd1e4e7e12b8f00eb3738c0 100644
--- a/arbor/include/arbor/cable_cell.hpp
+++ b/arbor/include/arbor/cable_cell.hpp
@@ -29,14 +29,49 @@ struct lid_range {
         begin(b), end(e) {}
 };
 
-// Probe type for cell descriptions.
-struct cell_probe_address {
-    enum probe_kind {
-        membrane_voltage, membrane_current
-    };
+// Each kind of probe has its own type for representing its address,
+// as below:
 
+// Voltage estimate [mV] at `location`, possibly interpolated.
+struct cell_probe_membrane_voltage {
     mlocation location;
-    probe_kind kind;
+};
+
+// Total current density [A/m²] across membrane excluding capacitive current at `location`.
+struct cell_probe_total_ionic_current_density {
+    mlocation location;
+};
+
+// Value of state variable `state` in density mechanism `mechanism` in CV at `location`.
+struct cell_probe_density_state {
+    mlocation location;
+    std::string mechanism;
+    std::string state;
+};
+
+// Value of state variable `key` in point mechanism `source` at target `target`.
+struct cell_probe_point_state {
+    cell_lid_type target;
+    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 {
+    mlocation location;
+    std::string ion;
+};
+
+// Ionic internal concentration [mmol/L] of ion `source` at `location`, possibly interpolated.
+struct cell_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 {
+    mlocation location;
+    std::string ion;
 };
 
 // Forward declare the implementation, for PIMPL.
diff --git a/example/dryrun/dryrun.cpp b/example/dryrun/dryrun.cpp
index ec9413bc59097e39e59405e55b57995c488f7b11..c8354c26c5a3d6da8fd991a9c6cab0b062567a98 100644
--- a/example/dryrun/dryrun.cpp
+++ b/example/dryrun/dryrun.cpp
@@ -52,7 +52,6 @@ using arb::cell_size_type;
 using arb::cell_member_type;
 using arb::cell_kind;
 using arb::time_type;
-using arb::cell_probe_address;
 
 // Generate a cell.
 arb::cable_cell branch_cell(arb::cell_gid_type gid, const cell_parameters& params);
@@ -127,12 +126,10 @@ public:
     }
 
     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;
         // Measure at the soma.
         arb::mlocation loc{0, 0.0};
 
-        return arb::probe_info{id, kind, cell_probe_address{loc, kind}};
+        return arb::probe_info{id, 0, arb::cell_probe_membrane_voltage{loc}};
     }
 
 private:
diff --git a/example/gap_junctions/gap_junctions.cpp b/example/gap_junctions/gap_junctions.cpp
index 8a39dd23bca8ccb49ec2b591e6f86b00d7d27c98..4168aa769a9468a37809041eeacf38c547781b07 100644
--- a/example/gap_junctions/gap_junctions.cpp
+++ b/example/gap_junctions/gap_junctions.cpp
@@ -53,7 +53,6 @@ using arb::cell_size_type;
 using arb::cell_member_type;
 using arb::cell_kind;
 using arb::time_type;
-using arb::cell_probe_address;
 
 // Writes voltage trace as a json file.
 void write_trace_json(const std::vector<arb::trace_data<double>>& trace, unsigned rank);
@@ -100,12 +99,9 @@ public:
     }
 
     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;
         // Measure at the soma.
         arb::mlocation loc{0, 1.};
-
-        return arb::probe_info{id, kind, cell_probe_address{loc, kind}};
+        return arb::probe_info{id, 0, arb::cell_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 e5234ec96980dae656103e1697d7ed7389ba58d5..bc392b43919086c6e4cc08338d6009c905b35d97 100644
--- a/example/generators/generators.cpp
+++ b/example/generators/generators.cpp
@@ -28,7 +28,6 @@ using arb::cell_size_type;
 using arb::cell_member_type;
 using arb::cell_kind;
 using arb::time_type;
-using arb::cell_probe_address;
 
 // Writes voltage trace as a json file.
 void write_trace_json(const arb::trace_data<double>& trace);
@@ -125,12 +124,9 @@ public:
         arb_assert(id.gid==0);     // There is one cell,
         arb_assert(id.index==0);   // with one probe.
 
-        // Get the appropriate kind for measuring voltage
-        cell_probe_address::probe_kind kind = cell_probe_address::membrane_voltage;
         // Measure at the soma
         arb::mlocation loc{0, 0.0};
-
-        return arb::probe_info{id, kind, cell_probe_address{loc, kind}};
+        return arb::probe_info{id, 0, arb::cell_probe_membrane_voltage{loc}};
     }
 };
 
diff --git a/example/ring/ring.cpp b/example/ring/ring.cpp
index 46b222cc11824717bfef78456451add30988f996..561b09df2dc296c3cf73072d2134d58296f56989 100644
--- a/example/ring/ring.cpp
+++ b/example/ring/ring.cpp
@@ -53,7 +53,6 @@ using arb::cell_size_type;
 using arb::cell_member_type;
 using arb::cell_kind;
 using arb::time_type;
-using arb::cell_probe_address;
 
 // Writes voltage trace as a json file.
 void write_trace_json(const arb::trace_data<double>& trace);
@@ -115,12 +114,9 @@ public:
     }
 
     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;
         // Measure at the soma.
         arb::mlocation loc{0, 0.0};
-
-        return arb::probe_info{id, kind, cell_probe_address{loc, kind}};
+        return arb::probe_info{id, 0, arb::cell_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 3946d9c759357488820b6697e86c8763869ed4ca..4e62819a99477ad9dc6fe2d594732bc8a67f6017 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_address probe = {mid_soma, arb::cell_probe_address::membrane_voltage};
+        arb::cell_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 9d9e342323af35f12b0a0c5641d672b237da87b4..0ce022d900ad799aafa0e5f5ecdcf5278ebd81ac 100644
--- a/python/recipe.cpp
+++ b/python/recipe.cpp
@@ -29,20 +29,14 @@ arb::util::unique_any py_recipe_shim::get_cell_description(arb::cell_gid_type gi
                 "Python error already thrown");
 }
 
-arb::cell_probe_address::probe_kind probe_kind_from_string(const std::string& name) {
-    if (name == "voltage") {
-        return arb::cell_probe_address::probe_kind::membrane_voltage;
+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}};
     }
-    else if (name == "current") {
-        return arb::cell_probe_address::probe_kind::membrane_current;
+    else if (kind == "ionic current density") {
+        return arb::probe_info{id, 0, arb::cell_probe_total_ionic_current_density{loc}};
     }
-    else throw pyarb_error(util::pprintf("invalid probe kind: {}, neither voltage nor current", name));
-}
-
-arb::probe_info cable_probe(std::string kind, arb::cell_member_type id, arb::mlocation loc) {
-    auto pkind = probe_kind_from_string(kind);
-    arb::cell_probe_address probe{loc, pkind};
-    return arb::probe_info{id, pkind, probe};
+    else throw pyarb_error(util::pprintf("unrecognized probe kind: {}", kind));
 };
 
 std::vector<arb::event_generator> convert_gen(std::vector<pybind11::object> pygens, arb::cell_gid_type gid) {
@@ -180,7 +174,7 @@ void register_recipe(pybind11::module& m) {
     // Probes
     m.def("cable_probe", &cable_probe,
         "Description of a probe at a location on a cable cell with id available for monitoring data of kind "\
-        "where kind is one of 'voltage' or 'current'.",
+        "where kind is one of 'voltage' or 'ionic current density'.",
         "kind"_a, "id"_a, "location"_a);
 
     pybind11::class_<arb::probe_info> probe(m, "probe");
diff --git a/python/single_cell_model.cpp b/python/single_cell_model.cpp
index 249e0325e855b5d9253adb80579239ba70f3e190..036477652662c9d4f3e6cf38136d87ddbcaa51d1 100644
--- a/python/single_cell_model.cpp
+++ b/python/single_cell_model.cpp
@@ -116,9 +116,8 @@ struct single_cell_recipe: arb::recipe {
         }
 
         // For now only voltage can be selected for measurement.
-        auto kind = arb::cell_probe_address::membrane_voltage;
         const auto& loc = probes_[probe_id.index].site;
-        return arb::probe_info{probe_id, kind, arb::cell_probe_address{loc, kind}};
+        return arb::probe_info{probe_id, 0, arb::cell_probe_membrane_voltage{loc}};
     }
 
     // gap junctions
diff --git a/test/unit/CMakeLists.txt b/test/unit/CMakeLists.txt
index d867d294b15d406f4f933ca16e181084b1507887..9f9d6455931cf9e1eb0040cd2bdca5fa1a392805 100644
--- a/test/unit/CMakeLists.txt
+++ b/test/unit/CMakeLists.txt
@@ -23,6 +23,7 @@ set(test_mechanisms
     test_cl_valence
     test_ca_read_valence
     read_eX
+    write_Xi_Xo
     write_multiple_eX
     write_eX
     read_cai_init
diff --git a/test/unit/mod/fixed_ica_current.mod b/test/unit/mod/fixed_ica_current.mod
index f3887c09d3ee87bd1339f765cc337a9cb67782e8..4756a70b6ad7e2beefe7f4baa2a47cdd64ece5d4 100644
--- a/test/unit/mod/fixed_ica_current.mod
+++ b/test/unit/mod/fixed_ica_current.mod
@@ -2,23 +2,23 @@
 
 NEURON {
     SUFFIX fixed_ica_current
-    USEION ca WRITE ica VALENCE 2
-    RANGE ica_density
+    USEION ca WRITE ica
+    RANGE current_density
 }
 
 PARAMETER {
-    ica_density = 0
+    current_density = 0
 }
 
 ASSIGNED {}
 
 INITIAL {
-    ica = ica_density
+    ica = current_density
 }
 
 STATE {}
 
 BREAKPOINT  {
-    ica = ica_density
+    ica = current_density
 }
 
diff --git a/test/unit/mod/write_Xi_Xo.mod b/test/unit/mod/write_Xi_Xo.mod
new file mode 100644
index 0000000000000000000000000000000000000000..fa916d21f901b8340e0021881e8a7ddca94b0282
--- /dev/null
+++ b/test/unit/mod/write_Xi_Xo.mod
@@ -0,0 +1,33 @@
+: Ion concentration writer, with state variable.
+: Used for testing mechanism and ion probes.
+
+NEURON {
+    SUFFIX write_Xi_Xo
+    USEION x WRITE xi, xo
+    GLOBAL xi0, xo0, s0
+}
+
+PARAMETER {
+    xi0 = 1
+    xo0 = 2
+    s0 = 3
+}
+
+ASSIGNED {}
+
+STATE {
+    s
+}
+
+INITIAL {
+    s = s0
+    xi = xi0
+    xo = xo0
+}
+
+BREAKPOINT  {
+    s = s0
+    xi = xi0
+    xo = xo0
+}
+
diff --git a/test/unit/test_fvm_layout.cpp b/test/unit/test_fvm_layout.cpp
index bbdc25fd98ff962bb85476bc3a7b68b36e7f8463..c09f25eef696e26127fb0da910b989f197796028 100644
--- a/test/unit/test_fvm_layout.cpp
+++ b/test/unit/test_fvm_layout.cpp
@@ -515,7 +515,7 @@ TEST(fvm_layout, density_norm_area) {
 
     std::vector<cable_cell> cells{std::move(cell)};
 
-    int ncv = 11; //ncomp + 1
+    int ncv = 11;
     std::vector<double> expected_gkbar(ncv, dflt_gkbar);
     std::vector<double> expected_gl(ncv, dflt_gl);
 
diff --git a/test/unit/test_fvm_lowered.cpp b/test/unit/test_fvm_lowered.cpp
index 884d2df185a5c3fc8e720fe2b26c758e74e8bf89..69a8e30d8d90ea697261474b461f7489f4aae6fa 100644
--- a/test/unit/test_fvm_lowered.cpp
+++ b/test/unit/test_fvm_lowered.cpp
@@ -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_address where{{1, 0.3}, cell_probe_address::membrane_current};
+    cell_probe_total_ionic_current_density where{{1, 0.3}};
     rec.add_probe(0, 0, where);
     rec.add_probe(1, 0, where);
     rec.add_probe(2, 0, where);
@@ -662,7 +662,7 @@ TEST(fvm_lowered, ionic_currents) {
 
     const double jca = 1.5;
     mechanism_desc m1("fixed_ica_current");
-    m1["ica_density"] = jca;
+    m1["current_density"] = jca;
 
     // Mechanism models a well-mixed fixed-depth volume without replenishment,
     // giving a linear response to ica over time.
diff --git a/test/unit/test_probe.cpp b/test/unit/test_probe.cpp
index 7ac92d3c395a403d72ae9ef15f76535b148ac4ad..9b8582e102771117103cdf8cc1bbd4a2a3285337 100644
--- a/test/unit/test_probe.cpp
+++ b/test/unit/test_probe.cpp
@@ -1,25 +1,71 @@
 #include "../gtest.h"
 
-#include <arbor/common_types.hpp>
 #include <arbor/cable_cell.hpp>
+#include <arbor/common_types.hpp>
+#include <arbor/mechcat.hpp>
+#include <arbor/mechinfo.hpp>
+#include <arbor/version.hpp>
+#include <arborenv/gpu_env.hpp>
 
 #include "backends/event.hpp"
 #include "backends/multicore/fvm.hpp"
+#ifdef ARB_GPU_ENABLED
+#include "backends/gpu/fvm.hpp"
+#endif
 #include "fvm_lowered_cell_impl.hpp"
+#include "memory/cuda_wrappers.hpp"
 #include "util/rangeutil.hpp"
 
 #include "common.hpp"
+#include "unit_test_catalogue.hpp"
 #include "../common_cells.hpp"
 #include "../simple_recipes.hpp"
 
 using namespace arb;
-using fvm_cell = fvm_lowered_cell_impl<multicore::backend>;
-using shared_state = multicore::backend::shared_state;
 
-ACCESS_BIND(std::unique_ptr<shared_state> fvm_cell::*, fvm_state_ptr, &fvm_cell::state_);
+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;
+
+    static multicore_shared_state& state(fvm_cell& cell) {
+        return *(cell.*multicore_fvm_state_ptr).get();
+    }
+
+    static fvm_value_type deref(const fvm_value_type* p) { return *p; }
+};
+
+#ifdef ARB_GPU_ENABLED
+
+using gpu_fvm_cell = fvm_lowered_cell_impl<gpu::backend>;
+using gpu_shared_state = gpu::backend::shared_state;
+ACCESS_BIND(std::unique_ptr<gpu_shared_state> gpu_fvm_cell::*, gpu_fvm_state_ptr, &gpu_fvm_cell::state_);
 
-TEST(probe, fvm_lowered_cell) {
-    execution_context context;
+template <>
+struct backend_access<gpu::backend> {
+    using fvm_cell = gpu_fvm_cell;
+
+    static gpu_shared_state& state(fvm_cell& cell) {
+        return *(cell.*gpu_fvm_state_ptr).get();
+    }
+
+    static fvm_value_type deref(const fvm_value_type* p) {
+        fvm_value_type r;
+        memory::cuda_memcpy_d2h(&r, p, sizeof(r));
+        return r;
+    }
+};
+
+#endif
+
+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);
 
@@ -32,15 +78,15 @@ TEST(probe, fvm_lowered_cell) {
     mlocation loc1{1, 1};
     mlocation loc2{1, 0.3};
 
-    rec.add_probe(0, 10, cell_probe_address{loc0, cell_probe_address::membrane_voltage});
-    rec.add_probe(0, 20, cell_probe_address{loc1, cell_probe_address::membrane_voltage});
-    rec.add_probe(0, 30, cell_probe_address{loc2, cell_probe_address::membrane_current});
+    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});
 
     std::vector<target_handle> targets;
     std::vector<fvm_index_type> cell_to_intdom;
     probe_association_map<probe_handle> probe_map;
 
-    fvm_cell lcell(context);
+    fvm_cell lcell(*ctx);
     lcell.initialize({0}, rec, cell_to_intdom, targets, probe_map);
 
     EXPECT_EQ(3u, rec.num_probes(0));
@@ -58,16 +104,16 @@ TEST(probe, fvm_lowered_cell) {
     // for the voltage probes (cell membrane potential should
     // be constant), and zero for the current probe.
 
-    auto& state = *(lcell.*fvm_state_ptr).get();
+    auto& state = backend_access<Backend>::state(lcell);
     auto& voltage = state.voltage;
 
-    auto resting = voltage[0];
+    fvm_value_type resting = voltage[0];
     EXPECT_NE(0.0, resting);
 
     // (Probe handles are just pointers in this implementation).
-    EXPECT_EQ(resting, *p0);
-    EXPECT_EQ(resting, *p1);
-    EXPECT_EQ(0.0, *p2);
+    EXPECT_EQ(resting, deref(p0));
+    EXPECT_EQ(resting, deref(p1));
+    EXPECT_EQ(0.0, deref(p2));
 
     // After an integration step, expect voltage probe values
     // to differ from resting, and between each other, and
@@ -78,11 +124,376 @@ TEST(probe, fvm_lowered_cell) {
 
     lcell.integrate(0.01, 0.0025, {}, {});
 
-    EXPECT_NE(resting, *p0);
-    EXPECT_NE(resting, *p1);
-    EXPECT_NE(*p0, *p1);
-    EXPECT_NE(0.0, *p2);
+    EXPECT_NE(resting, deref(p0));
+    EXPECT_NE(resting, deref(p1));
+    EXPECT_NE(deref(p0), deref(p1));
+    EXPECT_NE(0.0, deref(p2));
+
+    fvm_value_type v = voltage[0];
+    EXPECT_EQ(v, deref(p0));
+}
+
+template <typename Backend>
+void run_expsyn_g_probe_test(const context& ctx, bool coalesce_synapses = false) {
+    using fvm_cell = typename backend_access<Backend>::fvm_cell;
+    auto deref = [](const fvm_value_type* p) { return backend_access<Backend>::deref(p); };
+
+    const double tau = 2.0;
+    EXPECT_EQ(tau, global_default_catalogue()["expsyn"].parameters.at("tau").default_value);
+
+    // Ball-and-stick cell, two synapses, both in same CV.
+    mlocation loc0{1, 0.8};
+    mlocation loc1{1, 1.0};
+
+    cable_cell bs = make_cell_ball_and_stick(false);
+    bs.place(loc0, "expsyn");
+    bs.place(loc1, "expsyn");
+    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"});
+
+    std::vector<target_handle> targets;
+    std::vector<fvm_index_type> cell_to_intdom;
+    probe_association_map<probe_handle> 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());
+
+    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;
+
+    // Expect initial probe values to be intial synapse g == 0.
+
+    EXPECT_EQ(0.0, deref(p0));
+    EXPECT_EQ(0.0, deref(p1));
+
+    if (coalesce_synapses) {
+        // Should be the same raw pointer!
+        EXPECT_EQ(p0, p1);
+    }
+
+    // Integrate to 3 ms, with one event at 1ms to first expsyn weight 0.5,
+    // and another at 2ms to second, weight 1.
+
+    std::vector<deliverable_event> evs = {
+        {1.0, targets[0], 0.5},
+        {2.0, targets[1], 1.0}
+    };
+    const double tfinal = 3.;
+    const double dt = 0.001;
+    lcell.integrate(tfinal, dt, evs, {});
+
+    fvm_value_type g0 = deref(p0);
+    fvm_value_type g1 = deref(p1);
+
+    // Expected value: weight*exp(-(t_final-t_event)/tau).
+    double expected_g0 = 0.5*std::exp(-(tfinal-1.0)/tau);
+    double expected_g1 = 1.0*std::exp(-(tfinal-2.0)/tau);
+
+    const double rtol = 1e-6;
+    if (coalesce_synapses) {
+        EXPECT_TRUE(testing::near_relative(expected_g0+expected_g1, g0, rtol));
+        EXPECT_TRUE(testing::near_relative(expected_g0+expected_g1, g1, rtol));
+    }
+    else {
+        EXPECT_TRUE(testing::near_relative(expected_g0, g0, rtol));
+        EXPECT_TRUE(testing::near_relative(expected_g1, g1, rtol));
+    }
+}
+
+template <typename Backend>
+void run_ion_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 write_Xi_Xo to check ion concentration probes and
+    // density mechanism state probes.
+
+    auto cat = make_unit_test_catalogue();
+    cat.derive("write_ca1", "write_Xi_Xo", {{"xi0", 1.25}, {"xo0", 1.5}, {"s0", 1.75}}, {{"x", "ca"}});
+    cat.derive("write_ca2", "write_Xi_Xo", {{"xi0", 2.25}, {"xo0", 2.5}, {"s0", 2.75}}, {{"x", "ca"}});
+    cat.derive("write_na3", "write_Xi_Xo", {{"xi0", 3.25}, {"xo0", 3.5}, {"s0", 3.75}}, {{"x", "na"}});
+
+    // Simple constant diameter cable, 3 CVs.
+
+    cable_cell cable(sample_tree({msample{{0., 0., 0., 1.}, 0}, msample{{100., 0., 0., 1.}, 0}}, {mnpos, 0u}));
+    cable.default_parameters.discretization = cv_policy_fixed_per_branch(3);
+
+    // Calcium ions everywhere, half written by write_ca1, half by write_ca2.
+    // Sodium ions only on distal half.
+
+    cable.paint(mcable{0, 0., 0.5}, "write_ca1");
+    cable.paint(mcable{0, 0.5, 1.}, "write_ca2");
+    cable.paint(mcable{0, 0.5, 1.}, "write_na3");
+
+    // Place probes in each CV.
+
+    mlocation loc0{0, 0.1};
+    mlocation loc1{0, 0.5};
+    mlocation loc2{0, 0.9};
+
+    cable1d_recipe rec(cable);
+    rec.catalogue() = cat;
+
+    // Probe (0, 0): ca internal on CV 0.
+    rec.add_probe(0, 0, cell_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"});
+    // Probe (0, 2): ca internal on CV 2.
+    rec.add_probe(0, 0, cell_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"});
+    // Probe (0, 4): ca external on CV 1.
+    rec.add_probe(0, 0, cell_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"});
+ 
+    // Probe (0, 6): na internal on CV 0.
+    rec.add_probe(0, 0, cell_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"});
+
+    // Probe (0, 8): write_ca2 state 's' in CV 0.
+    rec.add_probe(0, 0, cell_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"});
+    // Probe (0, 10): write_ca2 state 's' in CV 2.
+    rec.add_probe(0, 0, cell_probe_density_state{loc2, "write_ca2", "s"});
+
+    std::vector<target_handle> targets;
+    std::vector<fvm_index_type> cell_to_intdom;
+    probe_association_map<probe_handle> probe_map;
+
+    fvm_cell lcell(*ctx);
+    lcell.initialize({0}, rec, cell_to_intdom, targets, probe_map);
+
+    // Should be no sodium ion instantiated on CV 0, so probe (0, 6) should
+    // have been silently discared. Similarly, write_ca2 is not instantiated on
+    // 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());
+
+    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;
+    EXPECT_EQ(0u, probe_map.count({0, 6}));
+    probe_handle na_int_cv2 = probe_map.at({0, 7}).handle;
+    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;
+
+    // Ion concentrations should have been written in initialization.
+    // For CV 1, calcium concentration should be mean of the two values
+    // from write_ca1 and write_ca2.
+
+    EXPECT_EQ(1.25, deref(ca_int_cv0));
+    EXPECT_DOUBLE_EQ((1.25+2.25)/2., deref(ca_int_cv1));
+    EXPECT_EQ(2.25, deref(ca_int_cv2));
+
+    EXPECT_EQ(1.5, deref(ca_ext_cv0));
+    EXPECT_DOUBLE_EQ((1.5+2.5)/2., deref(ca_ext_cv1));
+    EXPECT_EQ(2.5, deref(ca_ext_cv2));
+
+    EXPECT_EQ(3.25, deref(na_int_cv2));
+
+    // State variable in write_ca2 should be the same in both CV 1 and 2.
+    // The raw handles should be different addresses, however.
+
+    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);
+}
+
+template <typename Backend>
+void run_ion_current_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.
+
+    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.
+
+    cells[0] = cable_cell(sample_tree({msample{{0., 0., 0., 1.}, 0}, msample{{100., 0., 0., 1.}, 0}}, {mnpos, 0u}));
+    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].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};
+
+    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});
+
+    std::vector<target_handle> targets;
+    std::vector<fvm_index_type> cell_to_intdom;
+    probe_association_map<probe_handle> 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.
+
+    EXPECT_EQ(9u, rec.num_probes(0));
+    EXPECT_EQ(2u, 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;
+
+    probe_handle ica_cv5 = probe_map.at({1, 0}).handle;
+    probe_handle i_cv5 = probe_map.at({1, 1}).handle;
+
+    // Integrate cell for a bit, and check that currents add up as we expect.
+
+    lcell.integrate(0.01, 0.0025, {}, {});
+
+    EXPECT_DOUBLE_EQ(jca0, deref(ica_cv0));
+    EXPECT_DOUBLE_EQ((jca0+jca1)/2, deref(ica_cv1));
+    EXPECT_DOUBLE_EQ(jca1, deref(ica_cv2));
+
+    EXPECT_DOUBLE_EQ(jna1/2, deref(ina_cv1));
+    EXPECT_DOUBLE_EQ(jna1, deref(ina_cv2));
+
+    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));
+
+    // Currents on cell 1 should be 3 times those on cell 0.
+
+    EXPECT_DOUBLE_EQ(jca1*3, deref(ica_cv5));
+    EXPECT_DOUBLE_EQ((jna1+jca1)*3, deref(i_cv5));
+}
+
+TEST(probe, multicore_v_i) {
+    context ctx = make_context();
+    run_v_i_probe_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_ion_conc) {
+    context ctx = make_context();
+    run_ion_density_probe_test<multicore::backend>(ctx);
+}
+
+TEST(probe, multicore_ion_currents) {
+    context ctx = make_context();
+    run_ion_current_probe_test<multicore::backend>(ctx);
+}
+
+#ifdef ARB_GPU_ENABLED
+TEST(probe, gpu_v_i) {
+    context ctx = make_context(proc_allocation{1, arbenv::default_gpu()});
+    if (has_gpu(ctx)) {
+        run_v_i_probe_test<gpu::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, multicore_ion_conc) {
+    context ctx = make_context(proc_allocation{1, arbenv::default_gpu()});
+    if (has_gpu(ctx)) {
+        run_ion_density_probe_test<gpu::backend>(ctx);
+    }
+}
 
-    EXPECT_EQ(voltage[0], *p0);
+TEST(probe, multicore_ion_currents) {
+    context ctx = make_context(proc_allocation{1, arbenv::default_gpu()});
+    if (has_gpu(ctx)) {
+        run_ion_current_probe_test<gpu::backend>(ctx);
+    }
 }
+#endif
 
diff --git a/test/unit/unit_test_catalogue.cpp b/test/unit/unit_test_catalogue.cpp
index 0d893f288939b4ec65796e413c1f134429b57a0d..8d01b374326be96af215daa0ccc5856d96dea500 100644
--- a/test/unit/unit_test_catalogue.cpp
+++ b/test/unit/unit_test_catalogue.cpp
@@ -29,6 +29,7 @@
 #include "mechanisms/test_cl_valence.hpp"
 #include "mechanisms/test_ca_read_valence.hpp"
 #include "mechanisms/read_eX.hpp"
+#include "mechanisms/write_Xi_Xo.hpp"
 #include "mechanisms/write_multiple_eX.hpp"
 #include "mechanisms/write_eX.hpp"
 #include "mechanisms/read_cai_init.hpp"
@@ -74,6 +75,7 @@ mechanism_catalogue make_unit_test_catalogue() {
     ADD_MECH(cat, test_cl_valence)
     ADD_MECH(cat, test_ca_read_valence)
     ADD_MECH(cat, read_eX)
+    ADD_MECH(cat, write_Xi_Xo)
     ADD_MECH(cat, write_multiple_eX)
     ADD_MECH(cat, write_eX)
     ADD_MECH(cat, read_cai_init)