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:
| Method | Caller | Input |
|---|---|---|
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/method | Returns | What it does |
|---|---|---|
spike_times | np.ndarray[int64] length=count | every spike's timestep |
spike_trains | dict[int, np.ndarray[int64]] | per-neuron sorted timestep arrays |
count | int | total spikes recorded |
raster_data() | (times: ndarray, ids: ndarray) | tuple ready for plt.plot(times, ids, '.') |
firing_rates(n_steps, dt) | np.ndarray[n] Hz | mean 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):
neurons[0].get_state()if defined__dataclass_fields__if the neuron is a dataclass (excludingdt)- 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
| Property | Returns | Shape |
|---|---|---|
traces | dict[str, np.ndarray] keyed by variable name | (n_steps, n_recorded) per variable |
t | np.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
| Property | Returns | Note |
|---|---|---|
rate | np.ndarray[float64] Hz per bin | count / (bin_seconds × population.n) |
t | np.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).
| Configuration | Median wall | Δ vs baseline |
|---|---|---|
| baseline (no monitors) | 203.8 ms | — |
+ SpikeMonitor | 239.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 three | 296.2 ms | +92.4 ms (+45 %) |
Observations:
StateMonitoris the most expensive because every step it copies the full state-variable array (defaultpopulation.nentries) into a Python list. For large populations, setrecord=[indices]to a small subset.SpikeMonitorcost is sparse-driven — it appends one Python(int, int)per spike vianp.nonzero+ a Python loop. For high firing rates (>100 Hz × n>1000) it dominates.RateMonitorcost is constant per step — onespikes.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
_recordpass 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
| Surface | How it's wired | Verifier |
|---|---|---|
from sc_neurocore.network import SpikeMonitor, ... | network/__init__.py:13-25 | tests/test_network_monitors_stimulus.py |
Network(..., spike_monitor) registration | Network.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 loop | test_run_rust_decodes_voltages_and_spike_events |
| Rust rejects per-step monitors | Network._raise_for_rust_incompatibilities | test_forced_rust_rejects_state_monitors_until_step_traces_exist, test_forced_rust_rejects_rate_monitors_until_step_traces_exist |
| Stimulus targeting | stim.target = pop then Network.add(stim) | apply_stimuli (network.py:199) |
cross_correlation | imports analysis.spike_stats.cross_correlation lazily | test_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)
| # | Dimension | Status | Detail |
|---|---|---|---|
| 1 | Pipeline wiring | ✅ PASS | All six classes wired via Network.add dispatch |
| 2 | Multi-angle tests | ✅ PASS | 21 tests across 6 Test* classes covering construction, recording, edge cases (empty, off-window, clamp), label, default variables, bin accumulation, deterministic seeding |
| 3 | Rust path | ⚠️ WARN | SpikeMonitor.record_event is covered through a fake Rust runner; StateMonitor and RateMonitor correctly reject forced Rust until native per-step traces exist. |
| 4 | Benchmarks | ✅ PASS | §8 measured this session; 3-run median per config |
| 5 | Performance docs | ✅ PASS | §8 + §8.1 |
| 6 | Documentation page | ✅ PASS | This page |
| 7 | Rules followed | ✅ PASS | SPDX 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_trainsdict construction, label overrideTestStateMonitor(3): no-spike empty trains, voltage recording, default variable is"v"TestRateMonitor(2): empty after init, bin accumulationTestTimedArray(4): value-at-step, clamp past end, accepts numpy array, single-valueTestStepCurrent(5): zero outside window, amplitude inside, onset inclusive, offset exclusive, negative amplitudeTestPoissonInput(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_correlationend-to-end output (only invocation path is tested throughtest_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:
- Network orchestrator:
api/network.md - Spike-train statistics backend:
api/analysis.md
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