Monitors & Stimulus Sources

July 6, 2026 · View on GitHub

Modules: sc_neurocore.network.monitor, sc_neurocore.network.stimulus Source: src/sc_neurocore/network/monitor.py (173 LOC) + src/sc_neurocore/network/stimulus.py (68 LOC) Status (v3.14.0): all six classes (3 monitors + 3 stimuli) wired into Network.run; 21 dedicated monitor/stimulus tests pass. SpikeMonitor has a Rust event-ingestion boundary; StateMonitor and RateMonitor require the Python backend until Rust exports per-step traces.

This page covers the recording side (SpikeMonitor, StateMonitor, RateMonitor) and the stimulus side (TimedArray, PoissonInput, StepCurrent) of the network simulation engine. The orchestrator that drives them lives in api/network.md.


1. Where these classes fit in the simulation loop

Each timestep the network executes (network.py:170-onwards):

zero population currents
─► apply_stimuli(t, dt)              ← stimulus.get_current(t [, dt])
─► apply_projections(last_spikes)
─► step populations → spike vectors
─► record(pop, spikes, t, dt)        ← monitors observe here
─► update_plasticity

apply_stimuli walks network.stimuli and calls get_current(t) (or get_current(t, dt) for the time-aware variants), adds the result to the target population's current accumulator. record walks each monitor list and forwards spikes/state to whichever monitors target each population.

Monitors are passive observers — they never alter dynamics. Stimuli are active sources — they inject current. Both are accepted by Network(*objects) via isinstance dispatch (network.py:63).

Each monitor and stimulus carries a target: Population | None attribute. For stimuli, target=None means "broadcast to populations[0]" (network.py:201). For monitors, the population is set in the constructor and is required.


2. SpikeMonitor

SpikeMonitor(population: Population, label: str | None = None)

Records (neuron_id, timestep) events from a population. Two ingestion paths so the same monitor works for both Python and Rust backends:

MethodCallerInput
record(spikes, t_step)Python backend (Network._record)binary spike vector np.ndarray[int8] shape (n,)
record_event(neuron_id, t_step)Rust backend (Network._run_rust)one decoded (int, int) event per spike

record calls np.nonzero on the spike vector, then appends each (int, int) pair. record_event appends directly. Internal storage is two parallel Python lists _neuron_ids: list[int] and _timesteps: list[int] — kept as lists rather than numpy arrays because appends to numpy arrays are O(n).

2.1 Read-out helpers

Property/methodReturnsWhat it does
spike_timesnp.ndarray[int64] length=countevery spike's timestep
spike_trainsdict[int, np.ndarray[int64]]per-neuron sorted timestep arrays
countinttotal spikes recorded
raster_data()(times: ndarray, ids: ndarray)tuple ready for plt.plot(times, ids, '.')
firing_rates(n_steps, dt)np.ndarray[n] Hzmean rate per neuron over n_steps × dt seconds
isi(neuron)np.ndarray[int64]inter-spike intervals (timestep units) for one neuron
cross_correlation(i, j, max_lag)(corr, lags)delegates to sc_neurocore.analysis.spike_stats.cross_correlation

spike_trains builds the dict each call (no caching) — for very long recordings cache the result.

firing_rates divides total spikes by simulation duration in seconds (n_steps × dt). Returns Hz per neuron with no smoothing — a sharp quantity for short runs.

isi(neuron) returns differences in timestep units, not seconds. Multiply by dt to convert.

cross_correlation(i, j) builds binary spike vectors of length max(spike_times)+1, hands them to the analysis backend, and returns (correlation, lags) arrays of length 2 × max_lag + 1. Empty trains for either neuron return zeros (no exception).

2.2 Example

from sc_neurocore.network import (
    Network, Population, Projection, SpikeMonitor, PoissonInput
)

pop = Population("LapicqueNeuron", n=200)
proj = Projection(pop, pop, weight=0.05, probability=0.2, seed=7)
stim = PoissonInput(n=200, rate_hz=500.0, weight=2.0, seed=11)
mon = SpikeMonitor(pop)

net = Network(pop, proj, stim, mon, seed=1)
net.run(duration=0.2, dt=0.001, backend="python")

