Skip to main content

sc_neurocore_engine/neurons/cerebellar/
lugaro.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// Lugaro Cell
11// ═══════════════════════════════════════════════════════════════════
12
13/// Cerebellar Lugaro cell — rare fusiform interneuron in the granular layer.
14///
15/// Biophysics: LIF with adaptation for regular spiking, serotonin modulation
16/// (5-HT increases gain), and a depolarised leak for spontaneous firing.
17/// Inhibits Golgi cells and molecular layer interneurons (stellate, basket).
18///
19/// Lugaro cells are distinguished by their horizontal axonal projection,
20/// large fusiform soma, and sensitivity to serotonergic afferents from
21/// the brainstem raphe nuclei.
22///
23/// Dieudonné & Bhatt, J Physiol 548:97, 2003; Lainé & Bhatt, Front Syst Neurosci 1:4, 2007.
24#[derive(Clone, Debug)]
25pub struct LugaroCell {
26    pub v: f64,
27    pub adapt: f64, // Adaptation current
28    pub v_rest: f64,
29    pub v_reset: f64,
30    pub v_threshold: f64,
31    pub tau_m: f64,
32    pub tau_adapt: f64,
33    pub a_adapt: f64, // Adaptation coupling strength
34    pub gain: f64,
35    pub serotonin: f64, // 5-HT modulation factor [0, 1]
36    pub dt: f64,
37}
38
39impl Default for LugaroCell {
40    fn default() -> Self {
41        Self::new()
42    }
43}
44
45impl LugaroCell {
46    pub fn new() -> Self {
47        Self {
48            v: -55.0,
49            adapt: 0.0,
50            v_rest: -55.0, // Depolarised rest for spontaneous firing
51            v_reset: -65.0,
52            v_threshold: -48.0,
53            tau_m: 10.0,
54            tau_adapt: 150.0,
55            a_adapt: 0.05,
56            gain: 2.0,
57            serotonin: 0.0, // No 5-HT modulation by default
58            dt: 0.5,
59        }
60    }
61
62    /// Create with serotonin modulation active.
63    pub fn with_serotonin(serotonin_level: f64) -> Self {
64        let mut n = Self::new();
65        n.serotonin = serotonin_level.clamp(0.0, 1.0);
66        n
67    }
68
69    fn is_valid(&self) -> bool {
70        [
71            self.v,
72            self.adapt,
73            self.v_rest,
74            self.v_reset,
75            self.v_threshold,
76            self.tau_m,
77            self.tau_adapt,
78            self.a_adapt,
79            self.gain,
80            self.serotonin,
81            self.dt,
82        ]
83        .iter()
84        .all(|value| value.is_finite())
85            && self.tau_m > 0.0
86            && self.tau_adapt > 0.0
87            && self.dt > 0.0
88            && self.a_adapt >= 0.0
89            && self.gain >= 0.0
90            && (-100.0..=60.0).contains(&self.v)
91            && (0.0..=1.0).contains(&self.serotonin)
92            && self.adapt >= 0.0
93            && self.v_threshold > self.v_reset
94            && self.v_threshold > self.v_rest
95    }
96
97    pub fn step(&mut self, current: f64) -> i32 {
98        if !self.is_valid() || !current.is_finite() {
99            return 0;
100        }
101
102        // 5-HT modulation: increases effective gain
103        let effective_gain = self.gain * (1.0 + 0.5 * self.serotonin);
104        let input = effective_gain * current;
105
106        // LIF dynamics with closed-form first-order relaxation.
107        let v_inf = self.v_rest + input - self.adapt;
108        let v_next = v_inf + (self.v - v_inf) * (-self.dt / self.tau_m).exp();
109
110        // Adaptation dynamics with non-negative hyperpolarising current.
111        let adapt_inf = (self.a_adapt * (v_next - self.v_rest).max(0.0)).max(0.0);
112        let adapt_next =
113            (adapt_inf + (self.adapt - adapt_inf) * (-self.dt / self.tau_adapt).exp()).max(0.0);
114        if !v_next.is_finite() || !adapt_next.is_finite() {
115            return 0;
116        }
117
118        // Spike detection
119        if v_next >= self.v_threshold {
120            self.v = self.v_reset;
121            self.adapt = adapt_next + 1.0; // Spike-triggered adaptation increment
122            return 1;
123        }
124
125        // Safety bounds
126        self.v = v_next.clamp(-100.0, 60.0);
127        self.adapt = adapt_next;
128
129        0
130    }
131
132    pub fn reset(&mut self) {
133        *self = Self::new();
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    // -- Lugaro Cell tests --
142
143    #[test]
144    fn lugaro_fires_with_input() {
145        let mut n = LugaroCell::new();
146        let mut spikes = 0;
147        for _ in 0..10_000 {
148            spikes += n.step(5.0);
149        }
150        assert!(
151            spikes > 10,
152            "Lugaro must fire with excitatory input, got {spikes}"
153        );
154    }
155
156    #[test]
157    fn lugaro_low_threshold() {
158        // Near-threshold rest → fires easily with moderate input
159        let mut n = LugaroCell::new();
160        let mut spikes = 0;
161        for _ in 0..10_000 {
162            spikes += n.step(4.0);
163        }
164        assert!(
165            spikes > 10,
166            "Lugaro should fire easily with moderate input, got {spikes}"
167        );
168    }
169
170    #[test]
171    fn lugaro_adaptation() {
172        let mut n = LugaroCell::new();
173        let input = 10.0;
174        let mut spikes_early = 0;
175        for _ in 0..2000 {
176            spikes_early += n.step(input);
177        }
178        let mut spikes_late = 0;
179        for _ in 0..2000 {
180            spikes_late += n.step(input);
181        }
182        assert!(
183            spikes_early >= spikes_late,
184            "Adaptation should slow firing: early={spikes_early}, late={spikes_late}"
185        );
186    }
187
188    #[test]
189    fn lugaro_serotonin_increases_firing() {
190        let mut no_5ht = LugaroCell::new();
191        let mut with_5ht = LugaroCell::with_serotonin(1.0);
192
193        let input = 3.0;
194        let mut spikes_no = 0;
195        let mut spikes_5ht = 0;
196        for _ in 0..10_000 {
197            spikes_no += no_5ht.step(input);
198            spikes_5ht += with_5ht.step(input);
199        }
200        assert!(
201            spikes_5ht >= spikes_no,
202            "5-HT must increase firing: 5HT={spikes_5ht} vs none={spikes_no}"
203        );
204    }
205
206    #[test]
207    fn lugaro_negative_input_no_crash() {
208        let mut n = LugaroCell::new();
209        for _ in 0..10_000 {
210            n.step(-100.0);
211        }
212        assert!(n.v.is_finite());
213        assert!(n.v >= -100.0);
214    }
215
216    #[test]
217    fn lugaro_nan_input_stays_finite() {
218        let mut n = LugaroCell::new();
219        let before = n.clone();
220        n.step(f64::NAN);
221        assert!(n.v.is_finite());
222        assert_eq!(n.v, before.v);
223        assert_eq!(n.adapt, before.adapt);
224    }
225
226    #[test]
227    fn lugaro_corrupted_state_preserved_on_step() {
228        let mut n = LugaroCell::new();
229        n.adapt = f64::NAN;
230        let before = n.clone();
231        assert_eq!(n.step(5.0), 0);
232        assert_eq!(n.v, before.v);
233        assert!(n.adapt.is_nan());
234    }
235
236    #[test]
237    fn lugaro_invalid_voltage_preserved_on_step() {
238        let mut n = LugaroCell::new();
239        n.v = 60.1;
240        let before = n.clone();
241        assert_eq!(n.step(5.0), 0);
242        assert_eq!(n.v, before.v);
243        assert_eq!(n.adapt, before.adapt);
244    }
245
246    #[test]
247    fn lugaro_closed_form_membrane_and_adaptation_relaxation() {
248        let mut n = LugaroCell::new();
249        n.v = -56.0;
250        n.adapt = 0.2;
251        n.gain = 0.0;
252
253        let v_inf = n.v_rest - n.adapt;
254        let expected_v = exact_relax_lugaro(n.v, v_inf, n.tau_m, n.dt);
255        let adapt_inf = (n.a_adapt * (expected_v - n.v_rest).max(0.0)).max(0.0);
256        let expected_adapt = exact_relax_lugaro(n.adapt, adapt_inf, n.tau_adapt, n.dt).max(0.0);
257
258        assert_eq!(n.step(0.0), 0);
259        assert_close_lugaro(n.v, expected_v, 1e-12);
260        assert_close_lugaro(n.adapt, expected_adapt, 1e-12);
261    }
262
263    fn exact_relax_lugaro(value: f64, target: f64, tau: f64, dt: f64) -> f64 {
264        target + (value - target) * (-dt / tau).exp()
265    }
266
267    fn assert_close_lugaro(actual: f64, expected: f64, tolerance: f64) {
268        assert!(
269            (actual - expected).abs() <= tolerance,
270            "actual={actual:.16e} expected={expected:.16e} tolerance={tolerance:.3e}"
271        );
272    }
273
274    #[test]
275    fn lugaro_extreme_input_bounded() {
276        let mut n = LugaroCell::new();
277        for _ in 0..1000 {
278            n.step(1e6);
279        }
280        assert!(n.v.is_finite() && n.v <= 60.0);
281    }
282
283    #[test]
284    fn lugaro_reset_clears_state() {
285        let mut n = LugaroCell::new();
286        for _ in 0..1000 {
287            n.step(10.0);
288        }
289        n.reset();
290        assert_eq!(n.v, -55.0);
291        assert_eq!(n.adapt, 0.0);
292        assert_eq!(n.serotonin, 0.0);
293    }
294
295    #[test]
296    fn lugaro_adapt_increases_during_spiking() {
297        let mut n = LugaroCell::new();
298        let initial = n.adapt;
299        for _ in 0..5000 {
300            n.step(10.0);
301        }
302        assert!(
303            n.adapt > initial,
304            "Adaptation must increase during spiking, adapt={}",
305            n.adapt
306        );
307    }
308
309    #[test]
310    fn lugaro_performance_10k_steps() {
311        let start = std::time::Instant::now();
312        let mut n = LugaroCell::new();
313        for _ in 0..10_000 {
314            std::hint::black_box(n.step(5.0));
315        }
316        let elapsed = start.elapsed();
317        assert!(
318            elapsed.as_millis() < 50,
319            "10k steps must complete in <50ms, took {}ms",
320            elapsed.as_millis()
321        );
322    }
323
324    #[test]
325    fn lugaro_default_matches_constructor_contract() {
326        let default = LugaroCell::default();
327        let constructed = LugaroCell::new();
328        assert_eq!(default.v, constructed.v);
329        assert_eq!(default.adapt, constructed.adapt);
330        assert_eq!(default.serotonin, constructed.serotonin);
331        assert_eq!(default.dt, constructed.dt);
332    }
333}