Skip to main content

sc_neurocore_engine/neurons/cerebellar/
granule.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// Granule Cell
11// ═══════════════════════════════════════════════════════════════════
12
13/// Cerebellar granule cell — D'Angelo et al. 2001 full model.
14///
15/// Most numerous neuron in the brain (~50%). Tiny soma (6-8 µm),
16/// four short dendrites receiving mossy fibre input at glomeruli,
17/// output via parallel fibres to Purkinje cells.
18///
19/// Full Hodgkin-Huxley-type model with 7 ionic currents:
20/// - **INa** (transient Na, m³h): fast spike generation
21/// - **IK_dr** (delayed rectifier K, n⁴): repolarisation
22/// - **IK_A** (A-type K, a³b): delay to first spike, inter-spike interval
23/// - **ICa_T** (T-type Ca²⁺, m_t²s): post-inhibitory rebound bursting
24/// - **IK_Ca** (Ca²⁺-activated K, Hill): slow AHP
25/// - **Ih** (HCN, r): sag current, resting potential stabilisation
26/// - **IL** (leak)
27/// - **IGABA** (tonic GABA from Golgi cells)
28///
29/// Uses 4 sub-steps (dt_sub = 0.125 ms) for Na gating stability.
30///
31/// D'Angelo et al., J Neurosci 21(3):759, 2001.
32/// D'Angelo & De Zeeuw, Trends Neurosci 32:30, 2009 (review).
33#[derive(Clone, Debug)]
34pub struct GranuleCell {
35    pub v: f64,       // Membrane potential (mV)
36    pub m: f64,       // Na activation
37    pub h: f64,       // Na inactivation
38    pub n: f64,       // K_dr activation
39    pub a: f64,       // K_A activation
40    pub b: f64,       // K_A inactivation
41    pub m_t: f64,     // T-type Ca²⁺ activation
42    pub s: f64,       // T-type Ca²⁺ inactivation
43    pub ca: f64,      // Intracellular Ca²⁺ (µM)
44    pub r: f64,       // Ih activation
45    pub c_m: f64,     // Capacitance (µF/cm²)
46    pub g_na: f64,    // Na conductance
47    pub g_kdr: f64,   // K_dr conductance
48    pub g_ka: f64,    // K_A conductance
49    pub g_t: f64,     // T-type Ca²⁺ conductance
50    pub g_kca: f64,   // Ca²⁺-dependent K conductance
51    pub g_h: f64,     // Ih conductance
52    pub g_l: f64,     // Leak conductance
53    pub g_tonic: f64, // Tonic GABA conductance
54    pub e_na: f64,
55    pub e_k: f64,
56    pub e_ca: f64,
57    pub e_h: f64, // Ih reversal (~-40 mV, mixed cation)
58    pub e_l: f64,
59    pub e_gaba: f64,
60    pub tau_ca: f64, // Ca²⁺ decay (ms)
61    pub kd_kca: f64, // K_Ca half-saturation (µM)
62    pub dt: f64,
63    pub sub_steps: usize,
64    pub gain: f64,
65}
66
67impl Default for GranuleCell {
68    fn default() -> Self {
69        Self::new()
70    }
71}
72
73impl GranuleCell {
74    pub fn new() -> Self {
75        Self {
76            v: -70.0,
77            m: 0.02,
78            h: 0.85,
79            n: 0.05,
80            a: 0.1,
81            b: 0.8,
82            m_t: 0.01,
83            s: 0.95,
84            ca: 0.05,
85            r: 0.1,
86            c_m: 1.0,
87            g_na: 17.0,   // mS/cm² (D'Angelo 2001 Table 1)
88            g_kdr: 9.0,   // Delayed rectifier
89            g_ka: 1.0,    // A-type K
90            g_t: 0.5,     // T-type Ca²⁺
91            g_kca: 3.5,   // Ca²⁺-activated K
92            g_h: 0.03,    // Ih (small in granule cells)
93            g_l: 0.1,     // Leak
94            g_tonic: 0.2, // Tonic GABA (strong tonic inhibition)
95            e_na: 87.4,   // D'Angelo 2001
96            e_k: -84.7,
97            e_ca: 129.3,
98            e_h: -40.0, // Mixed cation
99            e_l: -58.0, // D'Angelo 2001
100            e_gaba: -75.0,
101            tau_ca: 10.0, // Ca²⁺ decay
102            kd_kca: 0.2,  // K_Ca half-sat (µM)
103            dt: 0.5,
104            sub_steps: 4, // dt_sub = 0.125 ms
105            gain: 1.0,
106        }
107    }
108
109    /// Boltzmann steady-state.
110    #[inline]
111    fn boltz(v: f64, vh: f64, k: f64) -> f64 {
112        let z = -(v - vh) / k;
113        if z > 60.0 {
114            0.0
115        } else if z < -60.0 {
116            1.0
117        } else {
118            1.0 / (1.0 + z.exp())
119        }
120    }
121
122    fn is_valid(&self) -> bool {
123        [
124            self.v,
125            self.m,
126            self.h,
127            self.n,
128            self.a,
129            self.b,
130            self.m_t,
131            self.s,
132            self.ca,
133            self.r,
134            self.c_m,
135            self.g_na,
136            self.g_kdr,
137            self.g_ka,
138            self.g_t,
139            self.g_kca,
140            self.g_h,
141            self.g_l,
142            self.g_tonic,
143            self.e_na,
144            self.e_k,
145            self.e_ca,
146            self.e_h,
147            self.e_l,
148            self.e_gaba,
149            self.tau_ca,
150            self.kd_kca,
151            self.dt,
152            self.gain,
153        ]
154        .iter()
155        .all(|value| value.is_finite())
156            && [
157                self.m, self.h, self.n, self.a, self.b, self.m_t, self.s, self.r,
158            ]
159            .iter()
160            .all(|gate| (0.0..=1.0).contains(gate))
161            && (-100.0..=60.0).contains(&self.v)
162            && self.ca >= 0.0
163            && [
164                self.g_na,
165                self.g_kdr,
166                self.g_ka,
167                self.g_t,
168                self.g_kca,
169                self.g_h,
170                self.g_l,
171                self.g_tonic,
172            ]
173            .iter()
174            .all(|conductance| *conductance >= 0.0)
175            && self.c_m > 0.0
176            && self.tau_ca > 0.0
177            && self.kd_kca > 0.0
178            && self.dt > 0.0
179            && self.sub_steps > 0
180            && self.gain >= 0.0
181    }
182
183    pub fn step(&mut self, current: f64) -> i32 {
184        if !self.is_valid() || !current.is_finite() {
185            return 0;
186        }
187
188        let input = self.gain * current;
189        let dt_sub = self.dt / self.sub_steps as f64;
190        let v_prev = self.v;
191        let mut v = self.v;
192        let mut m = self.m;
193        let mut h = self.h;
194        let mut n = self.n;
195        let mut a = self.a;
196        let mut b = self.b;
197        let mut m_t = self.m_t;
198        let mut s_gate = self.s;
199        let mut ca = self.ca;
200        let mut r = self.r;
201
202        for _ in 0..self.sub_steps {
203            // Na m gate (fast activation, Boltzmann + tau)
204            let m_inf = Self::boltz(v, -30.0, 7.0);
205            let tau_m = 0.1 + 0.3 / (1.0 + ((v + 30.0) / 10.0).powi(2)).max(0.01);
206            m = granule_exact_relax(m, m_inf, tau_m, dt_sub).clamp(0.0, 1.0);
207
208            // Na h gate (inactivation)
209            let h_inf = Self::boltz(v, -52.0, -6.0);
210            let tau_h = 0.5 + 5.0 / (1.0 + ((v + 50.0) / 15.0).powi(2)).max(0.01);
211            h = granule_exact_relax(h, h_inf, tau_h, dt_sub).clamp(0.0, 1.0);
212
213            // K_dr n gate
214            let n_inf = Self::boltz(v, -35.0, 8.0);
215            let tau_n = 1.0 + 5.0 / (1.0 + ((v + 35.0) / 15.0).powi(2)).max(0.01);
216            n = granule_exact_relax(n, n_inf, tau_n, dt_sub).clamp(0.0, 1.0);
217
218            // K_A a gate (fast activation)
219            let a_inf = Self::boltz(v, -50.0, 20.0);
220            let tau_a = 2.0;
221            a = granule_exact_relax(a, a_inf, tau_a, dt_sub).clamp(0.0, 1.0);
222
223            // K_A b gate (slow inactivation)
224            let b_inf = Self::boltz(v, -70.0, -6.0);
225            let tau_b = 50.0;
226            b = granule_exact_relax(b, b_inf, tau_b, dt_sub).clamp(0.0, 1.0);
227
228            // T-type Ca²⁺ m_t (fast activation)
229            let mt_inf = Self::boltz(v, -52.0, 5.0);
230            let tau_mt = 1.0;
231            m_t = granule_exact_relax(m_t, mt_inf, tau_mt, dt_sub).clamp(0.0, 1.0);
232
233            // T-type Ca²⁺ s (slow inactivation)
234            let s_inf = Self::boltz(v, -60.0, -6.5);
235            let tau_s = 20.0 + 50.0 / (1.0 + ((v + 65.0) / 10.0).powi(2)).max(0.01);
236            s_gate = granule_exact_relax(s_gate, s_inf, tau_s, dt_sub).clamp(0.0, 1.0);
237
238            // Ih r gate (slow activation at hyperpolarised V)
239            let r_inf = Self::boltz(v, -80.0, -10.0);
240            let tau_r = 50.0 + 200.0 / (1.0 + ((v + 80.0) / 20.0).powi(2)).max(0.01);
241            r = granule_exact_relax(r, r_inf, tau_r, dt_sub).clamp(0.0, 1.0);
242
243            // Ca²⁺ dynamics
244            let i_ca_t = self.g_t * m_t * m_t * s_gate * (v - self.e_ca);
245            let ca_entry = if i_ca_t < 0.0 { -i_ca_t * 0.001 } else { 0.0 }; // Inward Ca²⁺
246            ca = granule_exact_relax(ca, ca_entry * self.tau_ca, self.tau_ca, dt_sub).max(0.0);
247
248            // K_Ca (Hill function of Ca²⁺)
249            let kca_inf = ca * ca / (ca * ca + self.kd_kca * self.kd_kca);
250
251            // Ionic conductances with exact voltage relaxation.
252            let g_na_eff = self.g_na * m.powi(3) * h;
253            let g_kdr_eff = self.g_kdr * n.powi(4);
254            let g_ka_eff = self.g_ka * a.powi(3) * b;
255            let g_t_eff = self.g_t * m_t * m_t * s_gate;
256            let g_kca_eff = self.g_kca * kca_inf;
257            let g_h_eff = self.g_h * r;
258            v = granule_exact_voltage_step(
259                v,
260                input,
261                self.c_m,
262                dt_sub,
263                &[
264                    (g_na_eff, self.e_na),
265                    (g_kdr_eff, self.e_k),
266                    (g_ka_eff, self.e_k),
267                    (g_t_eff, self.e_ca),
268                    (g_kca_eff, self.e_k),
269                    (g_h_eff, self.e_h),
270                    (self.g_l, self.e_l),
271                    (self.g_tonic, self.e_gaba),
272                ],
273            )
274            .clamp(-100.0, 60.0);
275
276            if ![v, m, h, n, a, b, m_t, s_gate, ca, r]
277                .iter()
278                .all(|value| value.is_finite())
279            {
280                return 0;
281            }
282        }
283
284        self.v = v;
285        self.m = m;
286        self.h = h;
287        self.n = n;
288        self.a = a;
289        self.b = b;
290        self.m_t = m_t;
291        self.s = s_gate;
292        self.ca = ca;
293        self.r = r;
294
295        // Spike: V crosses 0 mV
296        if self.v >= 0.0 && v_prev < 0.0 {
297            1
298        } else {
299            0
300        }
301    }
302
303    pub fn reset(&mut self) {
304        *self = Self::new();
305    }
306}
307
308fn granule_exact_relax(value: f64, target: f64, tau: f64, dt: f64) -> f64 {
309    target + (value - target) * (-dt / tau).exp()
310}
311
312fn granule_exact_voltage_step(
313    v: f64,
314    input_current: f64,
315    c_m: f64,
316    dt: f64,
317    conductances: &[(f64, f64)],
318) -> f64 {
319    let g_total: f64 = conductances.iter().map(|(g, _)| *g).sum();
320    if g_total <= 0.0 {
321        return v + dt * input_current / c_m;
322    }
323    let reversal_drive: f64 = conductances.iter().map(|(g, e_rev)| g * e_rev).sum();
324    let v_inf = (input_current + reversal_drive) / g_total;
325    v_inf + (v - v_inf) * (-dt * g_total / c_m).exp()
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    // -- Granule Cell tests --
333
334    #[test]
335    fn granule_fires_with_strong_input() {
336        let mut n = GranuleCell::new();
337        let mut spikes = 0;
338        for _ in 0..10_000 {
339            spikes += n.step(15.0);
340        }
341        assert!(
342            spikes > 10,
343            "Granule cell must fire with strong excitatory input, got {spikes}"
344        );
345    }
346
347    #[test]
348    fn granule_silent_at_rest() {
349        let mut n = GranuleCell::new();
350        let mut spikes = 0;
351        for _ in 0..10_000 {
352            spikes += n.step(0.0);
353        }
354        assert_eq!(
355            spikes, 0,
356            "Granule cell must be silent without input (tonic GABA inhibition)"
357        );
358    }
359
360    #[test]
361    fn granule_no_fire_weak_input() {
362        // Tonic GABA raises effective threshold
363        let mut n = GranuleCell::new();
364        let mut spikes = 0;
365        for _ in 0..10_000 {
366            spikes += n.step(1.0);
367        }
368        assert!(
369            spikes == 0,
370            "Weak input should not overcome tonic GABA, got {spikes}"
371        );
372    }
373
374    #[test]
375    fn granule_tonic_gaba_raises_threshold() {
376        // Compare firing with and without tonic GABA
377        let mut with_gaba = GranuleCell::new();
378        let mut no_gaba = GranuleCell::new();
379        no_gaba.g_tonic = 0.0;
380
381        let input = 8.0;
382        let mut spikes_gaba = 0;
383        let mut spikes_no_gaba = 0;
384        for _ in 0..10_000 {
385            spikes_gaba += with_gaba.step(input);
386            spikes_no_gaba += no_gaba.step(input);
387        }
388        assert!(
389            spikes_no_gaba > spikes_gaba,
390            "Removing tonic GABA must increase firing: no_gaba={spikes_no_gaba} vs gaba={spikes_gaba}"
391        );
392    }
393
394    #[test]
395    fn granule_has_seven_currents() {
396        // D'Angelo 2001 model must have all 7 ionic currents
397        let n = GranuleCell::new();
398        assert!(n.g_na > 0.0, "Must have INa");
399        assert!(n.g_kdr > 0.0, "Must have IK_dr");
400        assert!(n.g_ka > 0.0, "Must have IK_A");
401        assert!(n.g_t > 0.0, "Must have ICa_T");
402        assert!(n.g_kca > 0.0, "Must have IK_Ca");
403        assert!(n.g_h > 0.0, "Must have Ih");
404        assert!(n.g_l > 0.0, "Must have IL");
405    }
406
407    #[test]
408    fn granule_t_type_deinactivates_at_rest() {
409        // T-type inactivation s should be high at rest (de-inactivated)
410        let mut n = GranuleCell::new();
411        for _ in 0..5000 {
412            n.step(0.0);
413        }
414        assert!(
415            n.s > 0.5,
416            "T-type must be partially de-inactivated at rest, s={}",
417            n.s
418        );
419    }
420
421    #[test]
422    fn granule_gate_and_calcium_kinetics_use_closed_form_relaxation() {
423        let mut n = GranuleCell::new();
424        n.g_na = 0.0;
425        n.g_kdr = 0.0;
426        n.g_ka = 0.0;
427        n.g_t = 0.0;
428        n.g_kca = 0.0;
429        n.g_h = 0.0;
430        n.g_l = 0.0;
431        n.g_tonic = 0.0;
432        n.gain = 0.0;
433        n.sub_steps = 1;
434        let (v0, m0, h0, n0, a0, b0, mt0, s0, ca0, r0) =
435            (n.v, n.m, n.h, n.n, n.a, n.b, n.m_t, n.s, n.ca, n.r);
436        let m_inf = GranuleCell::boltz(v0, -30.0, 7.0);
437        let tau_m = 0.1 + 0.3 / (1.0 + ((v0 + 30.0) / 10.0).powi(2)).max(0.01);
438        let h_inf = GranuleCell::boltz(v0, -52.0, -6.0);
439        let tau_h = 0.5 + 5.0 / (1.0 + ((v0 + 50.0) / 15.0).powi(2)).max(0.01);
440        let n_inf = GranuleCell::boltz(v0, -35.0, 8.0);
441        let tau_n = 1.0 + 5.0 / (1.0 + ((v0 + 35.0) / 15.0).powi(2)).max(0.01);
442        let a_inf = GranuleCell::boltz(v0, -50.0, 20.0);
443        let b_inf = GranuleCell::boltz(v0, -70.0, -6.0);
444        let mt_inf = GranuleCell::boltz(v0, -52.0, 5.0);
445        let s_inf = GranuleCell::boltz(v0, -60.0, -6.5);
446        let tau_s = 20.0 + 50.0 / (1.0 + ((v0 + 65.0) / 10.0).powi(2)).max(0.01);
447        let r_inf = GranuleCell::boltz(v0, -80.0, -10.0);
448        let tau_r = 50.0 + 200.0 / (1.0 + ((v0 + 80.0) / 20.0).powi(2)).max(0.01);
449
450        n.step(0.0);
451
452        assert_close_granule(n.v, v0);
453        assert_close_granule(n.m, granule_exact_relax(m0, m_inf, tau_m, n.dt));
454        assert_close_granule(n.h, granule_exact_relax(h0, h_inf, tau_h, n.dt));
455        assert_close_granule(n.n, granule_exact_relax(n0, n_inf, tau_n, n.dt));
456        assert_close_granule(n.a, granule_exact_relax(a0, a_inf, 2.0, n.dt));
457        assert_close_granule(n.b, granule_exact_relax(b0, b_inf, 50.0, n.dt));
458        assert_close_granule(n.m_t, granule_exact_relax(mt0, mt_inf, 1.0, n.dt));
459        assert_close_granule(n.s, granule_exact_relax(s0, s_inf, tau_s, n.dt));
460        assert_close_granule(n.ca, granule_exact_relax(ca0, 0.0, n.tau_ca, n.dt));
461        assert_close_granule(n.r, granule_exact_relax(r0, r_inf, tau_r, n.dt));
462    }
463
464    #[test]
465    fn granule_ca_rises_with_spiking() {
466        // Ca²⁺ should increase during spiking activity
467        let mut n = GranuleCell::new();
468        let ca0 = n.ca;
469        for _ in 0..5000 {
470            n.step(8.0);
471        }
472        assert!(
473            n.ca > ca0,
474            "Ca²⁺ should rise in the T-current firing regime: ca0={ca0}, ca_now={}",
475            n.ca
476        );
477    }
478
479    #[test]
480    fn granule_negative_input_no_crash() {
481        let mut n = GranuleCell::new();
482        for _ in 0..10_000 {
483            n.step(-100.0);
484        }
485        assert!(n.v.is_finite(), "Must stay finite with negative input");
486        assert!(n.v >= -100.0, "Must be bounded");
487    }
488
489    #[test]
490    fn granule_nan_input_stays_finite() {
491        let mut n = GranuleCell::new();
492        let before = n.clone();
493        n.step(f64::NAN);
494        assert!(n.v.is_finite(), "NaN input must not corrupt state");
495        assert_eq!(n.v, before.v);
496        assert_eq!(n.ca, before.ca);
497        assert_eq!(n.s, before.s);
498    }
499
500    #[test]
501    fn granule_corrupted_state_preserved_on_step() {
502        let mut n = GranuleCell::new();
503        n.m = -0.1;
504        let before = n.clone();
505        assert_eq!(n.step(10.0), 0);
506        assert_eq!(n.v, before.v);
507        assert_eq!(n.m, before.m);
508        assert_eq!(n.ca, before.ca);
509    }
510
511    #[test]
512    fn granule_extreme_input_bounded() {
513        let mut n = GranuleCell::new();
514        for _ in 0..1000 {
515            n.step(1e6);
516        }
517        assert!(
518            n.v.is_finite() && n.v <= 60.0,
519            "Extreme input must stay bounded"
520        );
521    }
522
523    #[test]
524    fn granule_reset_clears_state() {
525        let mut n = GranuleCell::new();
526        for _ in 0..1000 {
527            n.step(20.0);
528        }
529        n.reset();
530        assert_eq!(n.v, -70.0);
531        assert_eq!(n.s, 0.95);
532        assert_eq!(n.m, 0.02);
533    }
534
535    #[test]
536    fn granule_high_input_resistance() {
537        // Small soma → large voltage response to small current
538        let mut n = GranuleCell::new();
539        let v_before = n.v;
540        // Single step with moderate input
541        n.step(5.0);
542        let dv = n.v - v_before;
543        assert!(
544            dv > 0.5,
545            "High Rin should give large voltage change, got dv={dv}"
546        );
547    }
548
549    #[test]
550    fn granule_performance_10k_steps() {
551        let start = std::time::Instant::now();
552        let mut n = GranuleCell::new();
553        for _ in 0..10_000 {
554            std::hint::black_box(n.step(10.0));
555        }
556        let elapsed = start.elapsed();
557        assert!(
558            elapsed.as_millis() < 100,
559            "10k exact-integrator steps must complete in <100ms, took {}ms",
560            elapsed.as_millis()
561        );
562    }
563
564    fn assert_close_granule(observed: f64, expected: f64) {
565        assert!(
566            (observed - expected).abs() <= 1.0e-12,
567            "observed {:.17e}, expected {:.17e}",
568            observed,
569            expected,
570        );
571    }
572
573    #[test]
574    fn granule_default_matches_constructor_contract() {
575        let default = GranuleCell::default();
576        let constructed = GranuleCell::new();
577        assert_eq!(default.v, constructed.v);
578        assert_eq!(default.ca, constructed.ca);
579        assert_eq!(default.dt, constructed.dt);
580        assert_eq!(default.sub_steps, constructed.sub_steps);
581    }
582}