# Inspect
print(mon.count, "spikes")               # 782
times, ids = mon.raster_data()
rates_hz = mon.firing_rates(n_steps=200, dt=0.001)
print(rates_hz.mean(), "Hz mean")

3. StateMonitor

StateMonitor(
    population: Population,
    variables: list[str] | None = None,   # default ["v"]
    record: list[int] | None = None,      # default = all neurons
)

Captures snapshots of named state variables every time the network calls monitor.snapshot(t_step) (once per timestep for each attached population).

variables lists state names to record. The monitor reads them by calling population.get_states(), which itself uses (in order):

  1. neurons[0].get_state() if defined
  2. __dataclass_fields__ if the neuron is a dataclass (excluding dt)
  3. fall back to ["v"]

record optionally subsets which neurons to record (saves memory for large populations when only a few neurons matter).

3.1 Read-out

PropertyReturnsShape
tracesdict[str, np.ndarray] keyed by variable name(n_steps, n_recorded) per variable
tnp.ndarray[int64]timestep array, same length as first dim of traces[v]

If record is set, the second axis has length len(record); otherwise it matches population.n.

3.2 Example

from sc_neurocore.network import StateMonitor

mon = StateMonitor(pop, variables=["v"], record=[0, 50, 100, 150, 199])
net = Network(pop, proj, stim, mon, seed=1)
net.run(0.5, dt=0.001)

import matplotlib.pyplot as plt
for i in range(5):
    plt.plot(mon.t * 0.001, mon.traces["v"][:, i], label=f"neuron {[0,50,100,150,199][i]}")
plt.xlabel("time (s)"); plt.ylabel("V (a.u.)"); plt.legend()

4. RateMonitor

RateMonitor(population: Population, bin_ms: int = 10)

Bins spike counts into fixed-duration windows and converts to per-bin mean firing rate (Hz averaged over the population).

Internally each call to record(spikes, t_step, dt) accumulates int(spikes.sum()) and increments a step counter. When the counter reaches steps_per_bin = max(1, int(bin_ms / 1000.0 / dt)), the bin is flushed: the count is appended to _spike_counts, the timestep to _bin_edges, both internal counters reset.

4.1 Output

PropertyReturnsNote
ratenp.ndarray[float64] Hz per bincount / (bin_seconds × population.n)
tnp.ndarray[int64]timestep edges where each bin flushed

Empty rate is returned as np.array([], dtype=float64) if n_steps is shorter than one bin.

4.2 Bin-edge discretisation

steps_per_bin = max(1, int(bin_ms / 1000.0 / dt)) truncates: at dt = 0.001 and bin_ms = 7, steps_per_bin is 7 — matching the nominal bin. At dt = 0.0005 and bin_ms = 10, steps_per_bin is 20 (also matching). At dt = 0.001 and bin_ms = 0 (degenerate), the clamp gives steps_per_bin = 1 — every step becomes a bin.

The flush check is if self._steps_in_bin >= steps_per_bin, so the last incomplete bin is dropped if the simulation ends mid-window.


5. TimedArray — pre-computed time-varying current

TimedArray(values: np.ndarray | list[float], dt: float = 0.001)

Holds a 1-D array of scalar currents. get_current(t_step) returns values[min(t_step, len(values) - 1)]. Past the end of the array the last value is held forever — this matches Brian2's TimedArray clamp semantics.

dt is informational only at present (the network does not resample the stimulus to its own dt); callers must ensure values was built at the network's dt.

import numpy as np
ramp = TimedArray(np.linspace(0, 1.0, 1000), dt=0.001)
ramp.target = pop  # 1-second linear ramp into pop

6. PoissonInput — random Poisson spike train

PoissonInput(n: int, rate_hz: float, weight: float,
             dt: float = 0.001, seed: int = 42)

Each call to get_current(t_step, dt=None) draws n Bernoulli samples with probability rate_hz × dt, multiplies by weight, and returns a weighted current vector. The optional dt argument lets the network pass its own timestep; if omitted the per-stimulus dt is used.

The internal RNG is a np.random.default_rng(seed) — runs are deterministic when seeds are pinned.

