diff --git a/arbor/CMakeLists.txt b/arbor/CMakeLists.txt
index a184c0e9ad2da0bfceaee4a1287a35ed44b5e8ae..e9779a4af66a19878c352a3f3a2d829f30cfe02c 100644
--- a/arbor/CMakeLists.txt
+++ b/arbor/CMakeLists.txt
@@ -6,11 +6,9 @@ set(arbor_sources
     backends/multicore/fvm.cpp
     backends/multicore/mechanism.cpp
     backends/multicore/shared_state.cpp
-    backends/multicore/stimulus.cpp
     communication/communicator.cpp
     communication/dry_run_context.cpp
     benchmark_cell_group.cpp
-    builtin_mechanisms.cpp
     cable_cell.cpp
     cable_cell_param.cpp
     cell_group_factory.cpp
@@ -69,7 +67,6 @@ if(ARB_WITH_GPU)
         backends/gpu/mechanism.cpp
         backends/gpu/mechanism.cu
         backends/gpu/shared_state.cpp
-        backends/gpu/stimulus.cpp
         backends/gpu/stimulus.cu
         backends/gpu/threshold_watcher.cu
         backends/gpu/matrix_assemble.cu
diff --git a/arbor/backends/builtin_mech_proto.hpp b/arbor/backends/builtin_mech_proto.hpp
deleted file mode 100644
index 892f122f8165f38fe8dd7e32062c4f85e9b8904c..0000000000000000000000000000000000000000
--- a/arbor/backends/builtin_mech_proto.hpp
+++ /dev/null
@@ -1,35 +0,0 @@
-#pragma once
-
-#include <arbor/mechanism.hpp>
-#include <arbor/mechinfo.hpp>
-
-namespace arb {
-
-// Stimulus
-
-inline const mechanism_info& builtin_stimulus_info() {
-    using spec = mechanism_field_spec;
-    static mechanism_info info = {
-        // globals
-        {},
-        // parameters
-        {
-            {"delay",     {spec::parameter, "ms", 0, 0}},
-            {"duration",  {spec::parameter, "ms", 0, 0}},
-            {"amplitude", {spec::parameter, "nA", 0, 0}}
-        },
-        // state
-        {},
-        // ions
-        {},
-        // fingerprint
-        "##builtin_stimulus"
-    };
-
-    return info;
-};
-
-template <typename B>
-concrete_mech_ptr<B> make_builtin_stimulus();
-
-} // namespace arb
diff --git a/arbor/backends/gpu/hip_api.hpp b/arbor/backends/gpu/hip_api.hpp
index 9e5e98fc6f1e684e4f9d98bd68ce34316446539f..283f68bf4a90ff16e87f99dbbbd3dc92a0850161 100644
--- a/arbor/backends/gpu/hip_api.hpp
+++ b/arbor/backends/gpu/hip_api.hpp
@@ -83,7 +83,7 @@ inline api_error_type device_mem_get_info(ARGS&&... args) {
 
 __device__
 inline double gpu_atomic_add(double* address, double val) {
-return atomicAdd(address, val);
+    return atomicAdd(address, val);
 }
 
 __device__
diff --git a/arbor/backends/gpu/math_cu.hpp b/arbor/backends/gpu/math_cu.hpp
index d7658e6d81ab3c566de1b0c3a3a03967b38e2a0b..e2692b9d9a6106276083f2503a25de433a2055e3 100644
--- a/arbor/backends/gpu/math_cu.hpp
+++ b/arbor/backends/gpu/math_cu.hpp
@@ -1,9 +1,11 @@
 #pragma once
+
+// Implementations of mathematical operations required by generated CUDA mechanisms and back-end methods.
+
 #include <cfloat>
+#include <cmath>
 
 #include "gpu_api.hpp"
-// Implementations of mathematical operations required
-// by generated CUDA mechanisms.
 
 namespace arb {
 namespace gpu {
@@ -38,5 +40,13 @@ inline T max(T lhs, T rhs) {
     return lhs<rhs? rhs: lhs;
 }
 
+template <typename T>
+__device__
+inline T lerp(T a, T b, T u) {
+    return std::fma(u, b, std::fma(-u, a, a));
+}
+
+constexpr double pi = 3.1415926535897932384626433832795l;
+
 } // namespace gpu
 } // namespace arb
diff --git a/arbor/backends/gpu/shared_state.cpp b/arbor/backends/gpu/shared_state.cpp
index ee5648457a74e335610e5087f9da18854df335e8..9dfa021d89131a221792cb7429b75721574527a1 100644
--- a/arbor/backends/gpu/shared_state.cpp
+++ b/arbor/backends/gpu/shared_state.cpp
@@ -10,6 +10,7 @@
 #include "backends/multi_event_stream_state.hpp"
 #include "memory/copy.hpp"
 #include "memory/wrappers.hpp"
+#include "util/index_into.hpp"
 #include "util/rangeutil.hpp"
 
 using arb::memory::make_const_view;
@@ -86,6 +87,82 @@ void ion_state::reset() {
     memory::copy(init_eX_, eX_);
 }
 
+// istim_state methods:
+
+istim_state::istim_state(const fvm_stimulus_config& stim) {
+    using util::assign;
+
+    // Translate instance-to-CV index from stim to istim_state index vectors.
+    std::vector<fvm_index_type> accu_index_stage;
+    assign(accu_index_stage, util::index_into(stim.cv, stim.cv_unique));
+
+    std::size_t n = accu_index_stage.size();
+    std::vector<fvm_value_type> envl_a, envl_t;
+    std::vector<fvm_index_type> edivs;
+
+    frequency_ = make_const_view(stim.frequency);
+
+    arb_assert(n==frequency_.size());
+    arb_assert(n==stim.envelope_time.size());
+    arb_assert(n==stim.envelope_amplitude.size());
+
+    edivs.reserve(n+1);
+    edivs.push_back(0);
+
+    for (auto i: util::make_span(n)) {
+        arb_assert(stim.envelope_time[i].size()==stim.envelope_amplitude[i].size());
+        arb_assert(util::is_sorted(stim.envelope_time[i]));
+
+        util::append(envl_a, stim.envelope_amplitude[i]);
+        util::append(envl_t, stim.envelope_time[i]);
+        edivs.push_back(fvm_index_type(envl_t.size()));
+    }
+
+    accu_index_ = make_const_view(accu_index_stage);
+    accu_to_cv_ = make_const_view(stim.cv_unique);
+    accu_stim_ = array(accu_index_.size());
+    envl_amplitudes_ = make_const_view(envl_a);
+    envl_times_ = make_const_view(envl_t);
+    envl_divs_ = make_const_view(edivs);
+
+    // Initial indices into envelope match partition divisions; ignore last (index n) element.
+    envl_index_ = envl_divs_;
+
+    // Initialize ppack pointers.
+    ppack_.accu_index = accu_index_.data();
+    ppack_.accu_to_cv = accu_to_cv_.data();
+    ppack_.frequency = frequency_.data();
+    ppack_.envl_amplitudes = envl_amplitudes_.data();
+    ppack_.envl_times = envl_times_.data();
+    ppack_.envl_divs = envl_divs_.data();
+    ppack_.accu_stim = accu_stim_.data();
+    ppack_.envl_index = envl_index_.data();
+    // The following ppack fields must be set in add_current() before queuing kernel.
+    ppack_.time = nullptr;
+    ppack_.cv_to_intdom = nullptr;
+    ppack_.current_density = nullptr;
+}
+
+std::size_t istim_state::size() const {
+    return frequency_.size();
+}
+
+void istim_state::zero_current() {
+    memory::fill(accu_stim_, 0.);
+}
+
+void istim_state::reset() {
+    zero_current();
+    memory::copy(envl_divs_, envl_index_);
+}
+
+void istim_state::add_current(const array& time, const iarray& cv_to_intdom, array& current_density) {
+    ppack_.time = time.data();
+    ppack_.cv_to_intdom = cv_to_intdom.data();
+    ppack_.current_density = current_density.data();
+    istim_add_current_impl((int)size(), ppack_);
+}
+
 // Shared state methods:
 
 shared_state::shared_state(
@@ -136,6 +213,10 @@ void shared_state::add_ion(
         std::forward_as_tuple(charge, ion_info, 1u));
 }
 
+void shared_state::configure_stimulus(const fvm_stimulus_config& stims) {
+    stim_data = istim_state(stims);
+}
+
 void shared_state::reset() {
     memory::copy(init_voltage, voltage);
     memory::fill(current_density, 0);
@@ -147,6 +228,7 @@ void shared_state::reset() {
     for (auto& i: ion_data) {
         i.second.reset();
     }
+    stim_data.reset();
 }
 
 void shared_state::zero_currents() {
@@ -155,6 +237,7 @@ void shared_state::zero_currents() {
     for (auto& i: ion_data) {
         i.second.zero_current();
     }
+    stim_data.zero_current();
 }
 
 void shared_state::ions_init_concentration() {
@@ -175,6 +258,10 @@ void shared_state::add_gj_current() {
     add_gj_current_impl(n_gj, gap_junctions.data(), voltage.data(), current_density.data());
 }
 
+void shared_state::add_stimulus_current() {
+    stim_data.add_current(time, cv_to_intdom, current_density);
+}
+
 std::pair<fvm_value_type, fvm_value_type> shared_state::time_bounds() const {
     return minmax_value_impl(n_intdom, time.data());
 }
diff --git a/arbor/backends/gpu/shared_state.cu b/arbor/backends/gpu/shared_state.cu
index 62e7c4d450c3a34c868076e9bbe3cdd52ea0d418..c01376ff506ecc5fef06986de246dfb1282e8427 100644
--- a/arbor/backends/gpu/shared_state.cu
+++ b/arbor/backends/gpu/shared_state.cu
@@ -79,7 +79,7 @@ __global__ void take_samples_impl(
         auto end = s.ev_data+s.end_offset[i];
         for (auto p = begin; p!=end; ++p) {
             sample_time[p->offset] = time[i];
-            sample_value[p->offset] = *p->handle;
+            sample_value[p->offset] = p->handle? *p->handle: 0;
         }
     }
 }
diff --git a/arbor/backends/gpu/shared_state.hpp b/arbor/backends/gpu/shared_state.hpp
index 8d0254b3e95feeb9734a1bac6b3261d95d95d926..6243982e6b45428b81a20a2d53cbf60e055d4690 100644
--- a/arbor/backends/gpu/shared_state.hpp
+++ b/arbor/backends/gpu/shared_state.hpp
@@ -10,6 +10,7 @@
 #include "fvm_layout.hpp"
 
 #include "backends/gpu/gpu_store_types.hpp"
+#include "backends/gpu/stimulus.hpp"
 
 namespace arb {
 namespace gpu {
@@ -60,6 +61,42 @@ struct ion_state {
     void reset();
 };
 
+struct istim_state {
+    // Immutable data (post construction/initialization):
+    iarray accu_index_;     // Instance to accumulator index (accu_stim_ index) map.
+    iarray accu_to_cv_;     // Accumulator index to CV map.
+
+    array frequency_;       // (Hz) stimulus frequency per instance.
+    array envl_amplitudes_; // (A/m²) stimulus envelope amplitudes, partitioned by instance.
+    array envl_times_;      // (A/m²) stimulus envelope timepoints, partitioned by instance.
+    iarray envl_divs_;      // Partition divisions for envl_ arrays,
+
+    // Mutable data:
+    array accu_stim_;       // (A/m²) accumulated stim current / CV area, one per CV with a stimulus.
+    iarray envl_index_;     // Per instance index into envl_ arrays, corresponding to last sample time.
+
+    // Parameter pack presents pointers to state arrays, relevant shared state to GPU kernels.
+    // Initialized at state construction.
+    istim_pp ppack_;
+
+    // Zero stim current.
+    void zero_current();
+
+    // Zero stim current, reset indices.
+    void reset();
+
+    // Contribute to current density:
+    void add_current(const array& time, const iarray& cv_to_intdom, array& current_density);
+
+    // Number of stimuli:
+    std::size_t size() const;
+
+    // Construct state from i_clamp data; references to shared state vectors are used to initialize ppack.
+    istim_state(const fvm_stimulus_config& stim_data);
+
+    istim_state() = default;
+};
+
 struct shared_state {
     fvm_size_type n_intdom = 0;   // Number of distinct integration domains.
     fvm_size_type n_detector = 0; // Max number of detectors on all cells.
@@ -84,8 +121,8 @@ struct shared_state {
     array time_since_spike;   // Stores time since last spike on any detector, organized by cell.
     iarray src_to_spike;      // Maps spike source index to spike index
 
+    istim_state stim_data;
     std::unordered_map<std::string, ion_state> ion_data;
-
     deliverable_event_stream deliverable_events;
 
     shared_state() = default;
@@ -109,6 +146,8 @@ struct shared_state {
         int charge,
         const fvm_ion_config& ion_data);
 
+    void configure_stimulus(const fvm_stimulus_config&);
+
     void zero_currents();
 
     void ions_init_concentration();
@@ -122,6 +161,9 @@ struct shared_state {
     // Update gap_junction state
     void add_gj_current();
 
+    // Update stimulus state and add current contributions.
+    void add_stimulus_current();
+
     // Return minimum and maximum time value [ms] across cells.
     std::pair<fvm_value_type, fvm_value_type> time_bounds() const;
 
diff --git a/arbor/backends/gpu/stimulus.cpp b/arbor/backends/gpu/stimulus.cpp
deleted file mode 100644
index ba05951e9c1ade209ab4bd37fbdf65a9b00301ac..0000000000000000000000000000000000000000
--- a/arbor/backends/gpu/stimulus.cpp
+++ /dev/null
@@ -1,63 +0,0 @@
-#include "backends/builtin_mech_proto.hpp"
-#include "backends/gpu/mechanism.hpp"
-#include "backends/gpu/mechanism_ppack_base.hpp"
-
-#include "stimulus.hpp"
-
-namespace arb {
-namespace gpu {
-
-class stimulus: public arb::gpu::mechanism {
-public:
-    const mechanism_fingerprint& fingerprint() const override {
-        static mechanism_fingerprint hash = "##builtin_stimulus";
-        return hash;
-    }
-    std::string internal_name() const override { return "_builtin_stimulus"; }
-    mechanismKind kind() const override { return ::arb::mechanismKind::point; }
-    mechanism_ptr clone() const override { return mechanism_ptr(new stimulus()); }
-
-    void init() override {}
-    void advance_state() override {}
-    void compute_currents() override {
-        stimulus_current_impl(size(), pp_);
-    }
-
-    void write_ions() override {}
-    void apply_events(deliverable_event_stream::state events) override {}
-
-    mechanism_ppack_base* ppack_ptr() override {
-        return &pp_;
-    }
-
-protected:
-    std::size_t object_sizeof() const override { return sizeof(*this); }
-
-    mechanism_field_table field_table() override {
-        return {
-            {"delay", &pp_.delay},
-            {"duration", &pp_.duration},
-            {"amplitude", &pp_.amplitude}
-        };
-    }
-
-    mechanism_field_default_table field_default_table() override {
-        return {
-            {"delay", 0},
-            {"duration", 0},
-            {"amplitude", 0}
-        };
-    }
-
-private:
-    stimulus_pp pp_;
-};
-
-} // namespace gpu
-
-template <>
-concrete_mech_ptr<gpu::backend> make_builtin_stimulus() {
-    return concrete_mech_ptr<gpu::backend>(new arb::gpu::stimulus());
-}
-
-} // namespace arb
diff --git a/arbor/backends/gpu/stimulus.cu b/arbor/backends/gpu/stimulus.cu
index a207b376614d2874127c4349aad7dc63a1b597b5..cc3f85cad05d6f2df435d33e98a7a333ce8e8958 100644
--- a/arbor/backends/gpu/stimulus.cu
+++ b/arbor/backends/gpu/stimulus.cu
@@ -1,33 +1,62 @@
+#include <cmath>
+
 #include <arbor/fvm_types.hpp>
 
-#include "gpu_api.hpp"
-#include "gpu_common.hpp"
-#include "stimulus.hpp"
+#include "backends/gpu/gpu_api.hpp"
+#include "backends/gpu/gpu_common.hpp"
+#include "backends/gpu/math_cu.hpp"
+#include "backends/gpu/stimulus.hpp"
 
 namespace arb {
 namespace gpu {
 
+// TODO: Implement version with reproducibility-friendly accumulations.
+// See Arbor issue #1059.
+
 namespace kernel {
-    __global__
-    void stimulus_current_impl(int n, stimulus_pp pp) {
-        auto i = threadIdx.x + blockDim.x*blockIdx.x;
-        if (i<n) {
-            auto t = pp.vec_t_[pp.vec_di_[i]];
-            if (t>=pp.delay[i] && t<pp.delay[i]+pp.duration[i]) {
-                // use subtraction because the electrode currents are specified
-                // in terms of current into the compartment
-                gpu_atomic_add(pp.vec_i_+pp.node_index_[i], -pp.weight_[i]*pp.amplitude[i]);
-            }
-        }
+
+__global__
+void istim_add_current_impl(int n, istim_pp pp) {
+    constexpr double freq_scale = 2*pi*0.001;
+
+    auto i = threadIdx.x + blockDim.x*blockIdx.x;
+    if (i>=n) return;
+
+    fvm_index_type ei_left = pp.envl_divs[i];
+    fvm_index_type ei_right = pp.envl_divs[i+1];
+
+    fvm_index_type ai = pp.accu_index[i];
+    fvm_index_type cv = pp.accu_to_cv[ai];
+    double t = pp.time[pp.cv_to_intdom[cv]];
+
+    if (ei_left==ei_right || t<pp.envl_times[ei_left]) return;
+
+    fvm_index_type& ei = pp.envl_index[i];
+    while (ei+1<ei_right && pp.envl_times[ei+1]<=t) ++ei;
+
+    double J = pp.envl_amplitudes[ei]; // current density (A/m²)
+    if (ei+1<ei_right) {
+        // linearly interpolate:
+        double J1 = pp.envl_amplitudes[ei+1];
+        double u = (t-pp.envl_times[ei])/(pp.envl_times[ei+1]-pp.envl_times[ei]);
+        J = lerp(J, J1, u);
+    }
+
+    if (double f = pp.frequency[i]) {
+        J *= std::sin(freq_scale*f*t);
     }
-} // namespace kernel
 
+    gpu_atomic_add(&pp.accu_stim[ai], J);
+    gpu_atomic_sub(&pp.current_density[cv], J);
+}
+
+} // namespace kernel
 
-void stimulus_current_impl(int n, const stimulus_pp& pp) {
+void istim_add_current_impl(int n, const istim_pp& pp) {
     constexpr unsigned block_dim = 128;
     const unsigned grid_dim = impl::block_count(n, block_dim);
 
-    kernel::stimulus_current_impl<<<grid_dim, block_dim>>>(n, pp);
+    kernel::istim_add_current_impl<<<grid_dim, block_dim>>>(n, pp);
 }
 
 } // namespace gpu
diff --git a/arbor/backends/gpu/stimulus.hpp b/arbor/backends/gpu/stimulus.hpp
index b5d41dada90543e967f14750f166949f0fa6c33f..e03edd4322568af25cb9f40ba47c7c01f830251a 100644
--- a/arbor/backends/gpu/stimulus.hpp
+++ b/arbor/backends/gpu/stimulus.hpp
@@ -1,17 +1,30 @@
 #pragma once
 
-#include <backends/gpu/mechanism_ppack_base.hpp>
+#include <arbor/fvm_types.hpp>
 
 namespace arb {
 namespace gpu {
 
-struct stimulus_pp: mechanism_ppack_base {
-    fvm_value_type* delay;
-    fvm_value_type* duration;
-    fvm_value_type* amplitude;
+// Pointer representation of state is passed to GPU kernels.
+
+struct istim_pp {
+    // Stimulus constant and mutable data:
+    const fvm_index_type* accu_index;
+    const fvm_index_type* accu_to_cv;
+    const fvm_value_type* frequency;
+    const fvm_value_type* envl_amplitudes;
+    const fvm_value_type* envl_times;
+    const fvm_index_type* envl_divs;
+    fvm_value_type* accu_stim;
+    fvm_index_type* envl_index;
+
+    // Pointers to shared state data:
+    const fvm_value_type* time;
+    const fvm_index_type* cv_to_intdom;
+    fvm_value_type* current_density;
 };
 
-void stimulus_current_impl(int n, const stimulus_pp&);
+void istim_add_current_impl(int n, const istim_pp& pp);
 
 } // namespace gpu
 } // namespace arb
diff --git a/arbor/backends/gpu/threshold_watcher.cu b/arbor/backends/gpu/threshold_watcher.cu
index 61706f9ee4c5ea215a110a34f0e90e998a30b73f..553f85e4cf3725edbc71dc6ab36378a392805e0b 100644
--- a/arbor/backends/gpu/threshold_watcher.cu
+++ b/arbor/backends/gpu/threshold_watcher.cu
@@ -3,7 +3,7 @@
 #include <arbor/fvm_types.hpp>
 
 #include "backends/threshold_crossing.hpp"
-#include "gpu_common.hpp"
+#include "math_cu.hpp"
 #include "stack_cu.hpp"
 
 namespace arb {
@@ -11,12 +11,6 @@ namespace gpu {
 
 namespace kernel {
 
-template <typename T>
-__device__
-inline T lerp(T a, T b, T u) {
-    return std::fma(u, b, std::fma(-u, a, a));
-}
-
 /// kernel used to test for threshold crossing test code.
 /// params:
 ///     t       : current time (ms)
@@ -67,7 +61,7 @@ void test_thresholds_impl(
                 // The threshold has been passed, so estimate the time using
                 // linear interpolation
                 auto pos = (thresh - v_prev)/(v - v_prev);
-                crossing_time = lerp(t_before[intdom], t_after[intdom], pos);
+                crossing_time = gpu::lerp(t_before[intdom], t_after[intdom], pos);
 
                 if (record_time_since_spike) {
                     time_since_spike[spike_idx] = t_after[intdom] - crossing_time;
diff --git a/arbor/backends/multicore/shared_state.cpp b/arbor/backends/multicore/shared_state.cpp
index 02f4e9f880559a49bd66317249519d8f01e757e9..0da23bcaaa7cfa4e184782fdd30bcaebce0c9859 100644
--- a/arbor/backends/multicore/shared_state.cpp
+++ b/arbor/backends/multicore/shared_state.cpp
@@ -16,6 +16,7 @@
 
 #include "backends/event.hpp"
 #include "io/sepval.hpp"
+#include "util/index_into.hpp"
 #include "util/padded_alloc.hpp"
 #include "util/rangeutil.hpp"
 
@@ -44,7 +45,6 @@ inline unsigned min_alignment(unsigned align) {
 
 using pad = util::padded_allocator<>;
 
-
 // ion_state methods:
 
 ion_state::ion_state(
@@ -87,6 +87,95 @@ void ion_state::reset() {
     std::copy(init_eX_.begin(), init_eX_.end(), eX_.begin());
 }
 
+// istim_state methods:
+
+istim_state::istim_state(const fvm_stimulus_config& stim, unsigned align):
+    alignment(min_alignment(align)),
+    accu_to_cv_(stim.cv_unique.begin(), stim.cv_unique.end(), pad(alignment)),
+    frequency_(stim.frequency.begin(), stim.frequency.end(), pad(alignment))
+{
+    using util::assign;
+
+    // Translate instance-to-CV index from stim to istim_state index vectors.
+    assign(accu_index_, util::index_into(stim.cv, accu_to_cv_));
+    accu_stim_.resize(accu_to_cv_.size());
+
+    std::size_t n = accu_index_.size();
+    std::vector<fvm_value_type> envl_a, envl_t;
+    std::vector<fvm_index_type> edivs;
+
+    arb_assert(n==frequency_.size());
+    arb_assert(n==stim.envelope_time.size());
+    arb_assert(n==stim.envelope_amplitude.size());
+
+    edivs.reserve(n+1);
+    edivs.push_back(0);
+
+    for (auto i: util::make_span(n)) {
+        arb_assert(stim.envelope_time[i].size()==stim.envelope_amplitude[i].size());
+        arb_assert(util::is_sorted(stim.envelope_time[i]));
+
+        util::append(envl_a, stim.envelope_amplitude[i]);
+        util::append(envl_t, stim.envelope_time[i]);
+        edivs.push_back(fvm_index_type(envl_t.size()));
+    }
+
+    assign(envl_amplitudes_, envl_a);
+    assign(envl_times_, envl_t);
+    assign(envl_divs_, edivs);
+    envl_index_.assign(edivs.data(), edivs.data()+n);
+}
+
+void istim_state::zero_current() {
+    util::fill(accu_stim_, 0);
+}
+
+void istim_state::reset() {
+    zero_current();
+
+    std::size_t n = envl_index_.size();
+    std::copy(envl_divs_.data(), envl_divs_.data()+n, envl_index_.begin());
+}
+
+void istim_state::add_current(const array& time, const iarray& cv_to_intdom, array& current_density) {
+    constexpr double freq_scale = 2*math::pi<double>*0.001;
+
+    // Consider vectorizing...
+    for (auto i: util::count_along(accu_index_)) {
+        // Advance index into envelope until either
+        // - the next envelope time is greater than simulation time, or
+        // - it is the last valid index for the envelope.
+
+        fvm_index_type ei_left = envl_divs_[i];
+        fvm_index_type ei_right = envl_divs_[i+1];
+
+        fvm_index_type ai = accu_index_[i];
+        fvm_index_type cv = accu_to_cv_[ai];
+        double t = time[cv_to_intdom[cv]];
+
+        if (ei_left==ei_right || t<envl_times_[ei_left]) continue;
+
+        fvm_index_type& ei = envl_index_[i];
+        while (ei+1<ei_right && envl_times_[ei+1]<=t) ++ei;
+
+        double J = envl_amplitudes_[ei]; // current density (A/m²)
+        if (ei+1<ei_right) {
+            // linearly interpolate:
+            arb_assert(envl_times_[ei]<=t && envl_times_[ei+1]>t);
+            double J1 = envl_amplitudes_[ei+1];
+            double u = (t-envl_times_[ei])/(envl_times_[ei+1]-envl_times_[ei]);
+            J = math::lerp(J, J1, u);
+        }
+
+        if (frequency_[i]) {
+            J *= std::sin(freq_scale*frequency_[i]*t);
+        }
+
+        accu_stim_[ai] += J;
+        current_density[cv] -= J;
+    }
+}
+
 // shared_state methods:
 
 shared_state::shared_state(
@@ -155,6 +244,10 @@ void shared_state::add_ion(
         std::forward_as_tuple(charge, ion_info, alignment));
 }
 
+void shared_state::configure_stimulus(const fvm_stimulus_config& stims) {
+    stim_data = istim_state(stims, alignment);
+}
+
 void shared_state::reset() {
     std::copy(init_voltage.begin(), init_voltage.end(), voltage.begin());
     util::fill(current_density, 0);
@@ -166,6 +259,8 @@ void shared_state::reset() {
     for (auto& i: ion_data) {
         i.second.reset();
     }
+
+    stim_data.reset();
 }
 
 void shared_state::zero_currents() {
@@ -174,6 +269,7 @@ void shared_state::zero_currents() {
     for (auto& i: ion_data) {
         i.second.zero_current();
     }
+    stim_data.zero_current();
 }
 
 void shared_state::ions_init_concentration() {
@@ -228,6 +324,10 @@ void shared_state::add_gj_current() {
     }
 }
 
+void shared_state::add_stimulus_current() {
+     stim_data.add_current(time, cv_to_intdom, current_density);
+}
+
 std::pair<fvm_value_type, fvm_value_type> shared_state::time_bounds() const {
     return util::minmax_value(time);
 }
@@ -245,10 +345,11 @@ void shared_state::take_samples(
         auto begin = s.begin_marked(i);
         auto end = s.end_marked(i);
 
+        // Null handles are explicitly permitted, and always give a sample of zero.
         // (Note: probably not worth explicitly vectorizing this.)
         for (auto p = begin; p<end; ++p) {
             sample_time[p->offset] = time[i];
-            sample_value[p->offset] = *p->handle;
+            sample_value[p->offset] = p->handle? *p->handle: 0;
         }
     }
 }
diff --git a/arbor/backends/multicore/shared_state.hpp b/arbor/backends/multicore/shared_state.hpp
index c36908fd32335e166318e7416c979ac3f654848d..2011c66861dd87b5fc829422421b2ea2388245d5 100644
--- a/arbor/backends/multicore/shared_state.hpp
+++ b/arbor/backends/multicore/shared_state.hpp
@@ -69,11 +69,41 @@ struct ion_state {
     // Set ionic current density to zero.
     void zero_current();
 
-    // Zero currents, reset concentrations, and reset reversal potential from
-    // initial values.
+    // Zero currents, reset concentrations, and reset reversal potential from initial values.
     void reset();
 };
 
+struct istim_state {
+    unsigned alignment = 1; // Alignment and padding multiple.
+
+    // Immutable data (post initialization):
+    iarray accu_index_;     // Instance to accumulator index (accu_stim_ index) map.
+    iarray accu_to_cv_;     // Accumulator index to CV map.
+
+    array frequency_;       // (Hz) stimulus frequency per instance.
+    array envl_amplitudes_; // (A/m²) stimulus envelope amplitudes, partitioned by instance.
+    array envl_times_;      // (A/m²) stimulus envelope timepoints, partitioned by instance.
+    iarray envl_divs_;      // Partition divisions for envl_ arrays,
+
+    // Mutable data:
+    array accu_stim_;       // (A/m²) accumulated stim current / CV area, one per CV with a stimulus.
+    iarray envl_index_;     // Per instance index into envl_ arrays, corresponding to last sample time.
+
+    // Zero stim current.
+    void zero_current();
+
+    // Zero stim current, reset indices.
+    void reset();
+
+    // Contribute to current density:
+    void add_current(const array& time, const iarray& cv_to_intdom, array& current_density);
+
+    // Construct state from i_clamp data:
+    istim_state(const fvm_stimulus_config& stim_data, unsigned align);
+
+    istim_state() = default;
+};
+
 struct shared_state {
     unsigned alignment = 1;   // Alignment and padding multiple.
     util::padded_allocator<> alloc;  // Allocator with corresponging alignment/padding.
@@ -101,8 +131,8 @@ struct shared_state {
     array time_since_spike;   // Stores time since last spike on any detector, organized by cell.
     iarray src_to_spike;      // Maps spike source index to spike index
 
+    istim_state stim_data;
     std::unordered_map<std::string, ion_state> ion_data;
-
     deliverable_event_stream deliverable_events;
 
     shared_state() = default;
@@ -126,6 +156,8 @@ struct shared_state {
         int charge,
         const fvm_ion_config& ion_data);
 
+    void configure_stimulus(const fvm_stimulus_config&);
+
     void zero_currents();
 
     void ions_init_concentration();
@@ -141,6 +173,9 @@ struct shared_state {
     // Update gap_junction state
     void add_gj_current();
 
+    // Update stimulus state and add current contributions.
+    void add_stimulus_current();
+
     // Return minimum and maximum time value [ms] across cells.
     std::pair<fvm_value_type, fvm_value_type> time_bounds() const;
 
diff --git a/arbor/backends/multicore/stimulus.cpp b/arbor/backends/multicore/stimulus.cpp
deleted file mode 100644
index d6a8c139a816c1c97739a52e62415dc9dd66d7ca..0000000000000000000000000000000000000000
--- a/arbor/backends/multicore/stimulus.cpp
+++ /dev/null
@@ -1,68 +0,0 @@
-#include <cmath>
-
-#include <arbor/fvm_types.hpp>
-
-#include "backends/builtin_mech_proto.hpp"
-#include "backends/multicore/mechanism.hpp"
-
-namespace arb {
-
-namespace multicore {
-class stimulus: public arb::multicore::mechanism {
-public:
-    const mechanism_fingerprint& fingerprint() const override {
-        static mechanism_fingerprint hash = "##builtin_stimulus";
-        return hash;
-    }
-    std::string internal_name() const override { return "_builtin_stimulus"; }
-    mechanismKind kind() const override { return ::arb::mechanismKind::point; }
-    mechanism_ptr clone() const override { return mechanism_ptr(new stimulus()); }
-
-    void init() override {}
-    void advance_state() override {}
-    void compute_currents() override {
-        size_type n = size();
-        for (size_type i=0; i<n; ++i) {
-            auto cv = node_index_[i];
-            auto t = vec_t_[vec_di_[cv]];
-
-            if (t>=delay[i] && t<delay[i]+duration[i]) {
-                // Amplitudes are given as a current into a compartment, so subtract.
-                vec_i_[cv] -= weight_[i]*amplitude[i];
-            }
-        }
-    }
-    void write_ions() override {}
-    void apply_events(deliverable_event_stream::state events) override {}
-
-protected:
-    std::size_t object_sizeof() const override { return sizeof(*this); }
-
-    mechanism_field_table field_table() override {
-        return {
-            {"delay", &delay},
-            {"duration", &duration},
-            {"amplitude", &amplitude}
-        };
-    }
-
-    mechanism_field_default_table field_default_table() override {
-        return {
-            {"delay", 0},
-            {"duration", 0},
-            {"amplitude", 0}
-        };
-    }
-private:
-    fvm_value_type* delay;
-    fvm_value_type* duration;
-    fvm_value_type* amplitude;
-};
-} // namespace multicore
-
-template <>
-concrete_mech_ptr<multicore::backend> make_builtin_stimulus() {
-    return concrete_mech_ptr<multicore::backend>(new arb::multicore::stimulus());
-}
-
-} // namespace arb
diff --git a/arbor/builtin_mechanisms.cpp b/arbor/builtin_mechanisms.cpp
deleted file mode 100644
index 77a288593964e54f1e6403f2b959f133e0a50a4d..0000000000000000000000000000000000000000
--- a/arbor/builtin_mechanisms.cpp
+++ /dev/null
@@ -1,35 +0,0 @@
-#include <arbor/mechcat.hpp>
-
-#include "backends/builtin_mech_proto.hpp"
-
-#include "backends/multicore/fvm.hpp"
-#if ARB_HAVE_GPU
-#include "backends/gpu/fvm.hpp"
-#endif
-
-namespace arb {
-
-template <typename B>
-concrete_mech_ptr<B> make_builtin_stimulus();
-
-mechanism_catalogue build_builtin_mechanisms() {
-    mechanism_catalogue cat;
-
-    cat.add("_builtin_stimulus", builtin_stimulus_info());
-
-    cat.register_implementation("_builtin_stimulus", make_builtin_stimulus<multicore::backend>());
-
-#if ARB_HAVE_GPU
-    cat.register_implementation("_builtin_stimulus", make_builtin_stimulus<gpu::backend>());
-#endif
-
-    return cat;
-}
-
-const mechanism_catalogue& builtin_mechanisms() {
-    static mechanism_catalogue cat = build_builtin_mechanisms();
-    return cat;
-}
-
-} // namespace arb
-
diff --git a/arbor/builtin_mechanisms.hpp b/arbor/builtin_mechanisms.hpp
deleted file mode 100644
index 1a6688e3821ca39f896c29cf7ddfc48d49c3470e..0000000000000000000000000000000000000000
--- a/arbor/builtin_mechanisms.hpp
+++ /dev/null
@@ -1,9 +0,0 @@
-#pragma once
-
-#include <arbor/mechcat.hpp>
-
-namespace arb {
-
-const mechanism_catalogue& builtin_mechanisms();
-
-} // namespace arb
diff --git a/arbor/fvm_layout.cpp b/arbor/fvm_layout.cpp
index e97a6e4d251b9ab667028d261f688be8450d17a5..4f19daa7a6e6bbdbd6568a33ee40c87106dd5fa8 100644
--- a/arbor/fvm_layout.cpp
+++ b/arbor/fvm_layout.cpp
@@ -750,6 +750,12 @@ fvm_mechanism_data& append(fvm_mechanism_data& left, const fvm_mechanism_data& r
         }
     }
 
+    append(left.stimuli.cv, right.stimuli.cv);
+    append(left.stimuli.cv_unique, right.stimuli.cv_unique);
+    append(left.stimuli.frequency, right.stimuli.frequency);
+    append(left.stimuli.envelope_time, right.stimuli.envelope_time);
+    append(left.stimuli.envelope_amplitude, right.stimuli.envelope_amplitude);
+
     left.n_target += right.n_target;
     left.post_events |= right.post_events;
 
@@ -1076,6 +1082,7 @@ fvm_mechanism_data fvm_build_mechanism_data(const cable_cell_global_properties&
 
     if (!cell.stimuli().empty()) {
         const auto& stimuli = cell.stimuli();
+        fvm_stimulus_config config;
 
         std::vector<size_type> stimuli_cv;
         assign_by(stimuli_cv, stimuli, [&D, cell_idx](auto& p) {
@@ -1085,19 +1092,38 @@ fvm_mechanism_data fvm_build_mechanism_data(const cable_cell_global_properties&
         assign(cv_order, count_along(stimuli));
         sort_by(cv_order, [&](size_type i) { return stimuli_cv[i]; });
 
-        fvm_mechanism_config config;
-        config.kind = mechanismKind::point;
-        // (param_values entries must be ordered by parameter name)
-        config.param_values = {{"amplitude", {}}, {"delay", {}}, {"duration", {}}};
+        std::size_t n = stimuli.size();
+        config.cv.reserve(n);
+        config.cv_unique.reserve(n);
+        config.frequency.reserve(n);
+        config.envelope_time.reserve(n);
+        config.envelope_amplitude.reserve(n);
 
         for (auto i: cv_order) {
-            config.cv.push_back(stimuli_cv[i]);
-            config.param_values[0].second.push_back(stimuli[i].item.amplitude);
-            config.param_values[1].second.push_back(stimuli[i].item.delay);
-            config.param_values[2].second.push_back(stimuli[i].item.duration);
+            const i_clamp& stim = stimuli[i].item;
+            auto cv = stimuli_cv[i];
+            double cv_area_scale = 1000./D.cv_area[cv]; // constant scales from nA/µm² to A/m².
+
+            config.cv.push_back(cv);
+            config.frequency.push_back(stim.frequency);
+
+            std::size_t envl_n = stim.envelope.size();
+            std::vector<double> envl_t, envl_a;
+            envl_t.reserve(envl_n);
+            envl_a.reserve(envl_n);
+
+            for (auto [t, a]: stim.envelope) {
+                envl_t.push_back(t);
+                envl_a.push_back(a*cv_area_scale);
+            }
+            config.envelope_time.push_back(std::move(envl_t));
+            config.envelope_amplitude.push_back(std::move(envl_a));
         }
 
-        M.mechanisms["_builtin_stimulus"] = std::move(config);
+        std::unique_copy(config.cv.begin(), config.cv.end(), std::back_inserter(config.cv_unique));
+        config.cv_unique.shrink_to_fit();
+
+        M.stimuli = std::move(config);
     }
 
     // Ions:
diff --git a/arbor/fvm_layout.hpp b/arbor/fvm_layout.hpp
index cdc7c4dae5c8f84dd0d085b5e9f101a559876653..403d52d4b28eea81e2b536c70d2e92ef2a5c4146 100644
--- a/arbor/fvm_layout.hpp
+++ b/arbor/fvm_layout.hpp
@@ -265,7 +265,23 @@ struct fvm_ion_config {
 
     // Ion-specific (initial) reversal potential per CV.
     std::vector<value_type> init_revpot;
+};
+
+struct fvm_stimulus_config {
+    using value_type = fvm_value_type;
+    using index_type = fvm_index_type;
 
+    // CV index for each stimulus instance; monotonically increasing.
+    std::vector<index_type> cv;
+
+    // CV indices where one or more stimuli are present; strictly monotonically increasing.
+    std::vector<index_type> cv_unique;
+
+    // Frequency, amplitude info, per instance.
+    // Note that amplitudes have been scaled by 1/CV area so that they are represent as current densities, not currents.
+    std::vector<double> frequency; // [Hz]
+    std::vector<std::vector<double>> envelope_time;      // [ms]
+    std::vector<std::vector<double>> envelope_amplitude; // [A/m²]
 };
 
 struct fvm_mechanism_data {
@@ -275,6 +291,9 @@ struct fvm_mechanism_data {
     // Ion config, indexed by ion name.
     std::unordered_map<std::string, fvm_ion_config> ions;
 
+    // Stimulus config.
+    fvm_stimulus_config stimuli;
+
     // Total number of targets (point-mechanism points).
     std::size_t n_target = 0;
 
diff --git a/arbor/fvm_lowered_cell.hpp b/arbor/fvm_lowered_cell.hpp
index cd480f5917e859ec661fcf5fd298e55c74524aac..3e857f8a4d98e044abd096259ca5dacbc7035c4f 100644
--- a/arbor/fvm_lowered_cell.hpp
+++ b/arbor/fvm_lowered_cell.hpp
@@ -84,9 +84,24 @@ struct fvm_probe_weighted_multi {
     util::any_ptr get_metadata_ptr() const { return &metadata; }
 };
 
+struct fvm_probe_interpolated_multi {
+    std::vector<probe_handle> raw_handles; // First half take coef[0], second half coef[1].
+    std::vector<double> coef[2];
+    mcable_list metadata;
+
+    void shrink_to_fit() {
+        raw_handles.shrink_to_fit();
+        coef[0].shrink_to_fit();
+        coef[1].shrink_to_fit();
+        metadata.shrink_to_fit();
+    }
+
+    util::any_ptr get_metadata_ptr() const { return &metadata; }
+};
+
 // Trans-membrane currents require special handling!
 struct fvm_probe_membrane_currents {
-    std::vector<probe_handle> raw_handles; // Voltage per CV.
+    std::vector<probe_handle> raw_handles; // Voltage per CV, followed by stim current densities.
     std::vector<mcable> metadata;          // Cables from each CV, in CV order.
 
     std::vector<unsigned> cv_parent;       // Parent CV index for each CV.
@@ -94,6 +109,9 @@ struct fvm_probe_membrane_currents {
     std::vector<double> weight;            // Area of cable : area of CV.
     std::vector<unsigned> cv_cables_divs;  // Partitions metadata by CV index.
 
+    std::vector<double> stim_scale;        // CV area for scaling raw stim current densities.
+    std::vector<unsigned> stim_cv;         // CV index corresponding to each stim raw handle.
+
     void shrink_to_fit() {
         raw_handles.shrink_to_fit();
         metadata.shrink_to_fit();
@@ -101,6 +119,8 @@ struct fvm_probe_membrane_currents {
         cv_parent_cond.shrink_to_fit();
         weight.shrink_to_fit();
         cv_cables_divs.shrink_to_fit();
+        stim_scale.shrink_to_fit();
+        stim_cv.shrink_to_fit();
     }
 
     util::any_ptr get_metadata_ptr() const { return &metadata; }
@@ -120,6 +140,7 @@ struct fvm_probe_data {
     fvm_probe_data(fvm_probe_interpolated p): info(std::move(p)) {}
     fvm_probe_data(fvm_probe_multi p): info(std::move(p)) {}
     fvm_probe_data(fvm_probe_weighted_multi p): info(std::move(p)) {}
+    fvm_probe_data(fvm_probe_interpolated_multi p): info(std::move(p)) {}
     fvm_probe_data(fvm_probe_membrane_currents p): info(std::move(p)) {}
 
     std::variant<
@@ -128,6 +149,7 @@ struct fvm_probe_data {
         fvm_probe_interpolated,
         fvm_probe_multi,
         fvm_probe_weighted_multi,
+        fvm_probe_interpolated_multi,
         fvm_probe_membrane_currents
     > info = missing_probe_info{};
 
diff --git a/arbor/fvm_lowered_cell_impl.hpp b/arbor/fvm_lowered_cell_impl.hpp
index d5126cf0cb10888fd9d198befa53ece58c386339..6086383fcf2483e62d512fc5a27dfabda90aafc9 100644
--- a/arbor/fvm_lowered_cell_impl.hpp
+++ b/arbor/fvm_lowered_cell_impl.hpp
@@ -23,7 +23,6 @@
 #include <arbor/recipe.hpp>
 #include <arbor/util/any_visitor.hpp>
 
-#include "builtin_mechanisms.hpp"
 #include "execution_context.hpp"
 #include "fvm_layout.hpp"
 #include "fvm_lowered_cell.hpp"
@@ -261,6 +260,15 @@ fvm_integration_result fvm_lowered_cell_impl<Backend>::integrate(
         state_->set_dt();
         PL();
 
+        // Add stimulus current contributions.
+        // (Note: performed after dt, time_to calculation, in case we
+        // want to use mean current contributions as opposed to point
+        // sample.)
+
+        PE(advance_integrate_stimuli)
+        state_->add_stimulus_current();
+        PL();
+
         // Take samples at cell time if sample time in this step interval.
 
         PE(advance_integrate_samples);
@@ -428,8 +436,7 @@ void fvm_lowered_cell_impl<Backend>::initialize(
 
     // Mechanism instantiator helper.
     auto mech_instance = [&catalogue](const std::string& name) {
-        auto cat = builtin_mechanisms().has(name)? &builtin_mechanisms(): catalogue;
-        return cat->instance<backend>(name);
+        return catalogue->instance<backend>(name);
     };
 
     // Check for physically reasonable membrane volages?
@@ -486,7 +493,7 @@ void fvm_lowered_cell_impl<Backend>::initialize(
                 D.init_membrane_potential, D.temperature_K, D.diam_um, std::move(src_to_spike),
                 data_alignment? data_alignment: 1u);
 
-    // Instantiate mechanisms and ions.
+    // Instantiate mechanisms, ions, and stimuli.
 
     for (auto& i: mech_data.ions) {
         const std::string& ion_name = i.first;
@@ -499,6 +506,10 @@ void fvm_lowered_cell_impl<Backend>::initialize(
         }
     }
 
+    if (!mech_data.stimuli.cv.empty()) {
+        state_->configure_stimulus(mech_data.stimuli);
+    }
+
     target_handles.resize(mech_data.n_target);
 
     // Keep track of mechanisms by name for probe lookup.
@@ -530,7 +541,6 @@ void fvm_lowered_cell_impl<Backend>::initialize(
                 auto cv = layout.cv[i];
                 layout.weight[i] = 1000/D.cv_area[cv];
 
-                // (builtin stimulus, for example, has no targets)
                 if (config.target.empty()) continue;
 
                 target_handle handle(mech_id, i, cv_to_intdom[cv]);
@@ -775,6 +785,7 @@ void fvm_lowered_cell_impl<Backend>::resolve_probe_address(
         cable_probe_total_ion_current_density,
         cable_probe_total_ion_current_cell,
         cable_probe_total_current_cell,
+        cable_probe_stimulus_current_cell,
         cable_probe_density_state,
         cable_probe_density_state_cell,
         cable_probe_point_state,
@@ -841,28 +852,44 @@ void resolve_probe(const cable_probe_axial_current& p, probe_resolution_data<B>&
 
 template <typename B>
 void resolve_probe(const cable_probe_total_ion_current_density& p, probe_resolution_data<B>& R) {
+    // Use interpolated probe with coeffs 1, -1 to represent difference between accumulated current density and stimulus.
     for (mlocation loc: thingify(p.locations, R.cell.provider())) {
-        R.result.push_back(fvm_probe_scalar{
-            {R.state->current_density.data() + R.D.geometry.location_cv(R.cell_idx, loc, cv_prefer::cv_nonempty)},
+        fvm_index_type cv = R.D.geometry.location_cv(R.cell_idx, loc, cv_prefer::cv_nonempty);
+        const double* current_cv_ptr = R.state->current_density.data() + cv;
+
+        auto opt_i = util::binary_search_index(R.M.stimuli.cv_unique, cv);
+        const double* stim_cv_ptr = opt_i? R.state->stim_data.accu_stim_.data()+*opt_i: nullptr;
+
+        R.result.push_back(fvm_probe_interpolated{
+            {current_cv_ptr, stim_cv_ptr},
+            {1., -1.},
             loc});
     }
 }
 
 template <typename B>
 void resolve_probe(const cable_probe_total_ion_current_cell& p, probe_resolution_data<B>& R) {
-    fvm_probe_weighted_multi r;
+    fvm_probe_interpolated_multi r;
+    std::vector<const double*> stim_handles;
 
     for (auto cv: R.D.geometry.cell_cvs(R.cell_idx)) {
-        const double* ptr = R.state->current_density.data()+cv;
+        const double* current_cv_ptr = R.state->current_density.data()+cv;
+        auto opt_i = util::binary_search_index(R.M.stimuli.cv_unique, cv);
+        const double* stim_cv_ptr = opt_i? R.state->stim_data.accu_stim_.data()+*opt_i: nullptr;
+
         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.raw_handles.push_back(current_cv_ptr);
+                stim_handles.push_back(stim_cv_ptr);
+                r.coef[0].push_back(0.001*area); // Scale from [µm²·A/m²] to [nA].
+                r.coef[1].push_back(-r.coef[0].back());
                 r.metadata.push_back(cable);
             }
         }
     }
+
+    util::append(r.raw_handles, stim_handles);
     r.shrink_to_fit();
     R.result.push_back(std::move(r));
 }
@@ -878,6 +905,9 @@ void resolve_probe(const cable_probe_total_current_cell& p, probe_resolution_dat
         [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));
 
+    const auto& stim_cvs = R.M.stimuli.cv_unique;
+    const fvm_value_type* stim_src = R.state->stim_data.accu_stim_.data();
+
     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);
@@ -892,6 +922,39 @@ void resolve_probe(const cable_probe_total_current_cell& p, probe_resolution_dat
         }
         r.cv_cables_divs.push_back(r.metadata.size());
     }
+    for (auto cv: R.D.geometry.cell_cvs(R.cell_idx)) {
+        auto opt_i = util::binary_search_index(stim_cvs, cv);
+        if (!opt_i) continue;
+
+        r.raw_handles.push_back(stim_src+*opt_i);
+        r.stim_cv.push_back(cv-cv0);
+        r.stim_scale.push_back(0.001*R.D.cv_area[cv]); // Scale from [µm²·A/m²] to [nA].
+    }
+    r.shrink_to_fit();
+    R.result.push_back(std::move(r));
+}
+
+template <typename B>
+void resolve_probe(const cable_probe_stimulus_current_cell& p, probe_resolution_data<B>& R) {
+    fvm_probe_weighted_multi r;
+
+    const auto& stim_cvs = R.M.stimuli.cv_unique;
+    const fvm_value_type* src = R.state->stim_data.accu_stim_.data();
+
+    for (auto cv: R.D.geometry.cell_cvs(R.cell_idx)) {
+        auto opt_i = util::binary_search_index(stim_cvs, cv);
+        const double* ptr = opt_i? src+*opt_i: nullptr;
+
+        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);
+            }
+        }
+    }
+
     r.shrink_to_fit();
     R.result.push_back(std::move(r));
 }
diff --git a/arbor/include/arbor/cable_cell.hpp b/arbor/include/arbor/cable_cell.hpp
index 46468c903e4c0a86c4dc71777263b98ce83fee95..3185cf0f240f86cba756432da3af1b6e68555273 100644
--- a/arbor/include/arbor/cable_cell.hpp
+++ b/arbor/include/arbor/cable_cell.hpp
@@ -84,7 +84,7 @@ struct cable_probe_axial_current {
     locset locations;
 };
 
-// Total current density [A/m²] across membrane _excluding_ capacitive current at `location`.
+// Total current density [A/m²] across membrane _excluding_ capacitive and stimulus current at `location`.
 // Sample value type: `cable_sample_range`
 // Sample metadata type: `mlocation`
 struct cable_probe_total_ion_current_density {
@@ -96,11 +96,16 @@ struct cable_probe_total_ion_current_density {
 // Sample metadata type: `mcable_list`
 struct cable_probe_total_ion_current_cell {};
 
-// Total membrane current [nA] across components of the cell.
+// Total membrane current [nA] across components of the cell _excluding_ stimulus currents.
 // Sample value type: `cable_sample_range`
 // Sample metadata type: `mcable_list`
 struct cable_probe_total_current_cell {};
 
+// Stimulus currents [nA] across components of the cell.
+// Sample value type: `cable_sample_range`
+// Sample metadata type: `mcable_list`
+struct cable_probe_stimulus_current_cell {};
+
 // Value of state variable `state` in density mechanism `mechanism` in CV at `location`.
 // Sample value type: `double`
 // Sample metadata type: `mlocation`
diff --git a/arbor/include/arbor/cable_cell_param.hpp b/arbor/include/arbor/cable_cell_param.hpp
index edd9a0427418d3d3049068d522473559465c7c39..e7f5de4cf1cd3a69e83f430d5e9b8b67232eda0b 100644
--- a/arbor/include/arbor/cable_cell_param.hpp
+++ b/arbor/include/arbor/cable_cell_param.hpp
@@ -33,19 +33,48 @@ struct cable_cell_ion_data {
     std::optional<double> init_reversal_potential;
 };
 
-// Current clamp description for stimulus specification.
+// Clamp current is described by a sine wave with amplitude governed by a
+// piecewise linear envelope. A frequency of zero indicates that the current is
+// simply that given by the envelope.
+//
+// The envelope is given by a series of envelope_point values:
+// * The time points must be monotonically increasing.
+// * Onset and initial amplitude is given by the first point.
+// * The amplitude for time after the last time point is that of the last
+//   amplitgude point; an explicit zero amplitude point must be provided if the
+//    envelope is intended to have finite support.
+//
+// Periodic envelopes are not supported, but may well be a feature worth
+// considering in the future.
+
 struct i_clamp {
-    using value_type = double;
+    struct envelope_point {
+        double t;         // [ms]
+        double amplitude; // [nA]
+    };
 
-    value_type delay = 0;      // [ms]
-    value_type duration = 0;   // [ms]
-    value_type amplitude = 0;  // [nA]
+    std::vector<envelope_point> envelope;
+    double frequency = 0; // [Hz] 0 => constant
 
+    // A default constructed i_clamp, with empty envelope, describes
+    // a trivial stimulus, providing no current at all.
     i_clamp() = default;
 
-    i_clamp(value_type delay, value_type duration, value_type amplitude):
-        delay(delay), duration(duration), amplitude(amplitude)
+    // The simple constructor describes a constant amplitude stimulus starting from t=0.
+    explicit i_clamp(double amplitude, double frequency = 0):
+        envelope({{0., amplitude}}),
+        frequency(frequency)
     {}
+
+    // Describe a stimulus by envelope and frequency.
+    explicit i_clamp(std::vector<envelope_point> envelope, double frequency = 0):
+        envelope(std::move(envelope)),
+        frequency(frequency)
+    {}
+
+    // A 'box' stimulus with fixed onset time, duration, and constant amplitude.
+    i_clamp(double onset, double duration, double amplitude, double frequency = 0):
+        i_clamp({{onset, amplitude}, {onset+duration, amplitude}, {onset+duration, 0.}}, frequency) {}
 };
 
 // Threshold detector description.
diff --git a/arbor/include/arbor/mechcat.hpp b/arbor/include/arbor/mechcat.hpp
index c10b6f4bd96fc1ada8ad2830e35cf88ad323c309..876e548243ec4f88120ec59eb0c501d7fc7d4cda 100644
--- a/arbor/include/arbor/mechcat.hpp
+++ b/arbor/include/arbor/mechcat.hpp
@@ -27,8 +27,7 @@
 // after any modification to the catalogue.
 //
 // There is in addition a global default mechanism catalogue object that is
-// populated with any builtin mechanisms and mechanisms generated from
-// module files included with arbor.
+// populated with any mechanisms generated from module files included with arbor.
 //
 // When a mechanism name of the form "mech/param=value,..." is requested, if the
 // mechanism of that name does not already exist in the catalogue, it will be
diff --git a/arbor/mc_cell_group.cpp b/arbor/mc_cell_group.cpp
index ba77e032e51673045b3bb972d00aee2e5979b62e..2a013afe696e8f1668900a84a8bce36631f365a5 100644
--- a/arbor/mc_cell_group.cpp
+++ b/arbor/mc_cell_group.cpp
@@ -241,6 +241,53 @@ void run_samples(
     sc.sampler({sc.probe_id, sc.tag, sc.index, p.get_metadata_ptr()}, n_sample, sample_records.data());
 }
 
+void run_samples(
+    const fvm_probe_interpolated_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();
+    const sample_size_type n_interp_per_sample = 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);
+    arb_assert((unsigned)n_interp_per_sample==p.coef[0].size());
+    arb_assert((unsigned)n_interp_per_sample==p.coef[1].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_interp_per_sample*n_sample);
+
+    for (sample_size_type j = 0; j<n_sample; ++j) {
+        auto offset = j*n_raw_per_sample+sc.begin_offset;
+        const auto* raw_a = raw_samples + offset;
+        const auto* raw_b = raw_a + n_interp_per_sample;
+        for (sample_size_type i = 0; i<n_interp_per_sample; ++i) {
+            tmp.push_back(raw_a[i]*p.coef[0][i]+raw_b[i]*p.coef[1][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_interp_per_sample});
+        tmp_ptr += n_interp_per_sample;
+    }
+
+    const auto& csample_ranges = sample_ranges;
+    for (sample_size_type j = 0; j<n_sample; ++j) {
+        auto offset = j*n_interp_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, sc.index, p.get_metadata_ptr()}, n_sample, sample_records.data());
+}
+
 void run_samples(
     const fvm_probe_membrane_currents& p,
     const sampler_call_info& sc,
@@ -256,6 +303,8 @@ void run_samples(
     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);
+    const auto n_stim = p.stim_scale.size();
+    arb_assert(n_stim+n_cv==(unsigned)n_raw_per_sample);
 
     auto& sample_ranges = std::get<std::vector<cable_sample_range>>(scratch);
     sample_ranges.clear();
@@ -291,6 +340,16 @@ void run_samples(
             }
         }
 
+        const double* stim = raw_samples+offset+n_cv;
+        for (auto i: util::make_span(n_stim)) {
+            double cv_stim_I = stim[i]*p.stim_scale[i];
+            unsigned cv = p.stim_cv[i];
+            arb_assert(cv<n_cv);
+
+            for (auto cable_i: util::make_span(cables_by_cv[cv])) {
+                tmp_base[cable_i] -= cv_stim_I*p.weight[cable_i];
+            }
+        }
         sample_ranges.push_back({tmp_base, tmp_base+n_cable});
     }
 
diff --git a/doc/concepts/decor.rst b/doc/concepts/decor.rst
index 2fd773000fe7fc98d7c3e2ca3ee9e91e350f7de1..2177775dca3e2aea59e0b2693879584769576bcc 100644
--- a/doc/concepts/decor.rst
+++ b/doc/concepts/decor.rst
@@ -287,6 +287,37 @@ See :ref:`modelgapjunctions`.
 4. Stimuli
 ~~~~~~~~~~
 
+A current stimulus is a DC or sinusoidal current of fixed frequemcy with a time-varying amplitude
+governed by a piecewise-linear envelope.
+
+The stimulus is described by two parameters: a frequency in Hertz, where a value of zero denotes DC;
+and a sequence of points (*t*\ :sub:`i`\ , *a*\ :sub:`i`\ ) describing the envelope, where the times
+*t*\ :sub:`i` are in milliseconds and the amplitudes *a*\ :sub:`i` are in nanoamperes.
+
+The stimulus starts at the first timepoint *t*\ :sub:`0` with amplitude *a*\ :sub:`0`, and the amplitude
+is then interpolated linearly between successive points. The last envelope point
+(*t*\ :sub:`n`\ , *a*\ :sub:`n`\ ) describes a constant amplitude *a*\ :sub:`n` from
+the time *t*\ :sub:`n` onwards.
+
+Stimulus objects in the C++ and Python interfaces have simple constructors for describing
+constant stimuli and constant amplitude stimuli restricted to a fixed time interval.
+
+.. code-block:: Python
+
+    # Constant stimulus, amplitude 10 nA.
+    decor.place('(root)', arbor.iclamp(10))
+
+    # Constant amplitude 10 nA stimulus at 20 Hz.
+    decor.place('(root)', arbor.iclamp(10, 20))
+
+    # Stimulus at 20 Hz, amplitude 10 nA, for 40 ms starting at t = 30 ms.
+    decor.place('(root)', arbor.iclamp(30, 40, 10, 20))
+
+    # Piecewise linear stimulus with amplitude ranging from 0 nA to 10 nA,
+    # starting at t = 30 ms and stopping at t = 50 ms.
+    decor.place('(root)', arbor.iclamp([(30, 0), (37, 10), (43, 8), (50, 0)])
+
+
 .. _cablecell-probes:
 
 5. Probes
diff --git a/doc/cpp/probe_sample.rst b/doc/cpp/probe_sample.rst
index 7ca2d0b822e35bb54d0cdcf54755801dfa23180f..76e16bfac2599945a6f253363eb8fa86b36eb810 100644
--- a/doc/cpp/probe_sample.rst
+++ b/doc/cpp/probe_sample.rst
@@ -159,7 +159,7 @@ Membrane current density at given locations _excluding_ capacitive currents.
 
     struct cable_probe_total_ion_current_cell {};
 
-Membrane current _excluding_ capacitive currents across components of the cell.
+Membrane current _excluding_ capacitive currents and stimuli 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
@@ -173,7 +173,7 @@ Membrane current _excluding_ capacitive currents across components of the cell.
 
     struct cable_probe_total_current_cell {};
 
-Total membrance current across components of the cell.
+Total membrance current excluding current stimuli 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
@@ -182,6 +182,19 @@ Total membrance current across components of the cell.
 *  Metadata: ``mcable_list``. Each cable in the cable list describes
    the unbranched component for the corresponding sample value.
 
+.. code::
+
+    struct cable_probe_stimulus_current_cell {};
+
+Total stimulus currents applied 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 discretisation. Components of CVs where no stimulus is present
+   will report a corresponding stimulus value of zero.
+
+*  Metadata: ``mcable_list``. Each cable in the cable list describes
+   the unbranched component for the corresponding sample value.
 
 Ion concentration
 ^^^^^^^^^^^^^^^^^
diff --git a/doc/python/probe_sample.rst b/doc/python/probe_sample.rst
index 663682660ee266e060daf76b60a3550e683c6009..a0459d3e33603f112afc74534a0f95e546ba9dbb 100644
--- a/doc/python/probe_sample.rst
+++ b/doc/python/probe_sample.rst
@@ -69,7 +69,7 @@ Total ionic current
    .. py:function:: cable_probe_total_ion_current_cell()
 
    Transmembrane current (nA) _excluding_ capacitive currents across each
-   cable in each CV of the cell discretization.
+   cable in each CV of the cell discretization. Stimulus currents are not included.
 
    Metadata: the list of corresponding :class:`cable` objects.
 
@@ -77,7 +77,14 @@ Total transmembrane current
    .. py:function:: cable_probe_total_current_cell()
 
    Transmembrane current (nA) _including_ capacitive currents across each
-   cable in each CV of the cell discretization.
+   cable in each CV of the cell discretization. Stimulus currents are not included.
+
+   Metadata: the list of corresponding :class:`cable` objects.
+
+Total stimulus current
+   .. py:function:: cable_probe_stimulus_current_cell()
+
+   Total stimulus current (nA) across each cable in each CV of the cell discretization.
 
    Metadata: the list of corresponding :class:`cable` objects.
 
diff --git a/python/cable_probes.cpp b/python/cable_probes.cpp
index fb7e1be401523bb6dc95a08a2e5c7188a5aa0683..a8543a07e4ca570bc02792b432a0ebc88ab340b6 100644
--- a/python/cable_probes.cpp
+++ b/python/cable_probes.cpp
@@ -157,6 +157,10 @@ arb::probe_info cable_probe_total_current_cell() {
     return arb::cable_probe_total_current_cell{};
 }
 
+arb::probe_info cable_probe_stimulus_current_cell() {
+    return arb::cable_probe_stimulus_current_cell{};
+}
+
 arb::probe_info cable_probe_density_state(const char* where, const char* mechanism, const char* state) {
     return arb::cable_probe_density_state{arb::locset(where), mechanism, state};
 };
@@ -243,12 +247,15 @@ void register_cable_probes(pybind11::module& m, pyarb_global_ptr global_ptr) {
     m.def("cable_probe_total_current_cell", &cable_probe_total_current_cell,
         "Probe specification for cable cell total transmembrane current for each cable in each CV.");
 
+    m.def("cable_probe_stimulus_current_cell", &cable_probe_stimulus_current_cell,
+        "Probe specification for cable cell stimulus current across each cable in each CV.");
+
     m.def("cable_probe_density_state", &cable_probe_density_state,
         "Probe specification for a cable cell density mechanism state variable at points in a location set.",
         "where"_a, "mechanism"_a, "state"_a);
 
     m.def("cable_probe_density_state_cell", &cable_probe_density_state_cell,
-        "Probe specification for a cable cell density mechanism state variable on each cable in each CV.",
+        "Probe specification for a cable cell density mechanism state variable on each cable in each CV where defined.",
         "mechanism"_a, "state"_a);
 
     m.def("cable_probe_point_state", &cable_probe_point_state,
diff --git a/python/cells.cpp b/python/cells.cpp
index 5788bb86b437befab6ecfd356aa051965d64e922..76afd9e5fe18fc2969968ccd58c7f9e72ef27fa8 100644
--- a/python/cells.cpp
+++ b/python/cells.cpp
@@ -285,20 +285,42 @@ void register_cells(pybind11::module& m) {
     // arb::i_clamp
 
     pybind11::class_<arb::i_clamp> i_clamp(m, "iclamp",
-        "A current clamp, for injecting a single pulse of current with fixed duration and current.");
+        "A current clamp for injecting a DC or fixed frequency current governed by a piecewise linear envelope.");
     i_clamp
         .def(pybind11::init(
-                [](double ts, double dur, double cur) {
-                    return arb::i_clamp{ts, dur, cur};
-                }), "tstart"_a=0, "duration"_a=0, "current"_a=0)
-        .def_readonly("tstart", &arb::i_clamp::delay,       "Time at which current starts [ms]")
-        .def_readonly("duration", &arb::i_clamp::duration,  "Duration of the current injection [ms]")
-        .def_readonly("current", &arb::i_clamp::amplitude,  "Amplitude of the injected current [nA]")
-        .def("__repr__", [](const arb::i_clamp& c){
-            return util::pprintf("(iclamp {} {} {})", c.delay, c.duration, c.amplitude);})
-        .def("__str__", [](const arb::i_clamp& c){
-            return util::pprintf("<arbor.iclamp: tstart {} ms, duration {} ms, current {} nA>",
-                                 c.delay, c.duration, c.amplitude);});
+                [](double ts, double dur, double cur, double frequency) {
+                    return arb::i_clamp{ts, dur, cur, frequency};
+                }), "tstart"_a, "duration"_a, "current"_a, "frequency"_a=0,
+                "Construct finite duration current clamp, constant amplitude")
+        .def(pybind11::init(
+                [](double cur, double frequency) {
+                    return arb::i_clamp{cur, frequency};
+                }), "current"_a, "frequency"_a=0,
+                "Construct constant amplitude current clamp")
+        .def(pybind11::init(
+                [](std::vector<std::pair<double, double>> envl, double frequency) {
+                    arb::i_clamp clamp;
+                    for (const auto& p: envl) {
+                        clamp.envelope.push_back({p.first, p.second});
+                    }
+                    clamp.frequency = frequency;
+                    return clamp;
+                }), "envelope"_a, "frequency"_a=0,
+                "Construct current clamp according to (time, amplitude) linear envelope")
+        .def_property_readonly("envelope",
+                [](const arb::i_clamp& obj) {
+                    std::vector<std::pair<double, double>> envl;
+                    for (const auto& p: obj.envelope) {
+                        envl.push_back({p.t, p.amplitude});
+                    }
+                    return envl;
+                },
+                "List of (time [ms], amplitude [nA]) points comprising the piecewise linear envelope")
+        .def_readonly("frequency", &arb::i_clamp::frequency, "Oscillation frequency [Hz], zero implies DC stimulus.")
+        .def("__repr__", [](const arb::i_clamp& c) {
+            return util::pprintf("<arbor.iclamp: frequency {} Hz>", c.frequency);})
+        .def("__str__", [](const arb::i_clamp& c) {
+            return util::pprintf("<arbor.iclamp: frequency {} Hz>", c.frequency);});
 
     // arb::threshold_detector
 
diff --git a/python/test/unit/test_cable_probes.py b/python/test/unit/test_cable_probes.py
index 7d8c663dda8e4cdc2c74afa45b0ba9f31da1562b..8a39d3cfff1ad3a45224e39663b424b5dc2c01db 100644
--- a/python/test/unit/test_cable_probes.py
+++ b/python/test/unit/test_cable_probes.py
@@ -29,6 +29,7 @@ class cc_recipe(A.recipe):
 
         dec.place('(location 0 0.08)', "expsyn")
         dec.place('(location 0 0.09)', "exp2syn")
+        dec.place('(location 0 0.1)', A.iclamp(20.))
         dec.paint('(all)', "hh")
 
         self.cell = A.cable_cell(st, A.label_dict(), dec)
@@ -89,6 +90,8 @@ class cc_recipe(A.recipe):
             A.cable_probe_ion_ext_concentration(where='(location 0 0.14)', ion='na'),
             # probe id (0, 15)
             A.cable_probe_ion_ext_concentration_cell(ion='na'),
+            # probe id (0, 15)
+            A.cable_probe_stimulus_current_cell()
         ]
 
     def cell_description(self, gid):
@@ -172,6 +175,10 @@ class CableProbes(unittest.TestCase):
         self.assertEqual(1, len(m))
         self.assertEqual(all_cv_cables, m[0])
 
+        m = sim.probe_metadata((0, 16))
+        self.assertEqual(1, len(m))
+        self.assertEqual(all_cv_cables, m[0])
+
 def suite():
     # specify class and test functions in tuple (here: all tests starting with 'test' from class Contexts
     suite = unittest.makeSuite(CableProbes, ('test'))
diff --git a/python/test/unit/test_decor.py b/python/test/unit/test_decor.py
new file mode 100644
index 0000000000000000000000000000000000000000..81fe7e497d530f8138fa70ef23e3ea64a1ef9947
--- /dev/null
+++ b/python/test/unit/test_decor.py
@@ -0,0 +1,61 @@
+# -*- coding: utf-8 -*-
+
+import unittest
+import arbor as A
+
+# to be able to run .py file from child directory
+import sys, os
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')))
+
+try:
+    import options
+except ModuleNotFoundError:
+    from test import options
+
+"""
+Tests for decor and decoration wrappers.
+TODO: Coverage for more than just iclamp.
+"""
+
+class DecorClasses(unittest.TestCase):
+    def test_iclamp(self):
+        # Constant amplitude iclamp:
+        clamp = A.iclamp(10);
+        self.assertEqual(0, clamp.frequency)
+        self.assertEqual([(0, 10)], clamp.envelope)
+
+        clamp = A.iclamp(10, 20);
+        self.assertEqual(20, clamp.frequency)
+        self.assertEqual([(0, 10)], clamp.envelope)
+
+        # Square pulse:
+        clamp = A.iclamp(100, 20, 3);
+        self.assertEqual(0, clamp.frequency)
+        self.assertEqual([(100, 3), (120, 3), (120, 0)], clamp.envelope)
+
+        clamp = A.iclamp(100, 20, 3, 7);
+        self.assertEqual(7, clamp.frequency)
+        self.assertEqual([(100, 3), (120, 3), (120, 0)], clamp.envelope)
+
+        # Explicit envelope:
+        envelope = [(1, 10), (3, 30), (5, 50), (7, 0)]
+        clamp = A.iclamp(envelope);
+        self.assertEqual(0, clamp.frequency)
+        self.assertEqual(envelope, clamp.envelope)
+
+        clamp = A.iclamp(envelope, 7);
+        self.assertEqual(7, clamp.frequency)
+        self.assertEqual(envelope, clamp.envelope)
+
+def suite():
+    # specify class and test functions in tuple (here: all tests starting with 'test' from class Contexts
+    suite = unittest.makeSuite(DecorClasses, ('test'))
+    return suite
+
+def run():
+    v = options.parse_arguments().verbosity
+    runner = unittest.TextTestRunner(verbosity = v)
+    runner.run(suite())
+
+if __name__ == "__main__":
+    run()
diff --git a/test/unit/test_fvm_lowered.cpp b/test/unit/test_fvm_lowered.cpp
index e4a5635c38e8c2fcc42e3896efa43f4984238df1..5d11cff324a497ea27a99cfd47a81350eda2e1c1 100644
--- a/test/unit/test_fvm_lowered.cpp
+++ b/test/unit/test_fvm_lowered.cpp
@@ -1,3 +1,4 @@
+#include <cmath>
 #include <string>
 #include <vector>
 
@@ -353,9 +354,6 @@ TEST(fvm_lowered, stimulus) {
     const fvm_size_type soma_cv = 0u;
     const fvm_size_type tip_cv = 5u;
 
-    // now we have two stims :
-    //
-    //
     // The implementation of the stimulus is tested by creating a lowered cell, then
     // testing that the correct currents are injected at the correct control volumes
     // as during the stimulus windows.
@@ -373,10 +371,6 @@ TEST(fvm_lowered, stimulus) {
     fvm_cell fvcell(context);
     fvcell.initialize({0}, cable1d_recipe(cells), cell_to_intdom, targets, probe_map);
 
-    mechanism* stim = find_mechanism(fvcell, "_builtin_stimulus");
-    ASSERT_TRUE(stim);
-    EXPECT_EQ(2u, stim->size());
-
     auto& state = *(fvcell.*private_state_ptr).get();
     auto& J = state.current_density;
     auto& T = state.time;
@@ -384,30 +378,86 @@ TEST(fvm_lowered, stimulus) {
     // Test that no current is injected at t=0.
     memory::fill(J, 0.);
     memory::fill(T, 0.);
-    stim->update_current();
+    state.add_stimulus_current();
 
     for (auto j: J) {
         EXPECT_EQ(j, 0.);
     }
 
+    constexpr double reltol = 1e-10;
+    using testing::near_relative;
+
     // Test that 0.1 nA current is injected at soma at t=1.
     memory::fill(J, 0.);
     memory::fill(T, 1.);
-    stim->update_current();
+    state.add_stimulus_current();
     constexpr double unit_factor = 1e-3; // scale A/m²·µm² to nA
-    EXPECT_DOUBLE_EQ(-0.1, J[soma_cv]*A[soma_cv]*unit_factor);
+    EXPECT_TRUE(near_relative(-0.1, J[soma_cv]*A[soma_cv]*unit_factor, reltol));
 
     // Test that 0.1 nA is again injected at t=1.5, for a total of 0.2 nA.
     memory::fill(T, 1.);
-    stim->update_current();
-    EXPECT_DOUBLE_EQ(-0.2, J[soma_cv]*A[soma_cv]*unit_factor);
+    state.add_stimulus_current();
+    EXPECT_TRUE(near_relative(-0.2, J[soma_cv]*A[soma_cv]*unit_factor, reltol));
 
     // Test that at t=10, no more current is injected at soma, and that
     // that 0.3 nA is injected at dendrite tip.
     memory::fill(T, 10.);
-    stim->update_current();
-    EXPECT_DOUBLE_EQ(-0.2, J[soma_cv]*A[soma_cv]*unit_factor);
-    EXPECT_DOUBLE_EQ(-0.3, J[tip_cv]*A[tip_cv]*unit_factor);
+    state.add_stimulus_current();
+    EXPECT_TRUE(near_relative(-0.2, J[soma_cv]*A[soma_cv]*unit_factor, reltol));
+    EXPECT_TRUE(near_relative(-0.3, J[tip_cv]*A[tip_cv]*unit_factor, reltol));
+}
+
+TEST(fvm_lowered, ac_stimulus) {
+    // Simple cell (one CV) with oscillating stimulus.
+
+    arb::execution_context context; // Just use default context for this one!
+
+    decor dec;
+    segment_tree tree;
+    tree.append(mnpos, {0., 0., 0., 1.}, {100., 0., 0., 1.}, 1);
+
+    const double freq = 20; // (Hz)
+    const double max_amplitude = 30; // (nA)
+    const double max_time = 8; // (ms)
+
+    // Envelope is linear ramp from 0 to max_time.
+    dec.place(mlocation{0, 0}, i_clamp({{0, 0}, {max_time, max_amplitude}, {max_time, 0}}, freq));
+    std::vector<cable_cell> cells = {cable_cell(tree, {}, dec)};
+
+    cable_cell_global_properties gprop;
+    gprop.default_parameters = neuron_parameter_defaults;
+
+    fvm_cv_discretization D = fvm_cv_discretize(cells, gprop.default_parameters, context);
+    const auto& A = D.cv_area;
+
+    std::vector<target_handle> targets;
+    probe_association_map probe_map;
+    std::vector<fvm_index_type> cell_to_intdom(cells.size(), 0);
+
+    fvm_cell fvcell(context);
+    fvcell.initialize({0}, cable1d_recipe(cells), cell_to_intdom, targets, probe_map);
+
+    auto& state = *(fvcell.*private_state_ptr).get();
+    auto& J = state.current_density;
+    auto& T = state.time;
+
+    // Current from t = 0 to max_time should be given by
+    //     I = max_amplitude * t/max_time * sin(2Ï€ f t)
+    // where t is in ms and f = freq/1000 is the frequency in kHz.
+
+    constexpr double reltol = 1e-10;
+    using testing::near_relative;
+
+    for (double t: {0., 0.1*max_time, 0.7*max_time, 1.1*max_time}) {
+        constexpr double unit_factor = 1e-3; // scale A/m²·µm² to nA
+
+        memory::fill(J, 0.);
+        memory::fill(T, t);
+        state.add_stimulus_current();
+
+        double expected_I = t<=max_time? max_amplitude*t/max_time*std::sin(2*math::pi<double>*t*0.001*freq): 0;
+        EXPECT_TRUE(near_relative(-expected_I, J[0]*A[0]*unit_factor, reltol));
+    }
 }
 
 // Test derived mechanism behaviour.
diff --git a/test/unit/test_probe.cpp b/test/unit/test_probe.cpp
index 867821992a765ad4b050f4dd2e4604096aee66a5..f08a4cf0f34f3a6a82f421a1b2fc07e4944f6926 100644
--- a/test/unit/test_probe.cpp
+++ b/test/unit/test_probe.cpp
@@ -1,6 +1,7 @@
 #include "../gtest.h"
 
 #include <cmath>
+#include <map>
 #include <vector>
 
 #include <arbor/cable_cell.hpp>
@@ -142,17 +143,19 @@ void run_v_i_probe_test(const context& ctx) {
 
     // 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.
+    // implemented as an interpolated probe too, in order to account
+    // for stimulus contributions.
 
     ASSERT_TRUE(std::get_if<fvm_probe_interpolated>(&probe_map.data_on({0, 0}).front().info));
-    ASSERT_TRUE(std::get_if<fvm_probe_interpolated>(&probe_map.data_on({0, 0}).front().info));
-    ASSERT_TRUE(std::get_if<fvm_probe_scalar>(&probe_map.data_on({0, 2}).front().info));
+    ASSERT_TRUE(std::get_if<fvm_probe_interpolated>(&probe_map.data_on({0, 1}).front().info));
+    ASSERT_TRUE(std::get_if<fvm_probe_interpolated>(&probe_map.data_on({0, 2}).front().info));
 
     probe_handle p0a = get_probe_raw_handle({0, 0}, 0);
     probe_handle p0b = get_probe_raw_handle({0, 0}, 1);
     probe_handle p1a = get_probe_raw_handle({0, 1}, 0);
     probe_handle p1b = get_probe_raw_handle({0, 1}, 1);
-    probe_handle p2 = get_probe_raw_handle({0, 2});
+    probe_handle p2a = get_probe_raw_handle({0, 2}, 0);
+    probe_handle p2b = get_probe_raw_handle({0, 2}, 1);
 
     // Ball-and-stick cell with default discretization policy should
     // have three CVs, one for branch 0, one trivial one covering the
@@ -172,7 +175,7 @@ void run_v_i_probe_test(const context& ctx) {
 
     // 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.
+    // zero for the current probe (including stimulus component).
 
     fvm_value_type resting = voltage[0];
     EXPECT_NE(0.0, resting);
@@ -181,7 +184,8 @@ void run_v_i_probe_test(const context& ctx) {
     EXPECT_EQ(resting, deref(p0b));
     EXPECT_EQ(resting, deref(p1a));
     EXPECT_EQ(resting, deref(p1a));
-    EXPECT_EQ(0.0, deref(p2));
+    EXPECT_EQ(0.0, deref(p2a));
+    EXPECT_EQ(0.0, deref(p2b));
 
     // After an integration step, expect voltage probe values
     // to differ from resting, and for there to be a non-zero current.
@@ -192,7 +196,8 @@ void run_v_i_probe_test(const context& ctx) {
     EXPECT_NE(resting, deref(p0b));
     EXPECT_NE(resting, deref(p1a));
     EXPECT_NE(resting, deref(p1b));
-    EXPECT_NE(0.0, deref(p2));
+    EXPECT_NE(0.0, deref(p2a));
+    EXPECT_NE(0.0, deref(p2b));
 }
 
 template <typename Backend>
@@ -770,7 +775,7 @@ void run_partial_density_probe_test(const context& ctx) {
 template <typename Backend>
 void run_axial_and_ion_current_sampled_probe_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.
+    // and the axial currents should balance the stimulus and 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
@@ -802,8 +807,8 @@ void run_axial_and_ion_current_sampled_probe_test(const context& ctx) {
 
     cable_cell cell(m, {}, d);
 
-    // Place axial current probes at CV boundaries and make a cell-wide probe for
-    // total ionic membrane current.
+    // Place axial current probes at CV boundaries and make cell-wide probes for
+    // total ionic membrane current and stimulus currents.
 
     mlocation_list cv_boundaries;
     util::assign(cv_boundaries,
@@ -818,8 +823,9 @@ void run_axial_and_ion_current_sampled_probe_test(const context& ctx) {
         rec.add_probe(0, 0, cable_probe_axial_current{loc});
     }
 
-    // Use tag 1 to indicate it's a whole-cell probe.
+    // Use tags 1 and 2 for the whole-cell probes.
     rec.add_probe(0, 1, cable_probe_total_ion_current_cell{});
+    rec.add_probe(0, 2, cable_probe_stimulus_current_cell{});
 
     partition_hint_map phints = {
        {cell_kind::cable, {partition_hint::max_size, partition_hint::max_size, true}}
@@ -829,7 +835,7 @@ void run_axial_and_ion_current_sampled_probe_test(const context& 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;
+    std::vector<double> i_memb, i_stim;
 
     sim.add_sampler(all_probes, explicit_schedule({20*tau}),
         [&](probe_metadata pm, std::size_t n_sample, const sample_record* samples)
@@ -837,7 +843,7 @@ void run_axial_and_ion_current_sampled_probe_test(const context& ctx) {
             // Expect exactly one sample.
             ASSERT_EQ(1u, n_sample);
 
-            if (pm.tag==1) { // (whole cell probe)
+            if (pm.tag!=0) { // (whole cell probe)
                 const mcable_list* m = any_cast<const mcable_list*>(pm.meta);
                 ASSERT_NE(nullptr, m);
                 // Metadata should comprise one cable per CV.
@@ -847,8 +853,9 @@ void run_axial_and_ion_current_sampled_probe_test(const context& ctx) {
                 ASSERT_NE(nullptr, s);
                 ASSERT_EQ(s->first+n_cv, s->second);
 
+                auto* i_var = pm.tag==1? &i_memb: &i_stim;
                 for (const double* p = s->first; p!=s->second; ++p) {
-                    i_memb.push_back(*p);
+                    i_var->push_back(*p);
                 }
             }
             else { // axial current probe
@@ -881,7 +888,7 @@ void run_axial_and_ion_current_sampled_probe_test(const context& ctx) {
         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));
+        EXPECT_TRUE(testing::near_relative(net_axial_flux, -i_memb[i]-i_stim[i], 1e-6));
     }
 }
 
@@ -1010,6 +1017,7 @@ void run_v_sampled_probe_test(const context& ctx) {
     EXPECT_NE(trace0[1].v, trace1[1].v);
 }
 
+
 template <typename Backend>
 void run_total_current_probe_test(const context& ctx) {
     // Model two passive Y-shaped cells with a similar but not identical
@@ -1046,6 +1054,7 @@ void run_total_current_probe_test(const context& ctx) {
 
     trace_data<std::vector<double>, mcable_list> traces[2];
     trace_data<std::vector<double>, mcable_list> ion_traces[2];
+    trace_data<std::vector<double>, mcable_list> stim_traces[2];
 
     // Run the cells sampling at Ï„ and 20Ï„ for both total membrane
     // current and total membrane ionic current.
@@ -1053,7 +1062,6 @@ void run_total_current_probe_test(const context& ctx) {
     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.discretization() = policy; }
         d0.set_default(policy);
         d1.set_default(policy);
         std::vector<cable_cell> cells = {{m, {}, d0}, {m, {}, d1}};
@@ -1070,6 +1078,9 @@ void run_total_current_probe_test(const context& ctx) {
             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}).at(0);
 
+            stim_traces[i] = run_simple_sampler<std::vector<double>, mcable_list>(ctx, t_end, cells, i,
+                    cable_probe_stimulus_current_cell{}, {tau, 20*tau}).at(0);
+
             ASSERT_EQ(2u, traces[i].size());
             ASSERT_EQ(2u, ion_traces[i].size());
 
@@ -1083,26 +1094,30 @@ void run_total_current_probe_test(const context& ctx) {
             // same support and same metadata.
 
             ASSERT_EQ((n_cv_per_branch+(int)interior_forks)*n_branch, traces[i].meta.size());
-            EXPECT_EQ(ion_traces[i].meta, traces[i].meta);
+            ASSERT_EQ(ion_traces[i].meta, traces[i].meta);
             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).
+            // Check total membrane currents + stimulus currents are individually non-zero, but sum is,
+            // both at t=Ï„ (j=0) and t=20Ï„ (j=1).
+
+            ASSERT_EQ(ion_traces[i].meta, stim_traces[i].meta);
 
             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;
+                double max_abs_current = 0;
+                double sum_current = 0;
+                for (auto k: util::count_along(traces[i].meta)) {
+                    double current = traces[i][j].v[k] + stim_traces[i][j].v[k];
+
+                    EXPECT_NE(0.0, current);
+                    max_abs_current = std::max(max_abs_current, std::abs(current));
+                    sum_current += current;
                 }
 
-                EXPECT_NEAR(0.0, sum_i_memb, 1e-6*max_abs_i_memb);
+                ASSERT_NEAR(0.0, sum_current, 1e-6*max_abs_current);
             }
 
-            // Confirm that total and ion currents differ at Ï„ but are close at 20Ï„.
+            // Confirm that total (non-stim) 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;
@@ -1134,6 +1149,52 @@ void run_total_current_probe_test(const context& ctx) {
     }
 }
 
+
+template <typename Backend>
+void run_stimulus_probe_test(const context& ctx) {
+    // Model two simple stick cable cells, 3 CVs each, and stimuli on cell 0, cv 1
+    // and cell 1, cv 2. Run both cells in the same cell group.
+
+    const double stim_until = 1.; // [ms]
+    auto m = make_stick_morphology();
+    cv_policy policy = cv_policy_fixed_per_branch(3);
+
+    decor d0, d1;
+    d0.set_default(policy);
+    d0.place(mlocation{0, 0.5}, i_clamp(0., stim_until, 10.));
+    d0.place(mlocation{0, 0.5}, i_clamp(0., stim_until, 20.));
+    double expected_stim0 = 30;
+
+    d1.set_default(policy);
+    d1.place(mlocation{0, 1}, i_clamp(0., stim_until, 30.));
+    d1.place(mlocation{0, 1}, i_clamp(0., stim_until, -10.));
+    double expected_stim1 = 20;
+
+    std::vector<cable_cell> cells = {{m, {}, d0}, {m, {}, d1}};
+
+    // Sample the cells during the stimulus, and after.
+
+    trace_data<std::vector<double>, mcable_list> traces[2];
+
+    for (unsigned i: {0u, 1u}) {
+        traces[i] = run_simple_sampler<std::vector<double>, mcable_list>(ctx, 2.5*stim_until, cells, i,
+                cable_probe_stimulus_current_cell{}, {stim_until/2, 2*stim_until}).at(0);
+
+        ASSERT_EQ(3u, traces[i].meta.size());
+        for (unsigned cv: {0u, 1u, 2u}) {
+            ASSERT_EQ(2u, traces[i].size());
+        }
+    }
+
+    // Every sample in each trace should be zero _except_ the first sample for cell 0, cv 1
+    // and the first sample for cell 1, cv 2.
+
+    EXPECT_EQ((std::vector<double>{0, expected_stim0, 0}), traces[0][0]);
+    EXPECT_EQ((std::vector<double>{0, 0, expected_stim1}), traces[1][0]);
+    EXPECT_EQ((std::vector<double>(3)), traces[0][1]);
+    EXPECT_EQ((std::vector<double>(3)), traces[1][1]);
+}
+
 template <typename Backend>
 void run_exact_sampling_probe_test(const context& ctx) {
     // As the exact sampling implementation interacts with the event delivery
diff --git a/test/unit/test_spikes.cpp b/test/unit/test_spikes.cpp
index d207f586c7adff0180f456ea70126cb83ad57f9e..9ededc628810435abcfb5bc1f591300c22b7f3dc 100644
--- a/test/unit/test_spikes.cpp
+++ b/test/unit/test_spikes.cpp
@@ -22,9 +22,11 @@ using namespace arb;
 #ifndef USE_BACKEND
 using backend = multicore::backend;
 #define SPIKES_TEST_CLASS spikes
+#define GPU_ID -1
 #else
 using backend = USE_BACKEND;
 #define SPIKES_TEST_CLASS spikes_gpu
+#define GPU_ID 0
 #endif
 
 TEST(SPIKES_TEST_CLASS, threshold_watcher) {
@@ -211,7 +213,7 @@ TEST(SPIKES_TEST_CLASS, threshold_watcher_interpolation) {
     dict.set("mid", arb::ls::on_branches(0.5));
 
     arb::proc_allocation resources;
-    resources.gpu_id = arbenv::default_gpu();
+    resources.gpu_id = GPU_ID;
     auto context = arb::make_context(resources);
 
     std::vector<arb::spike> spikes;