Skip to main content

sc_neurocore_engine/neurons/cerebellar/
stellate.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
9use super::super::biophysical::safe_rate;
10
11// ═══════════════════════════════════════════════════════════════════
12// Stellate Cell
13// ═══════════════════════════════════════════════════════════════════
14
15/// Cerebellar stellate cell — fast-spiking interneuron in the molecular layer.
16///
17/// Biophysics: Wang-Buzsáki Na+/K+ core extended with Kv3.1 for narrow
18/// action potentials and high-frequency firing. Provides feedforward
19/// inhibition onto Purkinje cell dendrites. Receives excitatory input
20/// from parallel fibres (granule cell axons).
21///
22/// Stellate cells are smaller than basket cells and innervate more distal
23/// Purkinje cell dendrites. They show minimal spike frequency adaptation
24/// and can sustain high firing rates.
25///
26/// Sultan & Bower, J Comp Neurol 409:63, 1999; Häusser & Clark, Neuron 19:665, 1997.
27#[derive(Clone, Debug)]
28pub struct StellateCell {
29    pub v: f64,
30    pub h: f64, // Na+ inactivation
31    pub n: f64, // Kdr activation
32    pub p: f64, // Kv3.1 activation
33    // Conductances (mS/cm²)
34    pub g_na: f64,
35    pub g_k: f64,
36    pub g_kv3: f64,
37    pub g_l: f64,
38    // Reversal potentials (mV)
39    pub e_na: f64,
40    pub e_k: f64,
41    pub e_l: f64,
42    pub c_m: f64,
43    pub phi: f64,
44    pub dt: f64,
45    pub v_threshold: f64,
46    pub gain: f64,
47}
48
49impl Default for StellateCell {
50    fn default() -> Self {
51        Self::new()
52    }
53}
54
55impl StellateCell {
56    pub fn new() -> Self {
57        Self {
58            v: -65.0,
59            h: 0.6,
60            n: 0.32,
61            p: 0.0,
62            g_na: 35.0,
63            g_k: 9.0,
64            g_kv3: 3.0, // Less Kv3.1 than PV+ basket
65            g_l: 0.1,
66            e_na: 55.0,
67            e_k: -90.0,
68            e_l: -65.0,
69            c_m: 0.5, // Smaller cell → lower capacitance
70            phi: 5.0,
71            dt: 0.5,
72            v_threshold: -20.0,
73            gain: 1.0,
74        }
75    }
76
77    #[inline]
78    fn safe_exp(value: f64) -> f64 {
79        value.clamp(-60.0, 60.0).exp()
80    }
81
82    #[inline]
83    fn boltz(v: f64, vh: f64, k: f64) -> f64 {
84        let z = -(v - vh) / k;
85        if z > 60.0 {
86            0.0
87        } else if z < -60.0 {
88            1.0
89        } else {
90            1.0 / (1.0 + z.exp())
91        }
92    }
93
94    #[inline]
95    fn exact_relax(value: f64, target: f64, tau: f64, dt: f64) -> f64 {
96        if tau <= f64::EPSILON {
97            target.clamp(0.0, 1.0)
98        } else {
99            (target + (value - target) * (-dt / tau).exp()).clamp(0.0, 1.0)
100        }
101    }
102
103    #[inline]
104    fn exact_hh_gate(value: f64, alpha: f64, beta: f64, phi: f64, dt: f64) -> f64 {
105        let total = alpha + beta;
106        if total <= f64::EPSILON {
107            value.clamp(0.0, 1.0)
108        } else {
109            let steady = alpha / total;
110            (steady + (value - steady) * (-phi * total * dt).exp()).clamp(0.0, 1.0)
111        }
112    }
113
114    #[inline]
115    fn exact_voltage_step(
116        v: f64,
117        c_m: f64,
118        input: f64,
119        conductances: [(f64, f64); 4],
120        dt: f64,
121    ) -> f64 {
122        let g_total = conductances.iter().map(|(g, _)| *g).sum::<f64>();
123        let drive = input
124            + conductances
125                .iter()
126                .map(|(g, reversal)| g * reversal)
127                .sum::<f64>();
128        if g_total <= f64::EPSILON {
129            v + dt * drive / c_m
130        } else {
131            let v_inf = drive / g_total;
132            let tau = c_m / g_total;
133            v_inf + (v - v_inf) * (-dt / tau).exp()
134        }
135    }
136
137    fn is_valid(&self) -> bool {
138        [
139            self.v,
140            self.h,
141            self.n,
142            self.p,
143            self.g_na,
144            self.g_k,
145            self.g_kv3,
146            self.g_l,
147            self.e_na,
148            self.e_k,
149            self.e_l,
150            self.c_m,
151            self.phi,
152            self.dt,
153            self.v_threshold,
154            self.gain,
155        ]
156        .iter()
157        .all(|value| value.is_finite())
158            && (-100.0..=60.0).contains(&self.v)
159            && [self.h, self.n, self.p]
160                .iter()
161                .all(|gate| (0.0..=1.0).contains(gate))
162            && [self.g_na, self.g_k, self.g_kv3, self.g_l]
163                .iter()
164                .all(|conductance| *conductance >= 0.0)
165            && self.c_m > 0.0
166            && self.phi > 0.0
167            && self.dt > 0.0
168            && self.gain >= 0.0
169    }
170
171    pub fn step(&mut self, current: f64) -> i32 {
172        if !self.is_valid() || !current.is_finite() {
173            return 0;
174        }
175
176        let input = self.gain * current;
177        let sub_steps = 50;
178        let sub_dt = self.dt / sub_steps as f64;
179        let mut fired = 0i32;
180        let mut v = self.v;
181        let mut h = self.h;
182        let mut n = self.n;
183        let mut p = self.p;
184
185        for _ in 0..sub_steps {
186            // WB alpha/beta rates
187            let alpha_m = safe_rate(0.1, 35.0, v, 10.0, 1.0);
188            let beta_m = 4.0 * Self::safe_exp(-(v + 60.0) / 18.0);
189            let m_inf = alpha_m / (alpha_m + beta_m);
190
191            let alpha_h = 0.07 * Self::safe_exp(-(v + 58.0) / 20.0);
192            let beta_h = Self::boltz(v, -28.0, 10.0);
193
194            let alpha_n = safe_rate(0.01, 34.0, v, 10.0, 0.1);
195            let beta_n = 0.125 * Self::safe_exp(-(v + 44.0) / 80.0);
196
197            // Kv3.1 gating (fast activation, no inactivation)
198            let p_inf = Self::boltz(v, -10.0, 10.0);
199            let tau_p = 1.0 + 4.0 / (1.0 + Self::safe_exp((v + 20.0) / 15.0));
200
201            // Gate updates
202            h = Self::exact_hh_gate(h, alpha_h, beta_h, self.phi, sub_dt);
203            n = Self::exact_hh_gate(n, alpha_n, beta_n, self.phi, sub_dt);
204            p = Self::exact_relax(p, p_inf, tau_p, sub_dt);
205
206            // Currents (m uses steady-state for speed)
207            let g_na = self.g_na * m_inf.powi(3) * h;
208            let g_k = self.g_k * n.powi(4);
209            let g_kv3 = self.g_kv3 * p.powi(2);
210            let g_l = self.g_l;
211
212            v = Self::exact_voltage_step(
213                v,
214                self.c_m,
215                input,
216                [
217                    (g_na, self.e_na),
218                    (g_k, self.e_k),
219                    (g_kv3, self.e_k),
220                    (g_l, self.e_l),
221                ],
222                sub_dt,
223            )
224            .clamp(-100.0, 60.0);
225            if ![v, h, n, p].iter().all(|value| value.is_finite()) {
226                return 0;
227            }
228
229            if v >= self.v_threshold {
230                fired = 1;
231                v = -65.0;
232            }
233        }
234
235        self.v = v;
236        self.h = h;
237        self.n = n;
238        self.p = p;
239
240        fired
241    }
242
243    pub fn reset(&mut self) {
244        *self = Self::new();
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    // -- Stellate Cell tests --
253
254    #[test]
255    fn stellate_fires_with_input() {
256        let mut n = StellateCell::new();
257        let mut spikes = 0;
258        for _ in 0..2_000 {
259            spikes += n.step(2.0);
260        }
261        assert!(
262            spikes > 5,
263            "Stellate cell must fire with input, got {spikes}"
264        );
265    }
266
267    #[test]
268    fn stellate_silent_without_input() {
269        let mut n = StellateCell::new();
270        let mut spikes = 0;
271        for _ in 0..10_000 {
272            spikes += n.step(0.0);
273        }
274        assert_eq!(
275            spikes, 0,
276            "Stellate cell must be silent without input, got {spikes}"
277        );
278    }
279
280    #[test]
281    fn stellate_high_frequency() {
282        // Fast-spiking: should sustain high rates
283        let mut n = StellateCell::new();
284        let mut spikes = 0;
285        for _ in 0..2_000 {
286            spikes += n.step(20.0);
287        }
288        // 2000 steps * 0.5ms = 1000 ms; >100 spikes = >100 Hz
289        assert!(
290            spikes > 50,
291            "FS stellate should fire at high rate, got {spikes}"
292        );
293    }
294
295    #[test]
296    fn stellate_minimal_adaptation() {
297        // Compare early vs late firing — should show little adaptation
298        let mut n = StellateCell::new();
299        let input = 10.0;
300        let mut spikes_early = 0;
301        for _ in 0..2000 {
302            spikes_early += n.step(input);
303        }
304        let mut spikes_late = 0;
305        for _ in 0..2000 {
306            spikes_late += n.step(input);
307        }
308        // No AHP → minimal adaptation
309        let diff = (spikes_early - spikes_late).abs();
310        assert!(
311            diff < 20,
312            "FS should have minimal adaptation: early={spikes_early}, late={spikes_late}"
313        );
314    }
315
316    #[test]
317    fn stellate_kv3_narrows_spikes() {
318        // Kv3.1 should allow faster repolarisation → more spikes
319        let mut with_kv3 = StellateCell::new();
320        let mut no_kv3 = StellateCell::new();
321        no_kv3.g_kv3 = 0.0;
322
323        let mut spikes_kv3 = 0;
324        let mut spikes_no = 0;
325        for _ in 0..2000 {
326            spikes_kv3 += with_kv3.step(15.0);
327            spikes_no += no_kv3.step(15.0);
328        }
329        // Kv3.1 should enable higher frequency (more spikes at same input)
330        assert!(spikes_kv3 > 0, "With Kv3.1 must fire, got {spikes_kv3}");
331        assert!(
332            spikes_no >= 0,
333            "No-Kv3.1 baseline must not panic, got {spikes_no}"
334        );
335    }
336
337    #[test]
338    fn stellate_negative_input_no_crash() {
339        let mut n = StellateCell::new();
340        for _ in 0..10_000 {
341            n.step(-100.0);
342        }
343        assert!(n.v.is_finite());
344        assert!(n.v >= -100.0);
345    }
346
347    #[test]
348    fn stellate_nan_input_stays_finite() {
349        let mut n = StellateCell::new();
350        let before = n.clone();
351        n.step(f64::NAN);
352        assert!(n.v.is_finite());
353        assert_eq!(n.v, before.v);
354        assert_eq!(n.h, before.h);
355        assert_eq!(n.n, before.n);
356        assert_eq!(n.p, before.p);
357    }
358
359    #[test]
360    fn stellate_corrupted_state_preserved_on_step() {
361        let mut n = StellateCell::new();
362        n.h = -0.1;
363        let before = n.clone();
364        assert_eq!(n.step(8.0), 0);
365        assert_eq!(n.v, before.v);
366        assert_eq!(n.h, before.h);
367        assert_eq!(n.n, before.n);
368        assert_eq!(n.p, before.p);
369    }
370
371    #[test]
372    fn stellate_invalid_voltage_preserved_on_step() {
373        let mut n = StellateCell::new();
374        n.v = 60.1;
375        let before = n.clone();
376        assert_eq!(n.step(8.0), 0);
377        assert_eq!(n.v, before.v);
378        assert_eq!(n.h, before.h);
379        assert_eq!(n.n, before.n);
380        assert_eq!(n.p, before.p);
381    }
382
383    #[test]
384    fn stellate_closed_form_gate_kinetics() {
385        let mut n = StellateCell::new();
386        n.g_na = 0.0;
387        n.g_k = 0.0;
388        n.g_kv3 = 0.0;
389        n.g_l = 0.0;
390        n.gain = 0.0;
391
392        let alpha_h = 0.07 * StellateCell::safe_exp(-(n.v + 58.0) / 20.0);
393        let beta_h = StellateCell::boltz(n.v, -28.0, 10.0);
394        let alpha_n = safe_rate(0.01, 34.0, n.v, 10.0, 0.1);
395        let beta_n = 0.125 * StellateCell::safe_exp(-(n.v + 44.0) / 80.0);
396        let p_inf = StellateCell::boltz(n.v, -10.0, 10.0);
397        let tau_p = 1.0 + 4.0 / (1.0 + StellateCell::safe_exp((n.v + 20.0) / 15.0));
398
399        let expected_h = exact_hh_gate_stellate(n.h, alpha_h, beta_h, n.phi, n.dt);
400        let expected_n = exact_hh_gate_stellate(n.n, alpha_n, beta_n, n.phi, n.dt);
401        let expected_p = exact_relax_stellate(n.p, p_inf, tau_p, n.dt);
402        let expected_v = n.v;
403
404        assert_eq!(n.step(0.0), 0);
405        assert_close_stellate(n.v, expected_v, 1e-12);
406        assert_close_stellate(n.h, expected_h, 1e-12);
407        assert_close_stellate(n.n, expected_n, 1e-12);
408        assert_close_stellate(n.p, expected_p, 1e-12);
409    }
410
411    fn exact_relax_stellate(value: f64, target: f64, tau: f64, dt: f64) -> f64 {
412        if tau <= f64::EPSILON {
413            target.clamp(0.0, 1.0)
414        } else {
415            (target + (value - target) * (-dt / tau).exp()).clamp(0.0, 1.0)
416        }
417    }
418
419    fn exact_hh_gate_stellate(value: f64, alpha: f64, beta: f64, phi: f64, dt: f64) -> f64 {
420        let total = alpha + beta;
421        if total <= f64::EPSILON {
422            value.clamp(0.0, 1.0)
423        } else {
424            let steady = alpha / total;
425            (steady + (value - steady) * (-phi * total * dt).exp()).clamp(0.0, 1.0)
426        }
427    }
428
429    fn assert_close_stellate(actual: f64, expected: f64, tolerance: f64) {
430        assert!(
431            (actual - expected).abs() <= tolerance,
432            "actual={actual:.16e} expected={expected:.16e} tolerance={tolerance:.3e}"
433        );
434    }
435
436    #[test]
437    fn stellate_extreme_input_bounded() {
438        let mut n = StellateCell::new();
439        for _ in 0..1000 {
440            n.step(1e6);
441        }
442        assert!(n.v.is_finite() && n.v <= 60.0);
443    }
444
445    #[test]
446    fn stellate_reset_clears_state() {
447        let mut n = StellateCell::new();
448        for _ in 0..1000 {
449            n.step(20.0);
450        }
451        n.reset();
452        assert_eq!(n.v, -65.0);
453        assert_eq!(n.p, 0.0);
454    }
455
456    #[test]
457    fn stellate_gates_bounded() {
458        let mut n = StellateCell::new();
459        for _ in 0..10_000 {
460            n.step(15.0);
461        }
462        assert!(n.h >= 0.0 && n.h <= 1.0);
463        assert!(n.n >= 0.0 && n.n <= 1.0);
464        assert!(n.p >= 0.0 && n.p <= 1.0);
465    }
466
467    #[test]
468    fn stellate_performance_1k_steps() {
469        let start = std::time::Instant::now();
470        let mut n = StellateCell::new();
471        for _ in 0..1_000 {
472            std::hint::black_box(n.step(10.0));
473        }
474        let elapsed = start.elapsed();
475        assert!(
476            elapsed.as_millis() < 200,
477            "1k steps must complete in <200ms, took {}ms",
478            elapsed.as_millis()
479        );
480    }
481
482    #[test]
483    fn stellate_default_matches_constructor_contract() {
484        let default = StellateCell::default();
485        let constructed = StellateCell::new();
486        assert_eq!(default.v, constructed.v);
487        assert_eq!(default.h, constructed.h);
488        assert_eq!(default.p, constructed.p);
489        assert_eq!(default.dt, constructed.dt);
490    }
491}