# 80 Hz mean, weight 0.5, 100 inputs, deterministic
stim = PoissonInput(n=100, rate_hz=80.0, weight=0.5, dt=0.001, seed=11)
stim.target = pop

For high rate_hz × dt (e.g. > 0.5) the Bernoulli model under-counts true Poisson — switch to a true Poisson draw (rng.poisson(...)) if biological accuracy at high rates matters.


7. StepCurrent — rectangular pulse

StepCurrent(onset: int, offset: int, amplitude: float)

Returns amplitude if onset ≤ t_step < offset, else 0.0. Onset is inclusive, offset exclusive. get_current(t_step, dt=0.001) ignores dt because the gate is purely on t_step.

pulse = StepCurrent(onset=100, offset=200, amplitude=2.0)
pulse.target = pop  # injects 2.0 from step 100 (incl) to 200 (excl)

For sub-step or negative-amplitude shapes, build a TimedArray instead.


8. Performance — recording overhead

Measured on this workstation (Intel i5-11600K, Python 3.12.3) with the network setup from api/network.md §11 (n=500 LapicqueNeuron, recurrent random p=0.2, Poisson 500 Hz w=2.0, 200 steps @ dt=1 ms, 3-run median).

ConfigurationMedian wallΔ vs baseline
baseline (no monitors)203.8 ms
+ SpikeMonitor239.3 ms+35.5 ms (+17 %)
+ StateMonitor(['v'])322.8 ms+119.0 ms (+58 %)
+ RateMonitor(bin_ms=10)238.8 ms+35.0 ms (+17 %)
+ all three296.2 ms+92.4 ms (+45 %)

Observations:

  • StateMonitor is the most expensive because every step it copies the full state-variable array (default population.n entries) into a Python list. For large populations, set record=[indices] to a small subset.
  • SpikeMonitor cost is sparse-driven — it appends one Python (int, int) per spike via np.nonzero + a Python loop. For high firing rates (>100 Hz × n>1000) it dominates.
  • RateMonitor cost is constant per step — one spikes.sum() and a step counter increment. Bin size doesn't affect per-step cost, only the total number of flushes.
  • All three together is sub-additive because their internal work doesn't pay full Python overhead three times — the network's _record pass walks each monitor list once.

These overheads are pure-Python. The Rust backend records SpikeMonitor events via record_event once per spike at the end of the run, but StateMonitor and RateMonitor disqualify Rust dispatch until the engine exports per-step trace data.

8.1 Rust boundary

monitor.py and stimulus.py are intentionally pure-Python data containers. SpikeMonitor.record_event is the only monitor ingestion method called from Rust; it appends decoded (neuron_id, timestep) events after the Rust runner returns. StateMonitor and RateMonitor need per-step observations, so backend="auto" falls back to Python when either is attached and forced backend="rust" raises NotImplementedError.

PoissonInput.get_current does call rng.random(self.n) per step. For very large n (>10 000) at high rate_hz × dt, the Bernoulli draw becomes measurable and could be vectorised in a Rust extension — currently not on the roadmap.


9. Pipeline wiring

SurfaceHow it's wiredVerifier
from sc_neurocore.network import SpikeMonitor, ...network/__init__.py:13-25tests/test_network_monitors_stimulus.py
Network(..., spike_monitor) registrationNetwork.add isinstance chain (network.py:65)TestSpikeMonitor::test_record_*
Python backend invokes mon.record(spikes, t)Network._record (network.py:229)test_records_voltage
Rust backend invokes mon.record_event(nid, t)Network._run_rust decode looptest_run_rust_decodes_voltages_and_spike_events
Rust rejects per-step monitorsNetwork._raise_for_rust_incompatibilitiestest_forced_rust_rejects_state_monitors_until_step_traces_exist, test_forced_rust_rejects_rate_monitors_until_step_traces_exist
Stimulus targetingstim.target = pop then Network.add(stim)apply_stimuli (network.py:199)
cross_correlationimports analysis.spike_stats.cross_correlation lazilytest_network_basic.py indirectly

All six public symbols re-exported from sc_neurocore.network are wired into the simulation loop; none are orphan helpers.


