Skip to main content

sc_neurocore_engine/neurons/cerebellar/
unipolar_brush.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Commercial license available
3// © Concepts 1996–2026 Miroslav Šotek. All rights reserved.
4// © Code 2020–2026 Miroslav Šotek. All rights reserved.
5// ORCID: 0009-0009-3560-0851
6// Contact: www.anulum.li | protoscience@anulum.li
7// SC-NeuroCore — Cerebellar Circuit Neuron Models
8
9// ═══════════════════════════════════════════════════════════════════
10// Unipolar Brush Cell
11// ═══════════════════════════════════════════════════════════════════
12
13/// Cerebellar unipolar brush cell (UBC) — excitatory interneuron in vestibular cerebellum.
14///
15/// Biophysics: LIF with a slow persistent (NMDA-like) current that sustains
16/// depolarisation long after input ceases. The single brush-like dendrite
17/// forms a giant synapse with a mossy fibre rosette, creating a 1:1 relay
18/// that amplifies and prolongs the input signal.
19///
20/// UBCs are unique excitatory interneurons in the granular layer. They
21/// transform brief mossy fibre bursts into prolonged granule cell
22/// activation, important for vestibular signal processing and timing.
23///
24/// Bhatt et al., J Comp Neurol 349:560, 1994; Diana et al., J Neurosci 27:4374, 2007.
25#[derive(Clone, Debug)]
26pub struct UnipolarBrushCell {
27    pub v: f64,
28    pub persistent: f64, // Slow NMDA-like persistent current
29    pub v_rest: f64,
30    pub v_reset: f64,
31    pub v_threshold: f64,
32    pub tau_m: f64,
33    pub tau_persistent: f64,  // Slow decay of persistent current (ms)
34    pub persistent_gain: f64, // How much input drives persistent current
35    pub gain: f64,
36    pub dt: f64,
37}
38
39impl Default for UnipolarBrushCell {
40    fn default() -> Self {
41        Self::new()
42    }
43}
44
45impl UnipolarBrushCell {
46    pub fn new() -> Self {
47        Self {
48            v: -65.0,
49            persistent: 0.0,
50            v_rest: -65.0,
51            v_reset: -70.0,
52            v_threshold: -50.0,
53            tau_m: 8.0,
54            tau_persistent: 200.0,
55            persistent_gain: 0.5,
56            gain: 2.5,
57            dt: 0.5,
58        }
59    }
60
61    fn finite(values: &[f64]) -> bool {
62        values.iter().all(|value| value.is_finite())
63    }
64
65    fn valid_configuration(&self) -> bool {
66        Self::finite(&[
67            self.v_rest,
68            self.v_reset,
69            self.v_threshold,
70            self.tau_m,
71            self.tau_persistent,
72            self.persistent_gain,
73            self.gain,
74            self.dt,
75        ]) && self.tau_m > 0.0
76            && self.tau_persistent > 0.0
77            && self.persistent_gain >= 0.0
78            && self.gain >= 0.0
79            && self.dt > 0.0
80            && self.v_reset < self.v_threshold
81    }
82
83    fn valid_state(&self) -> bool {
84        Self::finite(&[self.v, self.persistent])
85            && (-100.0..=60.0).contains(&self.v)
86            && self.persistent >= 0.0
87    }
88
89    fn first_order_relaxation(previous: f64, steady_state: f64, dt: f64, tau: f64) -> f64 {
90        previous + (steady_state - previous) * (-(-dt / tau).exp_m1())
91    }
92
93    pub fn step(&mut self, current: f64) -> i32 {
94        if !self.valid_configuration() || !self.valid_state() || !current.is_finite() {
95            return 0;
96        }
97        let input = self.gain * current.max(0.0);
98        if !input.is_finite() {
99            return 0;
100        }
101        let next_persistent = Self::first_order_relaxation(
102            self.persistent,
103            self.persistent_gain * input,
104            self.dt,
105            self.tau_persistent,
106        )
107        .max(0.0);
108        let next_v = Self::first_order_relaxation(
109            self.v,
110            self.v_rest + input + next_persistent,
111            self.dt,
112            self.tau_m,
113        );
114        if !Self::finite(&[next_persistent, next_v]) {
115            return 0;
116        }
117        self.persistent = next_persistent;
118        if next_v >= self.v_threshold {
119            self.v = self.v_reset;
120            return 1;
121        }
122        self.v = next_v.clamp(-100.0, 60.0);
123        0
124    }
125
126    pub fn reset(&mut self) {
127        self.v = self.v_rest;
128        self.persistent = 0.0;
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    // -- Unipolar Brush Cell tests --
137
138    #[test]
139    fn ubc_fires_with_input() {
140        let mut n = UnipolarBrushCell::new();
141        let mut spikes = 0;
142        for _ in 0..10_000 {
143            spikes += n.step(5.0);
144        }
145        assert!(
146            spikes > 10,
147            "UBC must fire with excitatory input, got {spikes}"
148        );
149    }
150
151    #[test]
152    fn ubc_silent_without_input() {
153        let mut n = UnipolarBrushCell::new();
154        let mut spikes = 0;
155        for _ in 0..10_000 {
156            spikes += n.step(0.0);
157        }
158        assert_eq!(spikes, 0, "UBC must be silent without input");
159    }
160
161    fn ubc_exact_relaxation(previous: f64, steady_state: f64, dt: f64, tau: f64) -> f64 {
162        previous + (steady_state - previous) * (-(-dt / tau).exp_m1())
163    }
164
165    #[test]
166    fn ubc_uses_closed_form_persistent_and_membrane_relaxation() {
167        let mut n = UnipolarBrushCell::new();
168
169        let spike = n.step(1.0);
170
171        let input_drive = n.gain;
172        let expected_persistent =
173            ubc_exact_relaxation(0.0, n.persistent_gain * input_drive, n.dt, n.tau_persistent);
174        let expected_v = ubc_exact_relaxation(
175            n.v_rest,
176            n.v_rest + input_drive + expected_persistent,
177            n.dt,
178            n.tau_m,
179        );
180        assert_eq!(spike, 0);
181        assert!(
182            (n.persistent - expected_persistent).abs() <= 1e-12,
183            "persistent={} expected={}",
184            n.persistent,
185            expected_persistent
186        );
187        assert!(
188            (n.v - expected_v).abs() <= 1e-12,
189            "v={} expected={}",
190            n.v,
191            expected_v
192        );
193    }
194
195    #[test]
196    fn ubc_corrupted_state_is_preserved_on_step() {
197        let mut n = UnipolarBrushCell::new();
198        n.v = f64::NAN;
199        n.persistent = 2.0;
200
201        assert_eq!(n.step(10.0), 0);
202
203        assert!(n.v.is_nan());
204        assert_eq!(n.persistent, 2.0);
205    }
206
207    #[test]
208    fn ubc_persistent_activity() {
209        // After input stops, persistent current should sustain some depolarisation
210        let mut n = UnipolarBrushCell::new();
211        // Drive with input to build persistent current
212        for _ in 0..2000 {
213            n.step(10.0);
214        }
215        assert!(
216            n.persistent > 0.0,
217            "Persistent current must build during input"
218        );
219
220        // Now remove input — persistent current should persist
221        let persistent_before = n.persistent;
222        for _ in 0..100 {
223            n.step(0.0);
224        }
225        assert!(
226            n.persistent > 0.0,
227            "Persistent current must persist after input removal"
228        );
229        assert!(
230            n.persistent < persistent_before,
231            "Persistent current must decay"
232        );
233    }
234
235    #[test]
236    fn ubc_persistent_spikes_after_input() {
237        // UBC should continue firing briefly after input stops
238        let mut n = UnipolarBrushCell::new();
239        // Build up persistent current
240        for _ in 0..5000 {
241            n.step(10.0);
242        }
243        // Count spikes after input removal
244        let post_spikes: i32 = (0..500).map(|_| n.step(0.0)).sum();
245        // May or may not spike depending on persistent level — just test it doesn't crash
246        assert!(post_spikes >= 0, "post_spikes must be non-negative");
247        assert!(n.v.is_finite());
248    }
249
250    #[test]
251    fn ubc_negative_input_no_crash() {
252        let mut n = UnipolarBrushCell::new();
253        for _ in 0..10_000 {
254            n.step(-100.0);
255        }
256        assert!(n.v.is_finite());
257    }
258
259    #[test]
260    fn ubc_nan_input_stays_finite() {
261        let mut n = UnipolarBrushCell::new();
262        n.step(f64::NAN);
263        assert!(n.v.is_finite());
264    }
265
266    #[test]
267    fn ubc_extreme_input_bounded() {
268        let mut n = UnipolarBrushCell::new();
269        for _ in 0..1000 {
270            n.step(1e6);
271        }
272        assert!(n.v.is_finite() && n.v <= 60.0);
273    }
274
275    #[test]
276    fn ubc_reset_clears_state() {
277        let mut n = UnipolarBrushCell::new();
278        for _ in 0..1000 {
279            n.step(10.0);
280        }
281        n.reset();
282        assert_eq!(n.v, -65.0);
283        assert_eq!(n.persistent, 0.0);
284    }
285
286    #[test]
287    fn ubc_performance_10k_steps() {
288        let start = std::time::Instant::now();
289        let mut n = UnipolarBrushCell::new();
290        for _ in 0..10_000 {
291            std::hint::black_box(n.step(5.0));
292        }
293        let elapsed = start.elapsed();
294        assert!(elapsed.as_millis() < 50, "10k steps must complete in <50ms");
295    }
296
297    #[test]
298    fn ubc_default_matches_constructor_contract() {
299        let default = UnipolarBrushCell::default();
300        let constructed = UnipolarBrushCell::new();
301        assert_eq!(default.v, constructed.v);
302        assert_eq!(default.persistent, constructed.persistent);
303        assert_eq!(default.tau_persistent, constructed.tau_persistent);
304        assert_eq!(default.dt, constructed.dt);
305    }
306}