Skip to main content

sc_neurocore_engine/neurons/cerebellar/
dcn.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// Deep Cerebellar Nuclei Neuron
13// ═══════════════════════════════════════════════════════════════════
14
15/// Deep cerebellar nuclei (DCN) neuron — main output of the cerebellum.
16///
17/// Biophysics: WB Na+/K+ core with T-type Ca²⁺ for post-inhibitory rebound
18/// bursting, Ih (HCN) for pacemaker-like activity, persistent Na (INaP) for
19/// subthreshold depolarisation, and Ca²⁺-dependent AHP for spike frequency
20/// adaptation.
21///
22/// 7 currents: INa_t, INaP, IK_dr, ICa_T, IAHP, Ih, IL
23///
24/// Rebound bursting: when Purkinje inhibition is released, T-type Ca²⁺
25/// channels that de-inactivated during hyperpolarisation produce a burst.
26/// INaP amplifies subthreshold depolarisation. AHP limits burst duration.
27///
28/// Llinás & Mühlethaler, J Physiol 404:241, 1988; Jahnsen, J Physiol 372:129, 1986.
29#[derive(Clone, Debug)]
30pub struct DCNNeuron {
31    pub v: f64,
32    pub h: f64,  // Na_t inactivation
33    pub n: f64,  // K_dr activation
34    pub p: f64,  // Na_p persistent activation
35    pub s: f64,  // T-type Ca²⁺ inactivation (slow)
36    pub r: f64,  // Ih activation
37    pub ca: f64, // Intracellular Ca²⁺ (µM)
38    // Conductances (mS/cm²)
39    pub g_na: f64,
40    pub g_nap: f64, // Persistent Na
41    pub g_k: f64,
42    pub g_t: f64,   // T-type Ca²⁺
43    pub g_ahp: f64, // Ca²⁺-dependent AHP
44    pub g_h: f64,   // Ih
45    pub g_l: f64,
46    // Reversal potentials
47    pub e_na: f64,
48    pub e_k: f64,
49    pub e_ca: f64,
50    pub e_h: f64,
51    pub e_l: f64,
52    pub c_m: f64,
53    pub phi: f64,
54    pub tau_ca: f64,
55    pub kd_ahp: f64,
56    pub dt: f64,
57    pub v_threshold: f64,
58    pub gain: f64,
59}
60
61impl Default for DCNNeuron {
62    fn default() -> Self {
63        Self::new()
64    }
65}
66
67impl DCNNeuron {
68    pub fn new() -> Self {
69        Self {
70            v: -60.0,
71            h: 0.6,
72            n: 0.32,
73            p: 0.01,  // NaP activation (low at rest)
74            s: 0.8,   // T-type de-inactivated at rest
75            r: 0.1,   // Ih partially active
76            ca: 0.05, // Resting Ca²⁺ (µM)
77            g_na: 35.0,
78            g_nap: 0.5, // Persistent Na — amplifies subthreshold
79            g_k: 9.0,
80            g_t: 0.1,   // T-type Ca²⁺
81            g_ahp: 2.0, // Ca²⁺-dependent AHP
82            g_h: 0.02,  // Ih — modest
83            g_l: 0.2,   // Leak
84            e_na: 55.0,
85            e_k: -90.0,
86            e_ca: 120.0,
87            e_h: -40.0,
88            e_l: -65.0,
89            c_m: 1.0,
90            phi: 5.0,
91            tau_ca: 150.0,
92            kd_ahp: 0.5,
93            dt: 0.5,
94            v_threshold: -20.0,
95            gain: 1.0,
96        }
97    }
98
99    pub fn step(&mut self, current: f64) -> i32 {
100        if !self.is_valid() || !current.is_finite() {
101            return 0;
102        }
103        let input = self.gain * current;
104        let sub_steps = 20;
105        let sub_dt = self.dt / sub_steps as f64;
106        let mut fired = 0i32;
107        let (mut v, mut h, mut n, mut p, mut s, mut r, mut ca) =
108            (self.v, self.h, self.n, self.p, self.s, self.r, self.ca);
109
110        for _ in 0..sub_steps {
111            // Na_t: WB alpha/beta rates (m³h, m quasi-static)
112            let alpha_m = safe_rate(0.1, 35.0, v, 10.0, 1.0);
113            let beta_m = 4.0 * (-(v + 60.0) / 18.0).exp();
114            let m_inf = alpha_m / (alpha_m + beta_m);
115
116            let alpha_h = 0.07 * (-(v + 58.0) / 20.0).exp();
117            let beta_h = 1.0 / (1.0 + (-(v + 28.0) / 10.0).exp());
118
119            // K_dr: n⁴
120            let alpha_n = safe_rate(0.01, 34.0, v, 10.0, 0.1);
121            let beta_n = 0.125 * (-(v + 44.0) / 80.0).exp();
122
123            // Na_p: persistent Na (Boltzmann, V1/2=-48, k=5)
124            let p_inf = 1.0 / (1.0 + (-(v + 48.0) / 5.0).exp());
125            let tau_p = 5.0 + 15.0 / (1.0 + ((v + 48.0) / 10.0).powi(2)).max(0.01);
126
127            // T-type Ca²⁺ gating
128            let m_t_inf = 1.0 / (1.0 + (-(v + 52.0) / 5.0).exp());
129            let s_inf = 1.0 / (1.0 + ((v + 60.0) / 6.5).exp());
130            let tau_s = 20.0 + 50.0 / (1.0 + ((v + 65.0) / 10.0).exp());
131
132            // Ih gating
133            let r_inf = 1.0 / (1.0 + ((v + 80.0) / 10.0).exp());
134            let tau_r = 100.0 + 200.0 / (1.0 + ((v + 70.0) / 10.0).exp());
135
136            // First-order gates: exact exponential relaxation for the
137            // voltage-frozen sub-step, avoiding Euler overshoot in stiff
138            // rebound trajectories.
139            h = dcn_exact_hh_gate(h, alpha_h, beta_h, self.phi, sub_dt);
140            n = dcn_exact_hh_gate(n, alpha_n, beta_n, self.phi, sub_dt);
141            p = dcn_exact_relax(p, p_inf, tau_p, sub_dt);
142            s = dcn_exact_relax(s, s_inf, tau_s, sub_dt);
143            r = dcn_exact_relax(r, r_inf, tau_r, sub_dt);
144
145            // Ca²⁺ dynamics: entry via T-type, decay
146            let i_t = self.g_t * m_t_inf.powi(2) * s * (v - self.e_ca);
147            let ca_entry = if i_t < 0.0 { -i_t * 0.001 } else { 0.0 };
148            ca = dcn_exact_relax(ca, ca_entry * self.tau_ca, self.tau_ca, sub_dt).max(0.0);
149
150            // AHP: Ca²⁺-dependent K (Hill n=2)
151            let ahp_inf = ca.powi(2) / (ca.powi(2) + self.kd_ahp.powi(2));
152
153            // Voltage: exact ohmic conductance solution over the sub-step
154            // with gates frozen after their exponential update.
155            let g_na_eff = self.g_na * m_inf.powi(3) * h;
156            let g_nap_eff = self.g_nap * p;
157            let g_k_eff = self.g_k * n.powi(4);
158            let g_t_eff = self.g_t * m_t_inf.powi(2) * s;
159            let g_ahp_eff = self.g_ahp * ahp_inf;
160            let g_h_eff = self.g_h * r;
161            v = dcn_exact_voltage_step(
162                v,
163                input,
164                self.c_m,
165                sub_dt,
166                &[
167                    (g_na_eff, self.e_na),
168                    (g_nap_eff, self.e_na),
169                    (g_k_eff, self.e_k),
170                    (g_t_eff, self.e_ca),
171                    (g_ahp_eff, self.e_k),
172                    (g_h_eff, self.e_h),
173                    (self.g_l, self.e_l),
174                ],
175            );
176
177            if v >= self.v_threshold {
178                fired = 1;
179                v = -60.0;
180                s *= 0.5; // T-type inactivation on spike
181                ca += 0.5; // Ca²⁺ entry on spike
182            }
183        }
184
185        if ![v, h, n, p, s, r, ca].iter().all(|value| value.is_finite()) {
186            return 0;
187        }
188        self.v = v.clamp(-100.0, 60.0);
189        self.h = h.clamp(0.0, 1.0);
190        self.n = n.clamp(0.0, 1.0);
191        self.p = p.clamp(0.0, 1.0);
192        self.s = s.clamp(0.0, 1.0);
193        self.r = r.clamp(0.0, 1.0);
194        self.ca = ca.max(0.0);
195
196        fired
197    }
198
199    pub fn reset(&mut self) {
200        *self = Self::new();
201    }
202
203    fn is_valid(&self) -> bool {
204        [
205            self.v,
206            self.h,
207            self.n,
208            self.p,
209            self.s,
210            self.r,
211            self.ca,
212            self.g_na,
213            self.g_nap,
214            self.g_k,
215            self.g_t,
216            self.g_ahp,
217            self.g_h,
218            self.g_l,
219            self.e_na,
220            self.e_k,
221            self.e_ca,
222            self.e_h,
223            self.e_l,
224            self.c_m,
225            self.phi,
226            self.tau_ca,
227            self.kd_ahp,
228            self.dt,
229            self.v_threshold,
230            self.gain,
231        ]
232        .iter()
233        .all(|value| value.is_finite())
234            && [self.h, self.n, self.p, self.s, self.r]
235                .iter()
236                .all(|gate| (0.0..=1.0).contains(gate))
237            && self.ca >= 0.0
238            && (-100.0..=60.0).contains(&self.v)
239            && [
240                self.g_na, self.g_nap, self.g_k, self.g_t, self.g_ahp, self.g_h, self.g_l,
241            ]
242            .iter()
243            .all(|g| *g >= 0.0)
244            && self.c_m > 0.0
245            && self.phi > 0.0
246            && self.tau_ca > 0.0
247            && self.kd_ahp > 0.0
248            && self.dt > 0.0
249            && self.gain >= 0.0
250    }
251}
252
253fn dcn_exact_relax(value: f64, target: f64, tau: f64, dt: f64) -> f64 {
254    target + (value - target) * (-dt / tau).exp()
255}
256
257fn dcn_exact_hh_gate(value: f64, alpha: f64, beta: f64, phi: f64, dt: f64) -> f64 {
258    let rate = phi * (alpha + beta);
259    let target = alpha / (alpha + beta);
260    target + (value - target) * (-rate * dt).exp()
261}
262
263fn dcn_exact_voltage_step(
264    v: f64,
265    input_current: f64,
266    c_m: f64,
267    dt: f64,
268    conductances: &[(f64, f64)],
269) -> f64 {
270    let g_total: f64 = conductances.iter().map(|(g, _)| *g).sum();
271    if g_total <= 0.0 {
272        return v + dt * input_current / c_m;
273    }
274    let reversal_drive: f64 = conductances.iter().map(|(g, e_rev)| g * e_rev).sum();
275    let v_inf = (input_current + reversal_drive) / g_total;
276    v_inf + (v - v_inf) * (-dt * g_total / c_m).exp()
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    // -- DCN Neuron tests --
284
285    #[test]
286    fn dcn_fires_with_input() {
287        let mut n = DCNNeuron::new();
288        let mut spikes = 0;
289        for _ in 0..2_000 {
290            spikes += n.step(5.0);
291        }
292        assert!(
293            spikes > 3,
294            "DCN must fire with excitatory input, got {spikes}"
295        );
296    }
297
298    #[test]
299    fn dcn_spontaneous_activity() {
300        // DCN neurons fire spontaneously (Llinás & Mühlethaler 1988)
301        // INaP + Ih + depolarised leak drive autonomous firing
302        let mut n = DCNNeuron::new();
303        let mut spikes = 0;
304        for _ in 0..20_000 {
305            spikes += n.step(0.0);
306        }
307        // Should show some spontaneous activity (low rate)
308        // Without INaP, should be reduced
309        let mut no_nap = DCNNeuron::new();
310        no_nap.g_nap = 0.0;
311        let mut spikes_no = 0;
312        for _ in 0..20_000 {
313            spikes_no += no_nap.step(0.0);
314        }
315        assert!(
316            spikes >= spikes_no,
317            "INaP should contribute to spontaneous firing: with={spikes}, without={spikes_no}"
318        );
319    }
320
321    #[test]
322    fn dcn_rebound_burst() {
323        // Hyperpolarisation → T-type de-inactivation → rebound burst
324        let mut n = DCNNeuron::new();
325        // Hyperpolarise to de-inactivate T-type
326        for _ in 0..2000 {
327            n.step(-5.0);
328        }
329        assert!(
330            n.s > 0.5,
331            "T-type must de-inactivate during hyperpolarisation, s={}",
332            n.s
333        );
334
335        // Now provide excitation — T-type should help fire
336        let mut spikes = 0;
337        for _ in 0..200 {
338            spikes += n.step(3.0);
339        }
340        // Compare with pre-inactivated T-type
341        let mut n2 = DCNNeuron::new();
342        n2.s = 0.05; // pre-inactivated
343        let mut spikes2 = 0;
344        for _ in 0..200 {
345            spikes2 += n2.step(3.0);
346        }
347        assert!(
348            spikes >= spikes2,
349            "De-inactivated T-type should facilitate rebound: rebound={spikes} vs inact={spikes2}"
350        );
351    }
352
353    #[test]
354    fn dcn_ih_depolarises() {
355        // Ih should depolarise from hyperpolarised potentials
356        let mut with_ih = DCNNeuron::new();
357        with_ih.v = -80.0;
358        let mut no_ih = DCNNeuron::new();
359        no_ih.v = -80.0;
360        no_ih.g_h = 0.0;
361
362        for _ in 0..1000 {
363            with_ih.step(0.0);
364            no_ih.step(0.0);
365        }
366        assert!(
367            with_ih.v > no_ih.v,
368            "Ih should depolarise from hyperpolarised state: Ih={:.1} vs no_Ih={:.1}",
369            with_ih.v,
370            no_ih.v
371        );
372    }
373
374    #[test]
375    fn dcn_gate_and_calcium_kinetics_use_closed_form_relaxation() {
376        let mut n = DCNNeuron::new();
377        n.g_na = 0.0;
378        n.g_nap = 0.0;
379        n.g_k = 0.0;
380        n.g_t = 0.0;
381        n.g_ahp = 0.0;
382        n.g_h = 0.0;
383        n.g_l = 0.0;
384        n.gain = 0.0;
385        let (v0, h0, n0, p0, s0, r0, ca0) = (n.v, n.h, n.n, n.p, n.s, n.r, n.ca);
386        let alpha_h = 0.07 * (-(v0 + 58.0) / 20.0).exp();
387        let beta_h = 1.0 / (1.0 + (-(v0 + 28.0) / 10.0).exp());
388        let alpha_n = safe_rate(0.01, 34.0, v0, 10.0, 0.1);
389        let beta_n = 0.125 * (-(v0 + 44.0) / 80.0).exp();
390        let p_inf = 1.0 / (1.0 + (-(v0 + 48.0) / 5.0).exp());
391        let tau_p = 5.0 + 15.0 / (1.0 + ((v0 + 48.0) / 10.0).powi(2)).max(0.01);
392        let s_inf = 1.0 / (1.0 + ((v0 + 60.0) / 6.5).exp());
393        let tau_s = 20.0 + 50.0 / (1.0 + ((v0 + 65.0) / 10.0).exp());
394        let r_inf = 1.0 / (1.0 + ((v0 + 80.0) / 10.0).exp());
395        let tau_r = 100.0 + 200.0 / (1.0 + ((v0 + 70.0) / 10.0).exp());
396
397        n.step(0.0);
398
399        assert_close(n.v, v0);
400        assert_close(n.h, dcn_exact_hh_gate(h0, alpha_h, beta_h, n.phi, n.dt));
401        assert_close(n.n, dcn_exact_hh_gate(n0, alpha_n, beta_n, n.phi, n.dt));
402        assert_close(n.p, dcn_exact_relax(p0, p_inf, tau_p, n.dt));
403        assert_close(n.s, dcn_exact_relax(s0, s_inf, tau_s, n.dt));
404        assert_close(n.r, dcn_exact_relax(r0, r_inf, tau_r, n.dt));
405        assert_close(n.ca, dcn_exact_relax(ca0, 0.0, n.tau_ca, n.dt));
406    }
407
408    #[test]
409    fn dcn_negative_input_no_crash() {
410        let mut n = DCNNeuron::new();
411        for _ in 0..10_000 {
412            n.step(-100.0);
413        }
414        assert!(n.v.is_finite());
415        assert!(n.v >= -100.0);
416    }
417
418    #[test]
419    fn dcn_nan_input_stays_finite() {
420        let mut n = DCNNeuron::new();
421        let before = n.clone();
422        n.step(f64::NAN);
423        assert!(n.v.is_finite());
424        assert_eq!(n.v, before.v);
425        assert_eq!(n.ca, before.ca);
426    }
427
428    #[test]
429    fn dcn_extreme_input_bounded() {
430        let mut n = DCNNeuron::new();
431        for _ in 0..1000 {
432            n.step(1e6);
433        }
434        assert!(n.v.is_finite() && n.v <= 60.0);
435    }
436
437    #[test]
438    fn dcn_corrupted_state_preserved_on_step() {
439        let mut n = DCNNeuron::new();
440        n.h = -0.1;
441        let before_v = n.v;
442        let before_ca = n.ca;
443        assert_eq!(n.step(10.0), 0);
444        assert_eq!(n.v, before_v);
445        assert_eq!(n.ca, before_ca);
446    }
447
448    #[test]
449    fn dcn_reset_clears_state() {
450        let mut n = DCNNeuron::new();
451        for _ in 0..1000 {
452            n.step(10.0);
453        }
454        n.reset();
455        assert_eq!(n.v, -60.0);
456        assert_eq!(n.s, 0.8);
457        assert_eq!(n.r, 0.1);
458    }
459
460    #[test]
461    fn dcn_gates_bounded() {
462        let mut n = DCNNeuron::new();
463        for _ in 0..10_000 {
464            n.step(10.0);
465        }
466        for (name, val) in [("h", n.h), ("n", n.n), ("p", n.p), ("s", n.s), ("r", n.r)] {
467            assert!((0.0..=1.0).contains(&val), "{name} out of bounds: {val}");
468        }
469        assert!(n.ca >= 0.0, "Ca²⁺ must be non-negative: {}", n.ca);
470    }
471
472    #[test]
473    fn dcn_nap_increases_excitability() {
474        // INaP amplifies subthreshold depolarisation
475        let mut with_nap = DCNNeuron::new();
476        let mut no_nap = DCNNeuron::new();
477        no_nap.g_nap = 0.0;
478        let mut spikes_with = 0;
479        let mut spikes_no = 0;
480        for _ in 0..5_000 {
481            spikes_with += with_nap.step(3.0);
482            spikes_no += no_nap.step(3.0);
483        }
484        assert!(
485            spikes_with >= spikes_no,
486            "INaP should increase excitability: with={spikes_with}, without={spikes_no}"
487        );
488    }
489
490    #[test]
491    fn dcn_ahp_limits_rate() {
492        // Ca²⁺-AHP should reduce sustained firing rate
493        let mut with_ahp = DCNNeuron::new();
494        let mut no_ahp = DCNNeuron::new();
495        no_ahp.g_ahp = 0.0;
496        let mut spikes_with = 0;
497        let mut spikes_no = 0;
498        for _ in 0..5_000 {
499            spikes_with += with_ahp.step(8.0);
500            spikes_no += no_ahp.step(8.0);
501        }
502        assert!(
503            spikes_no >= spikes_with,
504            "AHP removal should increase firing: with={spikes_with}, without={spikes_no}"
505        );
506    }
507
508    #[test]
509    fn dcn_ca_rises_during_spiking() {
510        let mut n = DCNNeuron::new();
511        let ca_init = n.ca;
512        for _ in 0..5_000 {
513            n.step(10.0);
514        }
515        assert!(
516            n.ca > ca_init,
517            "Ca²⁺ must rise during spiking: init={ca_init}, now={}",
518            n.ca
519        );
520    }
521
522    #[test]
523    fn dcn_has_seven_currents() {
524        // Na_t, Na_p, K_dr, Ca_T, AHP, Ih, leak = 7
525        let n = DCNNeuron::new();
526        assert!(n.g_na > 0.0, "Na_t missing");
527        assert!(n.g_nap > 0.0, "Na_p missing");
528        assert!(n.g_k > 0.0, "K_dr missing");
529        assert!(n.g_t > 0.0, "Ca_T missing");
530        assert!(n.g_ahp > 0.0, "AHP missing");
531        assert!(n.g_h > 0.0, "Ih missing");
532        assert!(n.g_l > 0.0, "Leak missing");
533    }
534
535    #[test]
536    fn dcn_performance_1k_steps() {
537        let start = std::time::Instant::now();
538        let mut n = DCNNeuron::new();
539        for _ in 0..1_000 {
540            std::hint::black_box(n.step(5.0));
541        }
542        let elapsed = start.elapsed();
543        assert!(
544            elapsed.as_millis() < 200,
545            "1k steps must complete in <200ms"
546        );
547    }
548
549    fn assert_close(observed: f64, expected: f64) {
550        assert!(
551            (observed - expected).abs() <= 1.0e-12,
552            "observed {:.17e}, expected {:.17e}",
553            observed,
554            expected,
555        );
556    }
557
558    #[test]
559    fn dcn_default_matches_constructor_contract() {
560        let default = DCNNeuron::default();
561        let constructed = DCNNeuron::new();
562        assert_eq!(default.v, constructed.v);
563        assert_eq!(default.s, constructed.s);
564        assert_eq!(default.ca, constructed.ca);
565        assert_eq!(default.dt, constructed.dt);
566    }
567}