10. Audit (7-point checklist)

#DimensionStatusDetail
1Pipeline wiring✅ PASSAll six classes wired via Network.add dispatch
2Multi-angle tests✅ PASS21 tests across 6 Test* classes covering construction, recording, edge cases (empty, off-window, clamp), label, default variables, bin accumulation, deterministic seeding
3Rust path⚠️ WARNSpikeMonitor.record_event is covered through a fake Rust runner; StateMonitor and RateMonitor correctly reject forced Rust until native per-step traces exist.
4Benchmarks✅ PASS§8 measured this session; 3-run median per config
5Performance docs✅ PASS§8 + §8.1
6Documentation page✅ PASSThis page
7Rules followed✅ PASSSPDX headers ✅; no # noqa; no # type: ignore; British English in this doc

Net: 0 WARN, 0 FAIL.


11. Known issues

11.1 TimedArray.dt is informational only

The constructor accepts dt but the network never resamples the stored values array against its own dt. If the user builds a TimedArray with dt=0.0005 and passes it to a network running at dt=0.001, the stimulus advances at half the intended rate (every other network step uses the same values[t_step]). Consider documenting the contract explicitly or auto-resampling.

11.2 PoissonInput is Bernoulli, not Poisson

For low rate_hz × dt (< 0.1) the difference is negligible. For high rate_hz × dt (> 0.5) the Bernoulli draw under-counts. If high-rate biological accuracy matters, replace with rng.poisson(rate_hz × dt, n) and accept fractional spike currents.

11.3 RateMonitor drops the last incomplete bin

Final bin is silently lost if the simulation ends mid-window. Either pad the run to a multiple of bin_ms, or accept that len(rm.rate) is floor(n_steps / steps_per_bin).

11.4 Rust has no per-step monitor trace export

SpikeMonitor.record_event is exercised with a fake Rust runner, including decode of u64 = nid<<32 | t packed events and final-voltage sync. The real Rust wheel integration path is still environment-dependent. StateMonitor and RateMonitor remain Python-only until the Rust engine exposes per-step state and spike-bin trace data.


12. Tests

PYTHONPATH=src python3 -m pytest tests/test_network_monitors_stimulus.py -q
# 21 passed (verified 2026-04-17, suite runs in ~2 s including imports)

Covered (per Test* class):

  • TestSpikeMonitor (5 tests): empty after init, single spike, multiple spikes, spike_trains dict construction, label override
  • TestStateMonitor (3): no-spike empty trains, voltage recording, default variable is "v"
  • TestRateMonitor (2): empty after init, bin accumulation
  • TestTimedArray (4): value-at-step, clamp past end, accepts numpy array, single-value
  • TestStepCurrent (5): zero outside window, amplitude inside, onset inclusive, offset exclusive, negative amplitude
  • TestPoissonInput (2): creation, rate stored

Not covered:

  • Real Rust wheel integration for record_event — see §11.4
  • High-throughput stress (e.g. n=10 000, 60 Hz, 10 s) — would surface the StateMonitor list-copy cost
  • Mixing multiple monitors of the same type on the same population — the network supports it (each is in its own list) but it isn't asserted
  • cross_correlation end-to-end output (only invocation path is tested through test_network_basic.py)

13. References

  • Brian 2 monitor API (semantic ancestor): Stimberg M., Brette R., Goodman D. F. M. "Brian 2, an intuitive and efficient neural simulator." eLife 8:e47314 (2019).
  • NEST recording devices (semantic ancestor): Eppler J. M. et al. "PyNEST." Front Neuroinform 2:12 (2008).
  • Cross-correlogram method: Perkel D. H., Gerstein G. L., Moore G. P. "Neuronal Spike Trains and Stochastic Point Processes. II. Simultaneous Spike Trains." Biophysical Journal 7:419-440 (1967).

Internal:


14. Auto-rendered API

::: sc_neurocore.network.monitor options: show_root_heading: true show_source: true members: - SpikeMonitor - StateMonitor - RateMonitor

::: sc_neurocore.network.stimulus options: show_root_heading: true show_source: true members: - TimedArray - PoissonInput - StepCurrent