Skip to main content

sc_neurocore_engine/neurons/
cerebellar.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//! Cerebellar circuit neuron models for granular and molecular layer computations.
10//!
11//! Cerebellar model group: granule cell, Golgi cell, stellate cell, Lugaro cell,
12//! unipolar brush cell, deep cerebellar nuclei neuron.
13//! Added one by one with full 7-point checklist verification.
14
15use super::biophysical::safe_rate;
16
17// ═══════════════════════════════════════════════════════════════════
18// Granule Cell
19// ═══════════════════════════════════════════════════════════════════
20
21/// Cerebellar granule cell — D'Angelo et al. 2001 full model.
22///
23/// Most numerous neuron in the brain (~50%). Tiny soma (6-8 µm),
24/// four short dendrites receiving mossy fibre input at glomeruli,
25/// output via parallel fibres to Purkinje cells.
26///
27/// Full Hodgkin-Huxley-type model with 7 ionic currents:
28/// - **INa** (transient Na, m³h): fast spike generation
29/// - **IK_dr** (delayed rectifier K, n⁴): repolarisation
30/// - **IK_A** (A-type K, a³b): delay to first spike, inter-spike interval
31/// - **ICa_T** (T-type Ca²⁺, m_t²s): post-inhibitory rebound bursting
32/// - **IK_Ca** (Ca²⁺-activated K, Hill): slow AHP
33/// - **Ih** (HCN, r): sag current, resting potential stabilisation
34/// - **IL** (leak)
35/// - **IGABA** (tonic GABA from Golgi cells)
36///
37/// Uses 4 sub-steps (dt_sub = 0.125 ms) for Na gating stability.
38///
39/// D'Angelo et al., J Neurosci 21(3):759, 2001.
40/// D'Angelo & De Zeeuw, Trends Neurosci 32:30, 2009 (review).
41#[derive(Clone, Debug)]
42pub struct GranuleCell {
43    pub v: f64,       // Membrane potential (mV)
44    pub m: f64,       // Na activation
45    pub h: f64,       // Na inactivation
46    pub n: f64,       // K_dr activation
47    pub a: f64,       // K_A activation
48    pub b: f64,       // K_A inactivation
49    pub m_t: f64,     // T-type Ca²⁺ activation
50    pub s: f64,       // T-type Ca²⁺ inactivation
51    pub ca: f64,      // Intracellular Ca²⁺ (µM)
52    pub r: f64,       // Ih activation
53    pub c_m: f64,     // Capacitance (µF/cm²)
54    pub g_na: f64,    // Na conductance
55    pub g_kdr: f64,   // K_dr conductance
56    pub g_ka: f64,    // K_A conductance
57    pub g_t: f64,     // T-type Ca²⁺ conductance
58    pub g_kca: f64,   // Ca²⁺-dependent K conductance
59    pub g_h: f64,     // Ih conductance
60    pub g_l: f64,     // Leak conductance
61    pub g_tonic: f64, // Tonic GABA conductance
62    pub e_na: f64,
63    pub e_k: f64,
64    pub e_ca: f64,
65    pub e_h: f64, // Ih reversal (~-40 mV, mixed cation)
66    pub e_l: f64,
67    pub e_gaba: f64,
68    pub tau_ca: f64, // Ca²⁺ decay (ms)
69    pub kd_kca: f64, // K_Ca half-saturation (µM)
70    pub dt: f64,
71    pub sub_steps: usize,
72    pub gain: f64,
73}
74
75impl Default for GranuleCell {
76    fn default() -> Self {
77        Self::new()
78    }
79}
80
81impl GranuleCell {
82    pub fn new() -> Self {
83        Self {
84            v: -70.0,
85            m: 0.02,
86            h: 0.85,
87            n: 0.05,
88            a: 0.1,
89            b: 0.8,
90            m_t: 0.01,
91            s: 0.95,
92            ca: 0.05,
93            r: 0.1,
94            c_m: 1.0,
95            g_na: 17.0,   // mS/cm² (D'Angelo 2001 Table 1)
96            g_kdr: 9.0,   // Delayed rectifier
97            g_ka: 1.0,    // A-type K
98            g_t: 0.5,     // T-type Ca²⁺
99            g_kca: 3.5,   // Ca²⁺-activated K
100            g_h: 0.03,    // Ih (small in granule cells)
101            g_l: 0.1,     // Leak
102            g_tonic: 0.2, // Tonic GABA (strong tonic inhibition)
103            e_na: 87.4,   // D'Angelo 2001
104            e_k: -84.7,
105            e_ca: 129.3,
106            e_h: -40.0, // Mixed cation
107            e_l: -58.0, // D'Angelo 2001
108            e_gaba: -75.0,
109            tau_ca: 10.0, // Ca²⁺ decay
110            kd_kca: 0.2,  // K_Ca half-sat (µM)
111            dt: 0.5,
112            sub_steps: 4, // dt_sub = 0.125 ms
113            gain: 1.0,
114        }
115    }
116
117    /// Boltzmann steady-state.
118    #[inline]
119    fn boltz(v: f64, vh: f64, k: f64) -> f64 {
120        let z = -(v - vh) / k;
121        if z > 60.0 {
122            0.0
123        } else if z < -60.0 {
124            1.0
125        } else {
126            1.0 / (1.0 + z.exp())
127        }
128    }
129
130    fn is_valid(&self) -> bool {
131        [
132            self.v,
133            self.m,
134            self.h,
135            self.n,
136            self.a,
137            self.b,
138            self.m_t,
139            self.s,
140            self.ca,
141            self.r,
142            self.c_m,
143            self.g_na,
144            self.g_kdr,
145            self.g_ka,
146            self.g_t,
147            self.g_kca,
148            self.g_h,
149            self.g_l,
150            self.g_tonic,
151            self.e_na,
152            self.e_k,
153            self.e_ca,
154            self.e_h,
155            self.e_l,
156            self.e_gaba,
157            self.tau_ca,
158            self.kd_kca,
159            self.dt,
160            self.gain,
161        ]
162        .iter()
163        .all(|value| value.is_finite())
164            && [
165                self.m, self.h, self.n, self.a, self.b, self.m_t, self.s, self.r,
166            ]
167            .iter()
168            .all(|gate| (0.0..=1.0).contains(gate))
169            && (-100.0..=60.0).contains(&self.v)
170            && self.ca >= 0.0
171            && [
172                self.g_na,
173                self.g_kdr,
174                self.g_ka,
175                self.g_t,
176                self.g_kca,
177                self.g_h,
178                self.g_l,
179                self.g_tonic,
180            ]
181            .iter()
182            .all(|conductance| *conductance >= 0.0)
183            && self.c_m > 0.0
184            && self.tau_ca > 0.0
185            && self.kd_kca > 0.0
186            && self.dt > 0.0
187            && self.sub_steps > 0
188            && self.gain >= 0.0
189    }
190
191    pub fn step(&mut self, current: f64) -> i32 {
192        if !self.is_valid() || !current.is_finite() {
193            return 0;
194        }
195
196        let input = self.gain * current;
197        let dt_sub = self.dt / self.sub_steps as f64;
198        let v_prev = self.v;
199        let mut v = self.v;
200        let mut m = self.m;
201        let mut h = self.h;
202        let mut n = self.n;
203        let mut a = self.a;
204        let mut b = self.b;
205        let mut m_t = self.m_t;
206        let mut s_gate = self.s;
207        let mut ca = self.ca;
208        let mut r = self.r;
209
210        for _ in 0..self.sub_steps {
211            // Na m gate (fast activation, Boltzmann + tau)
212            let m_inf = Self::boltz(v, -30.0, 7.0);
213            let tau_m = 0.1 + 0.3 / (1.0 + ((v + 30.0) / 10.0).powi(2)).max(0.01);
214            m = granule_exact_relax(m, m_inf, tau_m, dt_sub).clamp(0.0, 1.0);
215
216            // Na h gate (inactivation)
217            let h_inf = Self::boltz(v, -52.0, -6.0);
218            let tau_h = 0.5 + 5.0 / (1.0 + ((v + 50.0) / 15.0).powi(2)).max(0.01);
219            h = granule_exact_relax(h, h_inf, tau_h, dt_sub).clamp(0.0, 1.0);
220
221            // K_dr n gate
222            let n_inf = Self::boltz(v, -35.0, 8.0);
223            let tau_n = 1.0 + 5.0 / (1.0 + ((v + 35.0) / 15.0).powi(2)).max(0.01);
224            n = granule_exact_relax(n, n_inf, tau_n, dt_sub).clamp(0.0, 1.0);
225
226            // K_A a gate (fast activation)
227            let a_inf = Self::boltz(v, -50.0, 20.0);
228            let tau_a = 2.0;
229            a = granule_exact_relax(a, a_inf, tau_a, dt_sub).clamp(0.0, 1.0);
230
231            // K_A b gate (slow inactivation)
232            let b_inf = Self::boltz(v, -70.0, -6.0);
233            let tau_b = 50.0;
234            b = granule_exact_relax(b, b_inf, tau_b, dt_sub).clamp(0.0, 1.0);
235
236            // T-type Ca²⁺ m_t (fast activation)
237            let mt_inf = Self::boltz(v, -52.0, 5.0);
238            let tau_mt = 1.0;
239            m_t = granule_exact_relax(m_t, mt_inf, tau_mt, dt_sub).clamp(0.0, 1.0);
240
241            // T-type Ca²⁺ s (slow inactivation)
242            let s_inf = Self::boltz(v, -60.0, -6.5);
243            let tau_s = 20.0 + 50.0 / (1.0 + ((v + 65.0) / 10.0).powi(2)).max(0.01);
244            s_gate = granule_exact_relax(s_gate, s_inf, tau_s, dt_sub).clamp(0.0, 1.0);
245
246            // Ih r gate (slow activation at hyperpolarised V)
247            let r_inf = Self::boltz(v, -80.0, -10.0);
248            let tau_r = 50.0 + 200.0 / (1.0 + ((v + 80.0) / 20.0).powi(2)).max(0.01);
249            r = granule_exact_relax(r, r_inf, tau_r, dt_sub).clamp(0.0, 1.0);
250
251            // Ca²⁺ dynamics
252            let i_ca_t = self.g_t * m_t * m_t * s_gate * (v - self.e_ca);
253            let ca_entry = if i_ca_t < 0.0 { -i_ca_t * 0.001 } else { 0.0 }; // Inward Ca²⁺
254            ca = granule_exact_relax(ca, ca_entry * self.tau_ca, self.tau_ca, dt_sub).max(0.0);
255
256            // K_Ca (Hill function of Ca²⁺)
257            let kca_inf = ca * ca / (ca * ca + self.kd_kca * self.kd_kca);
258
259            // Ionic conductances with exact voltage relaxation.
260            let g_na_eff = self.g_na * m.powi(3) * h;
261            let g_kdr_eff = self.g_kdr * n.powi(4);
262            let g_ka_eff = self.g_ka * a.powi(3) * b;
263            let g_t_eff = self.g_t * m_t * m_t * s_gate;
264            let g_kca_eff = self.g_kca * kca_inf;
265            let g_h_eff = self.g_h * r;
266            v = granule_exact_voltage_step(
267                v,
268                input,
269                self.c_m,
270                dt_sub,
271                &[
272                    (g_na_eff, self.e_na),
273                    (g_kdr_eff, self.e_k),
274                    (g_ka_eff, self.e_k),
275                    (g_t_eff, self.e_ca),
276                    (g_kca_eff, self.e_k),
277                    (g_h_eff, self.e_h),
278                    (self.g_l, self.e_l),
279                    (self.g_tonic, self.e_gaba),
280                ],
281            )
282            .clamp(-100.0, 60.0);
283
284            if ![v, m, h, n, a, b, m_t, s_gate, ca, r]
285                .iter()
286                .all(|value| value.is_finite())
287            {
288                return 0;
289            }
290        }
291
292        self.v = v;
293        self.m = m;
294        self.h = h;
295        self.n = n;
296        self.a = a;
297        self.b = b;
298        self.m_t = m_t;
299        self.s = s_gate;
300        self.ca = ca;
301        self.r = r;
302
303        // Spike: V crosses 0 mV
304        if self.v >= 0.0 && v_prev < 0.0 {
305            1
306        } else {
307            0
308        }
309    }
310
311    pub fn reset(&mut self) {
312        *self = Self::new();
313    }
314}
315
316fn granule_exact_relax(value: f64, target: f64, tau: f64, dt: f64) -> f64 {
317    target + (value - target) * (-dt / tau).exp()
318}
319
320fn granule_exact_voltage_step(
321    v: f64,
322    input_current: f64,
323    c_m: f64,
324    dt: f64,
325    conductances: &[(f64, f64)],
326) -> f64 {
327    let g_total: f64 = conductances.iter().map(|(g, _)| *g).sum();
328    if g_total <= 0.0 {
329        return v + dt * input_current / c_m;
330    }
331    let reversal_drive: f64 = conductances.iter().map(|(g, e_rev)| g * e_rev).sum();
332    let v_inf = (input_current + reversal_drive) / g_total;
333    v_inf + (v - v_inf) * (-dt * g_total / c_m).exp()
334}
335
336// ═══════════════════════════════════════════════════════════════════
337// Golgi Cell
338// ═══════════════════════════════════════════════════════════════════
339
340/// Cerebellar Golgi cell — Solinas et al. 2007 full model.
341///
342/// Large inhibitory interneuron in the granular layer. Provides tonic
343/// and phasic GABAergic/glycinergic inhibition to granule cells.
344/// Spontaneously active at 3-10 Hz due to intrinsic pacemaker currents.
345///
346/// Full Solinas 2007 model with 11 ionic currents:
347/// - **INa_t** (transient Na, m³h): fast spike generation
348/// - **INa_p** (persistent Na, p): subthreshold oscillations, pacemaking
349/// - **IK_dr** (delayed rectifier K, n⁴): repolarisation
350/// - **IK_A** (A-type K, a³b): onset delay, inter-spike interval
351/// - **IK_M** (muscarinic/slow K, w): spike frequency adaptation
352/// - **ICa_T** (T-type Ca²⁺, m_t²s): rebound, subthreshold oscillations
353/// - **ICa_N** (N-type Ca²⁺, c²): high-voltage activated, AHP trigger
354/// - **IBK** (BK, Ca²⁺+V dependent): fast AHP
355/// - **ISK** (SK, Ca²⁺ dependent): slow AHP, pacemaker regulation
356/// - **Ih** (HCN, r): sag, resting potential, pacemaker contribution
357/// - **IL** (leak)
358///
359/// 10 sub-steps (dt_sub = 0.05 ms) for Na gating stability.
360///
361/// Solinas et al., Front Cell Neurosci 1:2, 2007.
362#[derive(Clone, Debug)]
363pub struct GolgiCell {
364    pub v: f64,
365    pub m: f64,    // Na_t activation
366    pub h: f64,    // Na_t inactivation
367    pub p_na: f64, // Na_p persistent activation
368    pub n: f64,    // K_dr activation
369    pub a: f64,    // K_A activation
370    pub b: f64,    // K_A inactivation
371    pub w: f64,    // K_M (muscarinic) activation
372    pub m_t: f64,  // Ca_T activation
373    pub s: f64,    // Ca_T inactivation
374    pub c_n: f64,  // Ca_N activation
375    pub r: f64,    // Ih activation
376    pub ca: f64,   // Intracellular Ca²⁺ (µM)
377    // Conductances (mS/cm²)
378    pub g_na_t: f64,
379    pub g_na_p: f64,
380    pub g_kdr: f64,
381    pub g_ka: f64,
382    pub g_km: f64,
383    pub g_cat: f64,
384    pub g_can: f64,
385    pub g_bk: f64,
386    pub g_sk: f64,
387    pub g_h: f64,
388    pub g_l: f64,
389    // Reversals
390    pub e_na: f64,
391    pub e_k: f64,
392    pub e_ca: f64,
393    pub e_h: f64,
394    pub e_l: f64,
395    pub c_m: f64,
396    pub tau_ca: f64,
397    pub kd_bk: f64,
398    pub kd_sk: f64,
399    pub dt: f64,
400    pub sub_steps: usize,
401    pub gain: f64,
402}
403
404impl Default for GolgiCell {
405    fn default() -> Self {
406        Self::new()
407    }
408}
409
410impl GolgiCell {
411    pub fn new() -> Self {
412        Self {
413            v: -60.0,
414            m: 0.02,
415            h: 0.85,
416            p_na: 0.01,
417            n: 0.05,
418            a: 0.1,
419            b: 0.8,
420            w: 0.01,
421            m_t: 0.01,
422            s: 0.9,
423            c_n: 0.01,
424            r: 0.1,
425            ca: 0.05,
426            g_na_t: 48.0, // Solinas 2007 Table 1
427            g_na_p: 0.2,  // Persistent Na (small but critical for pacemaking)
428            g_kdr: 16.0,
429            g_ka: 8.0,  // A-type
430            g_km: 1.0,  // Muscarinic slow K
431            g_cat: 0.5, // T-type Ca²⁺
432            g_can: 1.0, // N-type Ca²⁺ (high-voltage)
433            g_bk: 3.0,  // BK fast AHP
434            g_sk: 1.0,  // SK slow AHP
435            g_h: 0.1,   // Ih
436            g_l: 0.05,
437            e_na: 55.0,
438            e_k: -90.0,
439            e_ca: 120.0,
440            e_h: -40.0,
441            e_l: -55.0, // Depolarised leak for spontaneous activity
442            c_m: 1.0,
443            tau_ca: 200.0,
444            kd_bk: 1.0,
445            kd_sk: 0.5,
446            dt: 0.5,
447            sub_steps: 10,
448            gain: 1.0,
449        }
450    }
451
452    #[inline]
453    fn boltz(v: f64, vh: f64, k: f64) -> f64 {
454        let x = (v - vh) / k;
455        if x >= 0.0 {
456            1.0 / (1.0 + (-x).exp())
457        } else {
458            let ex = x.exp();
459            ex / (1.0 + ex)
460        }
461    }
462
463    #[inline]
464    fn voltage_valid(value: f64) -> bool {
465        value.is_finite() && (-100.0..=60.0).contains(&value)
466    }
467
468    #[inline]
469    fn probability(value: f64) -> bool {
470        value.is_finite() && (0.0..=1.0).contains(&value)
471    }
472
473    #[inline]
474    fn gate_alpha_beta(previous: f64, alpha: f64, beta: f64, phi: f64, dt: f64) -> Option<f64> {
475        let total = phi * (alpha + beta);
476        if !previous.is_finite()
477            || !alpha.is_finite()
478            || !beta.is_finite()
479            || !total.is_finite()
480            || !dt.is_finite()
481            || total <= 0.0
482        {
483            return None;
484        }
485        let steady = alpha / (alpha + beta);
486        Some((steady + (previous - steady) * (-total * dt).exp()).clamp(0.0, 1.0))
487    }
488
489    #[inline]
490    fn gate_inf(previous: f64, steady: f64, tau: f64, dt: f64) -> Option<f64> {
491        if !previous.is_finite()
492            || !steady.is_finite()
493            || !tau.is_finite()
494            || !dt.is_finite()
495            || tau <= 0.0
496        {
497            return None;
498        }
499        Some((steady + (previous - steady) * (-dt / tau).exp()).clamp(0.0, 1.0))
500    }
501
502    #[inline]
503    fn calcium_exact(previous: f64, entry: f64, tau: f64, dt: f64) -> Option<f64> {
504        if !previous.is_finite()
505            || !entry.is_finite()
506            || !tau.is_finite()
507            || !dt.is_finite()
508            || tau <= 0.0
509            || previous < 0.0
510        {
511            return None;
512        }
513        let steady = entry * tau;
514        let value = steady + (previous - steady) * (-dt / tau).exp();
515        value.is_finite().then_some(value.max(0.0))
516    }
517
518    fn valid_state(&self) -> bool {
519        Self::voltage_valid(self.v)
520            && [
521                self.m, self.h, self.p_na, self.n, self.a, self.b, self.w, self.m_t, self.s,
522                self.c_n, self.r,
523            ]
524            .into_iter()
525            .all(Self::probability)
526            && [
527                self.g_na_t,
528                self.g_na_p,
529                self.g_kdr,
530                self.g_ka,
531                self.g_km,
532                self.g_cat,
533                self.g_can,
534                self.g_bk,
535                self.g_sk,
536                self.g_h,
537                self.g_l,
538            ]
539            .into_iter()
540            .all(|g| g.is_finite() && g >= 0.0)
541            && self.ca.is_finite()
542            && self.ca >= 0.0
543            && self.e_na.is_finite()
544            && self.e_k.is_finite()
545            && self.e_ca.is_finite()
546            && self.e_h.is_finite()
547            && self.e_l.is_finite()
548            && self.c_m.is_finite()
549            && self.tau_ca.is_finite()
550            && self.kd_bk.is_finite()
551            && self.kd_sk.is_finite()
552            && self.dt.is_finite()
553            && self.gain.is_finite()
554            && self.c_m > 0.0
555            && self.tau_ca > 0.0
556            && self.kd_bk > 0.0
557            && self.kd_sk > 0.0
558            && self.dt > 0.0
559            && self.sub_steps > 0
560            && self.gain >= 0.0
561    }
562
563    pub fn step(&mut self, current: f64) -> i32 {
564        if !current.is_finite() || !self.valid_state() {
565            return 0;
566        }
567
568        let input = self.gain * current;
569        let dt_sub = self.dt / self.sub_steps as f64;
570        let v_prev = self.v;
571        let mut next = self.clone();
572
573        for _ in 0..self.sub_steps {
574            let v = next.v;
575
576            // Na_t: m³h (fast, WB-style alpha/beta)
577            let alpha_m = safe_rate(0.1, 35.0, v, 10.0, 1.0);
578            let beta_m = 4.0 * (-(v + 60.0) / 18.0).exp();
579            let alpha_h = 0.07 * (-(v + 58.0) / 20.0).exp();
580            let beta_h = 1.0 / (1.0 + (-(v + 28.0) / 10.0).exp());
581            let Some(m) = Self::gate_alpha_beta(next.m, alpha_m, beta_m, 5.0, dt_sub) else {
582                return 0;
583            };
584            let Some(h) = Self::gate_alpha_beta(next.h, alpha_h, beta_h, 5.0, dt_sub) else {
585                return 0;
586            };
587
588            // Na_p: persistent (Boltzmann, slow)
589            let pna_inf = Self::boltz(v, -48.0, 5.0);
590            let tau_pna = 5.0 + 20.0 / (1.0 + ((v + 48.0) / 10.0).powi(2)).max(0.01);
591            let Some(p_na) = Self::gate_inf(next.p_na, pna_inf, tau_pna, dt_sub) else {
592                return 0;
593            };
594
595            // K_dr: n⁴
596            let alpha_n = safe_rate(0.01, 34.0, v, 10.0, 0.1);
597            let beta_n = 0.125 * (-(v + 44.0) / 80.0).exp();
598            let Some(n) = Self::gate_alpha_beta(next.n, alpha_n, beta_n, 5.0, dt_sub) else {
599                return 0;
600            };
601
602            // K_A: a³b (Solinas 2007: V1/2_act ≈ -27 mV, V1/2_inact ≈ -80 mV)
603            let a_inf = Self::boltz(v, -27.0, 16.0);
604            let b_inf = Self::boltz(v, -80.0, -6.0);
605            let Some(a) = Self::gate_inf(next.a, a_inf, 2.0, dt_sub) else {
606                return 0;
607            };
608            let Some(b) = Self::gate_inf(next.b, b_inf, 15.0, dt_sub) else {
609                return 0;
610            };
611
612            // K_M: w (slow muscarinic)
613            let w_inf = Self::boltz(v, -35.0, 10.0);
614            let tau_w = 100.0 / (3.3 * ((v + 35.0) / 20.0).exp() + (-(v + 35.0) / 20.0).exp());
615            let Some(w) = Self::gate_inf(next.w, w_inf, tau_w, dt_sub) else {
616                return 0;
617            };
618
619            // Ca_T: m_t²s
620            let mt_inf = Self::boltz(v, -52.0, 5.0);
621            let s_inf = Self::boltz(v, -60.0, -6.5);
622            let tau_s = 20.0 + 50.0 / (1.0 + ((v + 65.0) / 10.0).powi(2)).max(0.01);
623            let Some(m_t) = Self::gate_inf(next.m_t, mt_inf, 1.0, dt_sub) else {
624                return 0;
625            };
626            let Some(s) = Self::gate_inf(next.s, s_inf, tau_s, dt_sub) else {
627                return 0;
628            };
629
630            // Ca_N: c² (high-voltage activated)
631            let cn_inf = Self::boltz(v, -20.0, 5.0);
632            let tau_cn = 2.0 + 10.0 / (1.0 + ((v + 20.0) / 10.0).powi(2)).max(0.01);
633            let Some(c_n) = Self::gate_inf(next.c_n, cn_inf, tau_cn, dt_sub) else {
634                return 0;
635            };
636
637            // Ih: r (slow, hyperpolarisation-activated)
638            let r_inf = Self::boltz(v, -80.0, -10.0);
639            let tau_r = 50.0 + 200.0 / (1.0 + ((v + 80.0) / 20.0).powi(2)).max(0.01);
640            let Some(r) = Self::gate_inf(next.r, r_inf, tau_r, dt_sub) else {
641                return 0;
642            };
643
644            // Ca²⁺ dynamics (entry via Ca_T + Ca_N, decay)
645            let g_cat = self.g_cat * m_t.powi(2) * s;
646            let g_can = self.g_can * c_n.powi(2);
647            let i_cat = g_cat * (v - self.e_ca);
648            let i_can = g_can * (v - self.e_ca);
649            let ca_entry = if i_cat + i_can < 0.0 {
650                -(i_cat + i_can) * 0.001
651            } else {
652                0.0
653            };
654            let Some(ca) = Self::calcium_exact(next.ca, ca_entry, self.tau_ca, dt_sub) else {
655                return 0;
656            };
657
658            // BK: voltage + Ca²⁺ dependent (Hill n=2 for Ca²⁺ shift)
659            // V1/2 shifts from +100 mV (low Ca) to -20 mV (high Ca)
660            let ca2 = ca * ca;
661            let kd2 = self.kd_bk * self.kd_bk;
662            let bk_v = Self::boltz(v, 100.0 - 120.0 * ca2 / (ca2 + kd2), 15.0);
663            // SK: Ca²⁺ dependent (Hill n=2)
664            let sk_inf = ca2 / (ca2 + self.kd_sk.powi(2));
665
666            // All ionic currents
667            let g_na = self.g_na_t * m.powi(3) * h + self.g_na_p * p_na;
668            let g_k = self.g_kdr * n.powi(4)
669                + self.g_ka * a.powi(3) * b
670                + self.g_km * w
671                + self.g_bk * bk_v
672                + self.g_sk * sk_inf;
673            let g_ca = g_cat + g_can;
674            let g_h = self.g_h * r;
675            let g_total = g_na + g_k + g_ca + g_h + self.g_l;
676            if !g_total.is_finite() || g_total <= 0.0 {
677                return 0;
678            }
679            let steady_v = (input
680                + g_na * self.e_na
681                + g_k * self.e_k
682                + g_ca * self.e_ca
683                + g_h * self.e_h
684                + self.g_l * self.e_l)
685                / g_total;
686            let v_next = steady_v + (v - steady_v) * (-(g_total / self.c_m) * dt_sub).exp();
687            if !Self::voltage_valid(v_next) || !ca.is_finite() || ca < 0.0 {
688                return 0;
689            }
690
691            next.v = v_next;
692            next.m = m;
693            next.h = h;
694            next.p_na = p_na;
695            next.n = n;
696            next.a = a;
697            next.b = b;
698            next.w = w;
699            next.m_t = m_t;
700            next.s = s;
701            next.c_n = c_n;
702            next.r = r;
703            next.ca = ca;
704        }
705
706        *self = next;
707
708        // Spike: V crosses 0 mV
709        if self.v >= 0.0 && v_prev < 0.0 {
710            1
711        } else {
712            0
713        }
714    }
715
716    pub fn reset(&mut self) {
717        *self = Self::new();
718    }
719}
720
721// ═══════════════════════════════════════════════════════════════════
722// Stellate Cell
723// ═══════════════════════════════════════════════════════════════════
724
725/// Cerebellar stellate cell — fast-spiking interneuron in the molecular layer.
726///
727/// Biophysics: Wang-Buzsáki Na+/K+ core extended with Kv3.1 for narrow
728/// action potentials and high-frequency firing. Provides feedforward
729/// inhibition onto Purkinje cell dendrites. Receives excitatory input
730/// from parallel fibres (granule cell axons).
731///
732/// Stellate cells are smaller than basket cells and innervate more distal
733/// Purkinje cell dendrites. They show minimal spike frequency adaptation
734/// and can sustain high firing rates.
735///
736/// Sultan & Bower, J Comp Neurol 409:63, 1999; Häusser & Clark, Neuron 19:665, 1997.
737#[derive(Clone, Debug)]
738pub struct StellateCell {
739    pub v: f64,
740    pub h: f64, // Na+ inactivation
741    pub n: f64, // Kdr activation
742    pub p: f64, // Kv3.1 activation
743    // Conductances (mS/cm²)
744    pub g_na: f64,
745    pub g_k: f64,
746    pub g_kv3: f64,
747    pub g_l: f64,
748    // Reversal potentials (mV)
749    pub e_na: f64,
750    pub e_k: f64,
751    pub e_l: f64,
752    pub c_m: f64,
753    pub phi: f64,
754    pub dt: f64,
755    pub v_threshold: f64,
756    pub gain: f64,
757}
758
759impl Default for StellateCell {
760    fn default() -> Self {
761        Self::new()
762    }
763}
764
765impl StellateCell {
766    pub fn new() -> Self {
767        Self {
768            v: -65.0,
769            h: 0.6,
770            n: 0.32,
771            p: 0.0,
772            g_na: 35.0,
773            g_k: 9.0,
774            g_kv3: 3.0, // Less Kv3.1 than PV+ basket
775            g_l: 0.1,
776            e_na: 55.0,
777            e_k: -90.0,
778            e_l: -65.0,
779            c_m: 0.5, // Smaller cell → lower capacitance
780            phi: 5.0,
781            dt: 0.5,
782            v_threshold: -20.0,
783            gain: 1.0,
784        }
785    }
786
787    #[inline]
788    fn safe_exp(value: f64) -> f64 {
789        value.clamp(-60.0, 60.0).exp()
790    }
791
792    #[inline]
793    fn boltz(v: f64, vh: f64, k: f64) -> f64 {
794        let z = -(v - vh) / k;
795        if z > 60.0 {
796            0.0
797        } else if z < -60.0 {
798            1.0
799        } else {
800            1.0 / (1.0 + z.exp())
801        }
802    }
803
804    #[inline]
805    fn exact_relax(value: f64, target: f64, tau: f64, dt: f64) -> f64 {
806        if tau <= f64::EPSILON {
807            target.clamp(0.0, 1.0)
808        } else {
809            (target + (value - target) * (-dt / tau).exp()).clamp(0.0, 1.0)
810        }
811    }
812
813    #[inline]
814    fn exact_hh_gate(value: f64, alpha: f64, beta: f64, phi: f64, dt: f64) -> f64 {
815        let total = alpha + beta;
816        if total <= f64::EPSILON {
817            value.clamp(0.0, 1.0)
818        } else {
819            let steady = alpha / total;
820            (steady + (value - steady) * (-phi * total * dt).exp()).clamp(0.0, 1.0)
821        }
822    }
823
824    #[inline]
825    fn exact_voltage_step(
826        v: f64,
827        c_m: f64,
828        input: f64,
829        conductances: [(f64, f64); 4],
830        dt: f64,
831    ) -> f64 {
832        let g_total = conductances.iter().map(|(g, _)| *g).sum::<f64>();
833        let drive = input
834            + conductances
835                .iter()
836                .map(|(g, reversal)| g * reversal)
837                .sum::<f64>();
838        if g_total <= f64::EPSILON {
839            v + dt * drive / c_m
840        } else {
841            let v_inf = drive / g_total;
842            let tau = c_m / g_total;
843            v_inf + (v - v_inf) * (-dt / tau).exp()
844        }
845    }
846
847    fn is_valid(&self) -> bool {
848        [
849            self.v,
850            self.h,
851            self.n,
852            self.p,
853            self.g_na,
854            self.g_k,
855            self.g_kv3,
856            self.g_l,
857            self.e_na,
858            self.e_k,
859            self.e_l,
860            self.c_m,
861            self.phi,
862            self.dt,
863            self.v_threshold,
864            self.gain,
865        ]
866        .iter()
867        .all(|value| value.is_finite())
868            && (-100.0..=60.0).contains(&self.v)
869            && [self.h, self.n, self.p]
870                .iter()
871                .all(|gate| (0.0..=1.0).contains(gate))
872            && [self.g_na, self.g_k, self.g_kv3, self.g_l]
873                .iter()
874                .all(|conductance| *conductance >= 0.0)
875            && self.c_m > 0.0
876            && self.phi > 0.0
877            && self.dt > 0.0
878            && self.gain >= 0.0
879    }
880
881    pub fn step(&mut self, current: f64) -> i32 {
882        if !self.is_valid() || !current.is_finite() {
883            return 0;
884        }
885
886        let input = self.gain * current;
887        let sub_steps = 50;
888        let sub_dt = self.dt / sub_steps as f64;
889        let mut fired = 0i32;
890        let mut v = self.v;
891        let mut h = self.h;
892        let mut n = self.n;
893        let mut p = self.p;
894
895        for _ in 0..sub_steps {
896            // WB alpha/beta rates
897            let alpha_m = safe_rate(0.1, 35.0, v, 10.0, 1.0);
898            let beta_m = 4.0 * Self::safe_exp(-(v + 60.0) / 18.0);
899            let m_inf = alpha_m / (alpha_m + beta_m);
900
901            let alpha_h = 0.07 * Self::safe_exp(-(v + 58.0) / 20.0);
902            let beta_h = Self::boltz(v, -28.0, 10.0);
903
904            let alpha_n = safe_rate(0.01, 34.0, v, 10.0, 0.1);
905            let beta_n = 0.125 * Self::safe_exp(-(v + 44.0) / 80.0);
906
907            // Kv3.1 gating (fast activation, no inactivation)
908            let p_inf = Self::boltz(v, -10.0, 10.0);
909            let tau_p = 1.0 + 4.0 / (1.0 + Self::safe_exp((v + 20.0) / 15.0));
910
911            // Gate updates
912            h = Self::exact_hh_gate(h, alpha_h, beta_h, self.phi, sub_dt);
913            n = Self::exact_hh_gate(n, alpha_n, beta_n, self.phi, sub_dt);
914            p = Self::exact_relax(p, p_inf, tau_p, sub_dt);
915
916            // Currents (m uses steady-state for speed)
917            let g_na = self.g_na * m_inf.powi(3) * h;
918            let g_k = self.g_k * n.powi(4);
919            let g_kv3 = self.g_kv3 * p.powi(2);
920            let g_l = self.g_l;
921
922            v = Self::exact_voltage_step(
923                v,
924                self.c_m,
925                input,
926                [
927                    (g_na, self.e_na),
928                    (g_k, self.e_k),
929                    (g_kv3, self.e_k),
930                    (g_l, self.e_l),
931                ],
932                sub_dt,
933            )
934            .clamp(-100.0, 60.0);
935            if ![v, h, n, p].iter().all(|value| value.is_finite()) {
936                return 0;
937            }
938
939            if v >= self.v_threshold {
940                fired = 1;
941                v = -65.0;
942            }
943        }
944
945        self.v = v;
946        self.h = h;
947        self.n = n;
948        self.p = p;
949
950        fired
951    }
952
953    pub fn reset(&mut self) {
954        *self = Self::new();
955    }
956}
957
958// ═══════════════════════════════════════════════════════════════════
959// Lugaro Cell
960// ═══════════════════════════════════════════════════════════════════
961
962/// Cerebellar Lugaro cell — rare fusiform interneuron in the granular layer.
963///
964/// Biophysics: LIF with adaptation for regular spiking, serotonin modulation
965/// (5-HT increases gain), and a depolarised leak for spontaneous firing.
966/// Inhibits Golgi cells and molecular layer interneurons (stellate, basket).
967///
968/// Lugaro cells are distinguished by their horizontal axonal projection,
969/// large fusiform soma, and sensitivity to serotonergic afferents from
970/// the brainstem raphe nuclei.
971///
972/// Dieudonné & Bhatt, J Physiol 548:97, 2003; Lainé & Bhatt, Front Syst Neurosci 1:4, 2007.
973#[derive(Clone, Debug)]
974pub struct LugaroCell {
975    pub v: f64,
976    pub adapt: f64, // Adaptation current
977    pub v_rest: f64,
978    pub v_reset: f64,
979    pub v_threshold: f64,
980    pub tau_m: f64,
981    pub tau_adapt: f64,
982    pub a_adapt: f64, // Adaptation coupling strength
983    pub gain: f64,
984    pub serotonin: f64, // 5-HT modulation factor [0, 1]
985    pub dt: f64,
986}
987
988impl Default for LugaroCell {
989    fn default() -> Self {
990        Self::new()
991    }
992}
993
994impl LugaroCell {
995    pub fn new() -> Self {
996        Self {
997            v: -55.0,
998            adapt: 0.0,
999            v_rest: -55.0, // Depolarised rest for spontaneous firing
1000            v_reset: -65.0,
1001            v_threshold: -48.0,
1002            tau_m: 10.0,
1003            tau_adapt: 150.0,
1004            a_adapt: 0.05,
1005            gain: 2.0,
1006            serotonin: 0.0, // No 5-HT modulation by default
1007            dt: 0.5,
1008        }
1009    }
1010
1011    /// Create with serotonin modulation active.
1012    pub fn with_serotonin(serotonin_level: f64) -> Self {
1013        let mut n = Self::new();
1014        n.serotonin = serotonin_level.clamp(0.0, 1.0);
1015        n
1016    }
1017
1018    fn is_valid(&self) -> bool {
1019        [
1020            self.v,
1021            self.adapt,
1022            self.v_rest,
1023            self.v_reset,
1024            self.v_threshold,
1025            self.tau_m,
1026            self.tau_adapt,
1027            self.a_adapt,
1028            self.gain,
1029            self.serotonin,
1030            self.dt,
1031        ]
1032        .iter()
1033        .all(|value| value.is_finite())
1034            && self.tau_m > 0.0
1035            && self.tau_adapt > 0.0
1036            && self.dt > 0.0
1037            && self.a_adapt >= 0.0
1038            && self.gain >= 0.0
1039            && (-100.0..=60.0).contains(&self.v)
1040            && (0.0..=1.0).contains(&self.serotonin)
1041            && self.adapt >= 0.0
1042            && self.v_threshold > self.v_reset
1043            && self.v_threshold > self.v_rest
1044    }
1045
1046    pub fn step(&mut self, current: f64) -> i32 {
1047        if !self.is_valid() || !current.is_finite() {
1048            return 0;
1049        }
1050
1051        // 5-HT modulation: increases effective gain
1052        let effective_gain = self.gain * (1.0 + 0.5 * self.serotonin);
1053        let input = effective_gain * current;
1054
1055        // LIF dynamics with closed-form first-order relaxation.
1056        let v_inf = self.v_rest + input - self.adapt;
1057        let v_next = v_inf + (self.v - v_inf) * (-self.dt / self.tau_m).exp();
1058
1059        // Adaptation dynamics with non-negative hyperpolarising current.
1060        let adapt_inf = (self.a_adapt * (v_next - self.v_rest).max(0.0)).max(0.0);
1061        let adapt_next =
1062            (adapt_inf + (self.adapt - adapt_inf) * (-self.dt / self.tau_adapt).exp()).max(0.0);
1063        if !v_next.is_finite() || !adapt_next.is_finite() {
1064            return 0;
1065        }
1066
1067        // Spike detection
1068        if v_next >= self.v_threshold {
1069            self.v = self.v_reset;
1070            self.adapt = adapt_next + 1.0; // Spike-triggered adaptation increment
1071            return 1;
1072        }
1073
1074        // Safety bounds
1075        self.v = v_next.clamp(-100.0, 60.0);
1076        self.adapt = adapt_next;
1077
1078        0
1079    }
1080
1081    pub fn reset(&mut self) {
1082        *self = Self::new();
1083    }
1084}
1085
1086// ═══════════════════════════════════════════════════════════════════
1087// Unipolar Brush Cell
1088// ═══════════════════════════════════════════════════════════════════
1089
1090/// Cerebellar unipolar brush cell (UBC) — excitatory interneuron in vestibular cerebellum.
1091///
1092/// Biophysics: LIF with a slow persistent (NMDA-like) current that sustains
1093/// depolarisation long after input ceases. The single brush-like dendrite
1094/// forms a giant synapse with a mossy fibre rosette, creating a 1:1 relay
1095/// that amplifies and prolongs the input signal.
1096///
1097/// UBCs are unique excitatory interneurons in the granular layer. They
1098/// transform brief mossy fibre bursts into prolonged granule cell
1099/// activation, important for vestibular signal processing and timing.
1100///
1101/// Bhatt et al., J Comp Neurol 349:560, 1994; Diana et al., J Neurosci 27:4374, 2007.
1102#[derive(Clone, Debug)]
1103pub struct UnipolarBrushCell {
1104    pub v: f64,
1105    pub persistent: f64, // Slow NMDA-like persistent current
1106    pub v_rest: f64,
1107    pub v_reset: f64,
1108    pub v_threshold: f64,
1109    pub tau_m: f64,
1110    pub tau_persistent: f64,  // Slow decay of persistent current (ms)
1111    pub persistent_gain: f64, // How much input drives persistent current
1112    pub gain: f64,
1113    pub dt: f64,
1114}
1115
1116impl Default for UnipolarBrushCell {
1117    fn default() -> Self {
1118        Self::new()
1119    }
1120}
1121
1122impl UnipolarBrushCell {
1123    pub fn new() -> Self {
1124        Self {
1125            v: -65.0,
1126            persistent: 0.0,
1127            v_rest: -65.0,
1128            v_reset: -70.0,
1129            v_threshold: -50.0,
1130            tau_m: 8.0,
1131            tau_persistent: 200.0,
1132            persistent_gain: 0.5,
1133            gain: 2.5,
1134            dt: 0.5,
1135        }
1136    }
1137
1138    fn finite(values: &[f64]) -> bool {
1139        values.iter().all(|value| value.is_finite())
1140    }
1141
1142    fn valid_configuration(&self) -> bool {
1143        Self::finite(&[
1144            self.v_rest,
1145            self.v_reset,
1146            self.v_threshold,
1147            self.tau_m,
1148            self.tau_persistent,
1149            self.persistent_gain,
1150            self.gain,
1151            self.dt,
1152        ]) && self.tau_m > 0.0
1153            && self.tau_persistent > 0.0
1154            && self.persistent_gain >= 0.0
1155            && self.gain >= 0.0
1156            && self.dt > 0.0
1157            && self.v_reset < self.v_threshold
1158    }
1159
1160    fn valid_state(&self) -> bool {
1161        Self::finite(&[self.v, self.persistent])
1162            && (-100.0..=60.0).contains(&self.v)
1163            && self.persistent >= 0.0
1164    }
1165
1166    fn first_order_relaxation(previous: f64, steady_state: f64, dt: f64, tau: f64) -> f64 {
1167        previous + (steady_state - previous) * (-(-dt / tau).exp_m1())
1168    }
1169
1170    pub fn step(&mut self, current: f64) -> i32 {
1171        if !self.valid_configuration() || !self.valid_state() || !current.is_finite() {
1172            return 0;
1173        }
1174        let input = self.gain * current.max(0.0);
1175        if !input.is_finite() {
1176            return 0;
1177        }
1178        let next_persistent = Self::first_order_relaxation(
1179            self.persistent,
1180            self.persistent_gain * input,
1181            self.dt,
1182            self.tau_persistent,
1183        )
1184        .max(0.0);
1185        let next_v = Self::first_order_relaxation(
1186            self.v,
1187            self.v_rest + input + next_persistent,
1188            self.dt,
1189            self.tau_m,
1190        );
1191        if !Self::finite(&[next_persistent, next_v]) {
1192            return 0;
1193        }
1194        self.persistent = next_persistent;
1195        if next_v >= self.v_threshold {
1196            self.v = self.v_reset;
1197            return 1;
1198        }
1199        self.v = next_v.clamp(-100.0, 60.0);
1200        0
1201    }
1202
1203    pub fn reset(&mut self) {
1204        self.v = self.v_rest;
1205        self.persistent = 0.0;
1206    }
1207}
1208
1209// ═══════════════════════════════════════════════════════════════════
1210// Deep Cerebellar Nuclei Neuron
1211// ═══════════════════════════════════════════════════════════════════
1212
1213/// Deep cerebellar nuclei (DCN) neuron — main output of the cerebellum.
1214///
1215/// Biophysics: WB Na+/K+ core with T-type Ca²⁺ for post-inhibitory rebound
1216/// bursting, Ih (HCN) for pacemaker-like activity, persistent Na (INaP) for
1217/// subthreshold depolarisation, and Ca²⁺-dependent AHP for spike frequency
1218/// adaptation.
1219///
1220/// 7 currents: INa_t, INaP, IK_dr, ICa_T, IAHP, Ih, IL
1221///
1222/// Rebound bursting: when Purkinje inhibition is released, T-type Ca²⁺
1223/// channels that de-inactivated during hyperpolarisation produce a burst.
1224/// INaP amplifies subthreshold depolarisation. AHP limits burst duration.
1225///
1226/// Llinás & Mühlethaler, J Physiol 404:241, 1988; Jahnsen, J Physiol 372:129, 1986.
1227#[derive(Clone, Debug)]
1228pub struct DCNNeuron {
1229    pub v: f64,
1230    pub h: f64,  // Na_t inactivation
1231    pub n: f64,  // K_dr activation
1232    pub p: f64,  // Na_p persistent activation
1233    pub s: f64,  // T-type Ca²⁺ inactivation (slow)
1234    pub r: f64,  // Ih activation
1235    pub ca: f64, // Intracellular Ca²⁺ (µM)
1236    // Conductances (mS/cm²)
1237    pub g_na: f64,
1238    pub g_nap: f64, // Persistent Na
1239    pub g_k: f64,
1240    pub g_t: f64,   // T-type Ca²⁺
1241    pub g_ahp: f64, // Ca²⁺-dependent AHP
1242    pub g_h: f64,   // Ih
1243    pub g_l: f64,
1244    // Reversal potentials
1245    pub e_na: f64,
1246    pub e_k: f64,
1247    pub e_ca: f64,
1248    pub e_h: f64,
1249    pub e_l: f64,
1250    pub c_m: f64,
1251    pub phi: f64,
1252    pub tau_ca: f64,
1253    pub kd_ahp: f64,
1254    pub dt: f64,
1255    pub v_threshold: f64,
1256    pub gain: f64,
1257}
1258
1259impl Default for DCNNeuron {
1260    fn default() -> Self {
1261        Self::new()
1262    }
1263}
1264
1265impl DCNNeuron {
1266    pub fn new() -> Self {
1267        Self {
1268            v: -60.0,
1269            h: 0.6,
1270            n: 0.32,
1271            p: 0.01,  // NaP activation (low at rest)
1272            s: 0.8,   // T-type de-inactivated at rest
1273            r: 0.1,   // Ih partially active
1274            ca: 0.05, // Resting Ca²⁺ (µM)
1275            g_na: 35.0,
1276            g_nap: 0.5, // Persistent Na — amplifies subthreshold
1277            g_k: 9.0,
1278            g_t: 0.1,   // T-type Ca²⁺
1279            g_ahp: 2.0, // Ca²⁺-dependent AHP
1280            g_h: 0.02,  // Ih — modest
1281            g_l: 0.2,   // Leak
1282            e_na: 55.0,
1283            e_k: -90.0,
1284            e_ca: 120.0,
1285            e_h: -40.0,
1286            e_l: -65.0,
1287            c_m: 1.0,
1288            phi: 5.0,
1289            tau_ca: 150.0,
1290            kd_ahp: 0.5,
1291            dt: 0.5,
1292            v_threshold: -20.0,
1293            gain: 1.0,
1294        }
1295    }
1296
1297    pub fn step(&mut self, current: f64) -> i32 {
1298        if !self.is_valid() || !current.is_finite() {
1299            return 0;
1300        }
1301        let input = self.gain * current;
1302        let sub_steps = 20;
1303        let sub_dt = self.dt / sub_steps as f64;
1304        let mut fired = 0i32;
1305        let (mut v, mut h, mut n, mut p, mut s, mut r, mut ca) =
1306            (self.v, self.h, self.n, self.p, self.s, self.r, self.ca);
1307
1308        for _ in 0..sub_steps {
1309            // Na_t: WB alpha/beta rates (m³h, m quasi-static)
1310            let alpha_m = safe_rate(0.1, 35.0, v, 10.0, 1.0);
1311            let beta_m = 4.0 * (-(v + 60.0) / 18.0).exp();
1312            let m_inf = alpha_m / (alpha_m + beta_m);
1313
1314            let alpha_h = 0.07 * (-(v + 58.0) / 20.0).exp();
1315            let beta_h = 1.0 / (1.0 + (-(v + 28.0) / 10.0).exp());
1316
1317            // K_dr: n⁴
1318            let alpha_n = safe_rate(0.01, 34.0, v, 10.0, 0.1);
1319            let beta_n = 0.125 * (-(v + 44.0) / 80.0).exp();
1320
1321            // Na_p: persistent Na (Boltzmann, V1/2=-48, k=5)
1322            let p_inf = 1.0 / (1.0 + (-(v + 48.0) / 5.0).exp());
1323            let tau_p = 5.0 + 15.0 / (1.0 + ((v + 48.0) / 10.0).powi(2)).max(0.01);
1324
1325            // T-type Ca²⁺ gating
1326            let m_t_inf = 1.0 / (1.0 + (-(v + 52.0) / 5.0).exp());
1327            let s_inf = 1.0 / (1.0 + ((v + 60.0) / 6.5).exp());
1328            let tau_s = 20.0 + 50.0 / (1.0 + ((v + 65.0) / 10.0).exp());
1329
1330            // Ih gating
1331            let r_inf = 1.0 / (1.0 + ((v + 80.0) / 10.0).exp());
1332            let tau_r = 100.0 + 200.0 / (1.0 + ((v + 70.0) / 10.0).exp());
1333
1334            // First-order gates: exact exponential relaxation for the
1335            // voltage-frozen sub-step, avoiding Euler overshoot in stiff
1336            // rebound trajectories.
1337            h = dcn_exact_hh_gate(h, alpha_h, beta_h, self.phi, sub_dt);
1338            n = dcn_exact_hh_gate(n, alpha_n, beta_n, self.phi, sub_dt);
1339            p = dcn_exact_relax(p, p_inf, tau_p, sub_dt);
1340            s = dcn_exact_relax(s, s_inf, tau_s, sub_dt);
1341            r = dcn_exact_relax(r, r_inf, tau_r, sub_dt);
1342
1343            // Ca²⁺ dynamics: entry via T-type, decay
1344            let i_t = self.g_t * m_t_inf.powi(2) * s * (v - self.e_ca);
1345            let ca_entry = if i_t < 0.0 { -i_t * 0.001 } else { 0.0 };
1346            ca = dcn_exact_relax(ca, ca_entry * self.tau_ca, self.tau_ca, sub_dt).max(0.0);
1347
1348            // AHP: Ca²⁺-dependent K (Hill n=2)
1349            let ahp_inf = ca.powi(2) / (ca.powi(2) + self.kd_ahp.powi(2));
1350
1351            // Voltage: exact ohmic conductance solution over the sub-step
1352            // with gates frozen after their exponential update.
1353            let g_na_eff = self.g_na * m_inf.powi(3) * h;
1354            let g_nap_eff = self.g_nap * p;
1355            let g_k_eff = self.g_k * n.powi(4);
1356            let g_t_eff = self.g_t * m_t_inf.powi(2) * s;
1357            let g_ahp_eff = self.g_ahp * ahp_inf;
1358            let g_h_eff = self.g_h * r;
1359            v = dcn_exact_voltage_step(
1360                v,
1361                input,
1362                self.c_m,
1363                sub_dt,
1364                &[
1365                    (g_na_eff, self.e_na),
1366                    (g_nap_eff, self.e_na),
1367                    (g_k_eff, self.e_k),
1368                    (g_t_eff, self.e_ca),
1369                    (g_ahp_eff, self.e_k),
1370                    (g_h_eff, self.e_h),
1371                    (self.g_l, self.e_l),
1372                ],
1373            );
1374
1375            if v >= self.v_threshold {
1376                fired = 1;
1377                v = -60.0;
1378                s *= 0.5; // T-type inactivation on spike
1379                ca += 0.5; // Ca²⁺ entry on spike
1380            }
1381        }
1382
1383        if ![v, h, n, p, s, r, ca].iter().all(|value| value.is_finite()) {
1384            return 0;
1385        }
1386        self.v = v.clamp(-100.0, 60.0);
1387        self.h = h.clamp(0.0, 1.0);
1388        self.n = n.clamp(0.0, 1.0);
1389        self.p = p.clamp(0.0, 1.0);
1390        self.s = s.clamp(0.0, 1.0);
1391        self.r = r.clamp(0.0, 1.0);
1392        self.ca = ca.max(0.0);
1393
1394        fired
1395    }
1396
1397    pub fn reset(&mut self) {
1398        *self = Self::new();
1399    }
1400
1401    fn is_valid(&self) -> bool {
1402        [
1403            self.v,
1404            self.h,
1405            self.n,
1406            self.p,
1407            self.s,
1408            self.r,
1409            self.ca,
1410            self.g_na,
1411            self.g_nap,
1412            self.g_k,
1413            self.g_t,
1414            self.g_ahp,
1415            self.g_h,
1416            self.g_l,
1417            self.e_na,
1418            self.e_k,
1419            self.e_ca,
1420            self.e_h,
1421            self.e_l,
1422            self.c_m,
1423            self.phi,
1424            self.tau_ca,
1425            self.kd_ahp,
1426            self.dt,
1427            self.v_threshold,
1428            self.gain,
1429        ]
1430        .iter()
1431        .all(|value| value.is_finite())
1432            && [self.h, self.n, self.p, self.s, self.r]
1433                .iter()
1434                .all(|gate| (0.0..=1.0).contains(gate))
1435            && self.ca >= 0.0
1436            && (-100.0..=60.0).contains(&self.v)
1437            && [
1438                self.g_na, self.g_nap, self.g_k, self.g_t, self.g_ahp, self.g_h, self.g_l,
1439            ]
1440            .iter()
1441            .all(|g| *g >= 0.0)
1442            && self.c_m > 0.0
1443            && self.phi > 0.0
1444            && self.tau_ca > 0.0
1445            && self.kd_ahp > 0.0
1446            && self.dt > 0.0
1447            && self.gain >= 0.0
1448    }
1449}
1450
1451fn dcn_exact_relax(value: f64, target: f64, tau: f64, dt: f64) -> f64 {
1452    target + (value - target) * (-dt / tau).exp()
1453}
1454
1455fn dcn_exact_hh_gate(value: f64, alpha: f64, beta: f64, phi: f64, dt: f64) -> f64 {
1456    let rate = phi * (alpha + beta);
1457    let target = alpha / (alpha + beta);
1458    target + (value - target) * (-rate * dt).exp()
1459}
1460
1461fn dcn_exact_voltage_step(
1462    v: f64,
1463    input_current: f64,
1464    c_m: f64,
1465    dt: f64,
1466    conductances: &[(f64, f64)],
1467) -> f64 {
1468    let g_total: f64 = conductances.iter().map(|(g, _)| *g).sum();
1469    if g_total <= 0.0 {
1470        return v + dt * input_current / c_m;
1471    }
1472    let reversal_drive: f64 = conductances.iter().map(|(g, e_rev)| g * e_rev).sum();
1473    let v_inf = (input_current + reversal_drive) / g_total;
1474    v_inf + (v - v_inf) * (-dt * g_total / c_m).exp()
1475}
1476
1477// ═══════════════════════════════════════════════════════════════════
1478// Tests
1479// ═══════════════════════════════════════════════════════════════════
1480
1481#[cfg(test)]
1482mod tests {
1483    use super::*;
1484
1485    // -- Granule Cell tests --
1486
1487    #[test]
1488    fn granule_fires_with_strong_input() {
1489        let mut n = GranuleCell::new();
1490        let mut spikes = 0;
1491        for _ in 0..10_000 {
1492            spikes += n.step(15.0);
1493        }
1494        assert!(
1495            spikes > 10,
1496            "Granule cell must fire with strong excitatory input, got {spikes}"
1497        );
1498    }
1499
1500    #[test]
1501    fn granule_silent_at_rest() {
1502        let mut n = GranuleCell::new();
1503        let mut spikes = 0;
1504        for _ in 0..10_000 {
1505            spikes += n.step(0.0);
1506        }
1507        assert_eq!(
1508            spikes, 0,
1509            "Granule cell must be silent without input (tonic GABA inhibition)"
1510        );
1511    }
1512
1513    #[test]
1514    fn granule_no_fire_weak_input() {
1515        // Tonic GABA raises effective threshold
1516        let mut n = GranuleCell::new();
1517        let mut spikes = 0;
1518        for _ in 0..10_000 {
1519            spikes += n.step(1.0);
1520        }
1521        assert!(
1522            spikes == 0,
1523            "Weak input should not overcome tonic GABA, got {spikes}"
1524        );
1525    }
1526
1527    #[test]
1528    fn granule_tonic_gaba_raises_threshold() {
1529        // Compare firing with and without tonic GABA
1530        let mut with_gaba = GranuleCell::new();
1531        let mut no_gaba = GranuleCell::new();
1532        no_gaba.g_tonic = 0.0;
1533
1534        let input = 8.0;
1535        let mut spikes_gaba = 0;
1536        let mut spikes_no_gaba = 0;
1537        for _ in 0..10_000 {
1538            spikes_gaba += with_gaba.step(input);
1539            spikes_no_gaba += no_gaba.step(input);
1540        }
1541        assert!(
1542            spikes_no_gaba > spikes_gaba,
1543            "Removing tonic GABA must increase firing: no_gaba={spikes_no_gaba} vs gaba={spikes_gaba}"
1544        );
1545    }
1546
1547    #[test]
1548    fn granule_has_seven_currents() {
1549        // D'Angelo 2001 model must have all 7 ionic currents
1550        let n = GranuleCell::new();
1551        assert!(n.g_na > 0.0, "Must have INa");
1552        assert!(n.g_kdr > 0.0, "Must have IK_dr");
1553        assert!(n.g_ka > 0.0, "Must have IK_A");
1554        assert!(n.g_t > 0.0, "Must have ICa_T");
1555        assert!(n.g_kca > 0.0, "Must have IK_Ca");
1556        assert!(n.g_h > 0.0, "Must have Ih");
1557        assert!(n.g_l > 0.0, "Must have IL");
1558    }
1559
1560    #[test]
1561    fn granule_t_type_deinactivates_at_rest() {
1562        // T-type inactivation s should be high at rest (de-inactivated)
1563        let mut n = GranuleCell::new();
1564        for _ in 0..5000 {
1565            n.step(0.0);
1566        }
1567        assert!(
1568            n.s > 0.5,
1569            "T-type must be partially de-inactivated at rest, s={}",
1570            n.s
1571        );
1572    }
1573
1574    #[test]
1575    fn granule_gate_and_calcium_kinetics_use_closed_form_relaxation() {
1576        let mut n = GranuleCell::new();
1577        n.g_na = 0.0;
1578        n.g_kdr = 0.0;
1579        n.g_ka = 0.0;
1580        n.g_t = 0.0;
1581        n.g_kca = 0.0;
1582        n.g_h = 0.0;
1583        n.g_l = 0.0;
1584        n.g_tonic = 0.0;
1585        n.gain = 0.0;
1586        n.sub_steps = 1;
1587        let (v0, m0, h0, n0, a0, b0, mt0, s0, ca0, r0) =
1588            (n.v, n.m, n.h, n.n, n.a, n.b, n.m_t, n.s, n.ca, n.r);
1589        let m_inf = GranuleCell::boltz(v0, -30.0, 7.0);
1590        let tau_m = 0.1 + 0.3 / (1.0 + ((v0 + 30.0) / 10.0).powi(2)).max(0.01);
1591        let h_inf = GranuleCell::boltz(v0, -52.0, -6.0);
1592        let tau_h = 0.5 + 5.0 / (1.0 + ((v0 + 50.0) / 15.0).powi(2)).max(0.01);
1593        let n_inf = GranuleCell::boltz(v0, -35.0, 8.0);
1594        let tau_n = 1.0 + 5.0 / (1.0 + ((v0 + 35.0) / 15.0).powi(2)).max(0.01);
1595        let a_inf = GranuleCell::boltz(v0, -50.0, 20.0);
1596        let b_inf = GranuleCell::boltz(v0, -70.0, -6.0);
1597        let mt_inf = GranuleCell::boltz(v0, -52.0, 5.0);
1598        let s_inf = GranuleCell::boltz(v0, -60.0, -6.5);
1599        let tau_s = 20.0 + 50.0 / (1.0 + ((v0 + 65.0) / 10.0).powi(2)).max(0.01);
1600        let r_inf = GranuleCell::boltz(v0, -80.0, -10.0);
1601        let tau_r = 50.0 + 200.0 / (1.0 + ((v0 + 80.0) / 20.0).powi(2)).max(0.01);
1602
1603        n.step(0.0);
1604
1605        assert_close_granule(n.v, v0);
1606        assert_close_granule(n.m, granule_exact_relax(m0, m_inf, tau_m, n.dt));
1607        assert_close_granule(n.h, granule_exact_relax(h0, h_inf, tau_h, n.dt));
1608        assert_close_granule(n.n, granule_exact_relax(n0, n_inf, tau_n, n.dt));
1609        assert_close_granule(n.a, granule_exact_relax(a0, a_inf, 2.0, n.dt));
1610        assert_close_granule(n.b, granule_exact_relax(b0, b_inf, 50.0, n.dt));
1611        assert_close_granule(n.m_t, granule_exact_relax(mt0, mt_inf, 1.0, n.dt));
1612        assert_close_granule(n.s, granule_exact_relax(s0, s_inf, tau_s, n.dt));
1613        assert_close_granule(n.ca, granule_exact_relax(ca0, 0.0, n.tau_ca, n.dt));
1614        assert_close_granule(n.r, granule_exact_relax(r0, r_inf, tau_r, n.dt));
1615    }
1616
1617    #[test]
1618    fn granule_ca_rises_with_spiking() {
1619        // Ca²⁺ should increase during spiking activity
1620        let mut n = GranuleCell::new();
1621        let ca0 = n.ca;
1622        for _ in 0..5000 {
1623            n.step(8.0);
1624        }
1625        assert!(
1626            n.ca > ca0,
1627            "Ca²⁺ should rise in the T-current firing regime: ca0={ca0}, ca_now={}",
1628            n.ca
1629        );
1630    }
1631
1632    #[test]
1633    fn granule_negative_input_no_crash() {
1634        let mut n = GranuleCell::new();
1635        for _ in 0..10_000 {
1636            n.step(-100.0);
1637        }
1638        assert!(n.v.is_finite(), "Must stay finite with negative input");
1639        assert!(n.v >= -100.0, "Must be bounded");
1640    }
1641
1642    #[test]
1643    fn granule_nan_input_stays_finite() {
1644        let mut n = GranuleCell::new();
1645        let before = n.clone();
1646        n.step(f64::NAN);
1647        assert!(n.v.is_finite(), "NaN input must not corrupt state");
1648        assert_eq!(n.v, before.v);
1649        assert_eq!(n.ca, before.ca);
1650        assert_eq!(n.s, before.s);
1651    }
1652
1653    #[test]
1654    fn granule_corrupted_state_preserved_on_step() {
1655        let mut n = GranuleCell::new();
1656        n.m = -0.1;
1657        let before = n.clone();
1658        assert_eq!(n.step(10.0), 0);
1659        assert_eq!(n.v, before.v);
1660        assert_eq!(n.m, before.m);
1661        assert_eq!(n.ca, before.ca);
1662    }
1663
1664    #[test]
1665    fn granule_extreme_input_bounded() {
1666        let mut n = GranuleCell::new();
1667        for _ in 0..1000 {
1668            n.step(1e6);
1669        }
1670        assert!(
1671            n.v.is_finite() && n.v <= 60.0,
1672            "Extreme input must stay bounded"
1673        );
1674    }
1675
1676    #[test]
1677    fn granule_reset_clears_state() {
1678        let mut n = GranuleCell::new();
1679        for _ in 0..1000 {
1680            n.step(20.0);
1681        }
1682        n.reset();
1683        assert_eq!(n.v, -70.0);
1684        assert_eq!(n.s, 0.95);
1685        assert_eq!(n.m, 0.02);
1686    }
1687
1688    #[test]
1689    fn granule_high_input_resistance() {
1690        // Small soma → large voltage response to small current
1691        let mut n = GranuleCell::new();
1692        let v_before = n.v;
1693        // Single step with moderate input
1694        n.step(5.0);
1695        let dv = n.v - v_before;
1696        assert!(
1697            dv > 0.5,
1698            "High Rin should give large voltage change, got dv={dv}"
1699        );
1700    }
1701
1702    #[test]
1703    fn granule_performance_10k_steps() {
1704        let start = std::time::Instant::now();
1705        let mut n = GranuleCell::new();
1706        for _ in 0..10_000 {
1707            std::hint::black_box(n.step(10.0));
1708        }
1709        let elapsed = start.elapsed();
1710        assert!(
1711            elapsed.as_millis() < 100,
1712            "10k exact-integrator steps must complete in <100ms, took {}ms",
1713            elapsed.as_millis()
1714        );
1715    }
1716
1717    fn assert_close_granule(observed: f64, expected: f64) {
1718        assert!(
1719            (observed - expected).abs() <= 1.0e-12,
1720            "observed {:.17e}, expected {:.17e}",
1721            observed,
1722            expected,
1723        );
1724    }
1725
1726    // -- Golgi Cell tests --
1727
1728    #[test]
1729    fn golgi_fires_with_input() {
1730        let mut n = GolgiCell::new();
1731        let mut spikes = 0;
1732        for _ in 0..10_000 {
1733            spikes += n.step(15.0);
1734        }
1735        assert!(
1736            spikes > 10,
1737            "Golgi cell must fire with excitatory input, got {spikes}"
1738        );
1739    }
1740
1741    #[test]
1742    fn golgi_spontaneous_firing() {
1743        // Golgi cells are spontaneously active due to depolarised leak
1744        let mut n = GolgiCell::new();
1745        let _spikes: i32 = (0..20_000).map(|_| n.step(0.0)).sum();
1746        // With e_l = -60 and v_t = -56.2, may or may not spontaneously fire
1747        // The key property is that they fire easily with minimal input
1748        let mut n2 = GolgiCell::new();
1749        let mut spikes_small = 0;
1750        for _ in 0..20_000 {
1751            spikes_small += n2.step(0.5);
1752        }
1753        assert!(
1754            spikes_small > 0,
1755            "Golgi cell should fire with minimal input (near-threshold), got {spikes_small}"
1756        );
1757    }
1758
1759    #[test]
1760    fn golgi_ahp_reduces_rate_at_high_drive() {
1761        // BK + SK provide AHP — removing them should increase sustained firing
1762        let mut with_ahp = GolgiCell::new();
1763        let mut no_ahp = GolgiCell::new();
1764        no_ahp.g_bk = 0.0;
1765        no_ahp.g_sk = 0.0;
1766        let mut spikes_with = 0;
1767        let mut spikes_no = 0;
1768        for _ in 0..10_000 {
1769            spikes_with += with_ahp.step(10.0);
1770            spikes_no += no_ahp.step(10.0);
1771        }
1772        assert!(
1773            spikes_no >= spikes_with,
1774            "AHP removal should increase firing: with={spikes_with}, without={spikes_no}"
1775        );
1776    }
1777
1778    #[test]
1779    fn golgi_ka_is_transient() {
1780        // K_A (A-type) is transient: activates fast, inactivates fast.
1781        // In full 11-current Golgi model, removing K_A changes firing pattern.
1782        let mut with_a = GolgiCell::new();
1783        let mut no_a = GolgiCell::new();
1784        no_a.g_ka = 0.0;
1785        let mut spikes_with = 0;
1786        let mut spikes_no = 0;
1787        for _ in 0..10_000 {
1788            spikes_with += with_a.step(5.0);
1789            spikes_no += no_a.step(5.0);
1790        }
1791        // Both configurations must fire (K_A doesn't prevent spiking)
1792        assert!(spikes_with > 0, "Must fire with K_A");
1793        // K_A modulates rate — the difference should be measurable
1794        assert!(
1795            spikes_with != spikes_no,
1796            "K_A should affect firing rate: with={spikes_with}, without={spikes_no}"
1797        );
1798    }
1799
1800    #[test]
1801    fn golgi_ca_accumulates_during_spiking() {
1802        let mut n = GolgiCell::new();
1803        let ca_init = n.ca;
1804        for _ in 0..5000 {
1805            n.step(10.0);
1806        }
1807        assert!(
1808            n.ca > ca_init,
1809            "Ca²⁺ must rise during spiking: init={ca_init}, now={}",
1810            n.ca
1811        );
1812    }
1813
1814    #[test]
1815    fn golgi_negative_input_no_crash() {
1816        let mut n = GolgiCell::new();
1817        for _ in 0..10_000 {
1818            n.step(-100.0);
1819        }
1820        assert!(n.v.is_finite(), "Must stay finite with negative input");
1821        assert!(n.v >= -100.0);
1822    }
1823
1824    #[test]
1825    fn golgi_nan_input_stays_finite() {
1826        let mut n = GolgiCell::new();
1827        n.step(f64::NAN);
1828        assert!(n.v.is_finite(), "NaN input must not corrupt state");
1829    }
1830
1831    #[test]
1832    fn golgi_extreme_input_bounded() {
1833        let mut n = GolgiCell::new();
1834        for _ in 0..1000 {
1835            n.step(1e6);
1836        }
1837        assert!(
1838            n.v.is_finite() && n.v <= 60.0,
1839            "Extreme input must stay bounded"
1840        );
1841    }
1842
1843    #[test]
1844    fn golgi_reset_clears_state() {
1845        let mut n = GolgiCell::new();
1846        for _ in 0..5000 {
1847            n.step(10.0);
1848        }
1849        n.reset();
1850        let fresh = GolgiCell::new();
1851        assert_eq!(n.v, fresh.v);
1852        assert_eq!(n.ca, fresh.ca);
1853        assert_eq!(n.m, fresh.m);
1854        assert_eq!(n.h, fresh.h);
1855        assert_eq!(n.p_na, fresh.p_na);
1856        assert_eq!(n.w, fresh.w);
1857        assert_eq!(n.r, fresh.r);
1858    }
1859
1860    #[test]
1861    fn golgi_gates_bounded() {
1862        let mut n = GolgiCell::new();
1863        for _ in 0..10_000 {
1864            n.step(15.0);
1865        }
1866        // All 11 gating variables must be in [0, 1]
1867        for (name, val) in [
1868            ("m", n.m),
1869            ("h", n.h),
1870            ("p_na", n.p_na),
1871            ("n", n.n),
1872            ("a", n.a),
1873            ("b", n.b),
1874            ("w", n.w),
1875            ("m_t", n.m_t),
1876            ("s", n.s),
1877            ("c_n", n.c_n),
1878            ("r", n.r),
1879        ] {
1880            assert!((0.0..=1.0).contains(&val), "{name} out of bounds: {val}");
1881        }
1882        assert!(n.ca >= 0.0, "Ca²⁺ must be non-negative: {}", n.ca);
1883    }
1884
1885    #[test]
1886    fn golgi_has_eleven_currents() {
1887        // Solinas 2007: Na_t, Na_p, K_dr, K_A, K_M, Ca_T, Ca_N, BK, SK, Ih, leak = 11
1888        let n = GolgiCell::new();
1889        assert!(n.g_na_t > 0.0, "Na_t missing");
1890        assert!(n.g_na_p > 0.0, "Na_p missing");
1891        assert!(n.g_kdr > 0.0, "K_dr missing");
1892        assert!(n.g_ka > 0.0, "K_A missing");
1893        assert!(n.g_km > 0.0, "K_M missing");
1894        assert!(n.g_cat > 0.0, "Ca_T missing");
1895        assert!(n.g_can > 0.0, "Ca_N missing");
1896        assert!(n.g_bk > 0.0, "BK missing");
1897        assert!(n.g_sk > 0.0, "SK missing");
1898        assert!(n.g_h > 0.0, "Ih missing");
1899        assert!(n.g_l > 0.0, "Leak missing");
1900    }
1901
1902    #[test]
1903    fn golgi_persistent_na_depolarises() {
1904        // Na_p contributes to pacemaking — removing it should reduce excitability
1905        let mut with_nap = GolgiCell::new();
1906        let mut no_nap = GolgiCell::new();
1907        no_nap.g_na_p = 0.0;
1908        let mut spikes_with = 0;
1909        let mut spikes_no = 0;
1910        for _ in 0..10_000 {
1911            spikes_with += with_nap.step(2.0);
1912            spikes_no += no_nap.step(2.0);
1913        }
1914        assert!(
1915            spikes_with >= spikes_no,
1916            "Na_p should increase excitability: with={spikes_with} vs without={spikes_no}"
1917        );
1918    }
1919
1920    #[test]
1921    fn golgi_km_modulates_firing_pattern() {
1922        // K_M (muscarinic) is a slow K+ conductance that changes the exact
1923        // pacemaking trajectory. Under this fixed-drive protocol, removing K_M
1924        // depolarises the cell into a different conductance balance rather than
1925        // producing a globally monotonic rate increase.
1926        let mut with_km = GolgiCell::new();
1927        let mut no_km = GolgiCell::new();
1928        no_km.g_km = 0.0;
1929        let mut spikes_with = 0;
1930        let mut spikes_no = 0;
1931        for _ in 0..10_000 {
1932            spikes_with += with_km.step(10.0);
1933            spikes_no += no_km.step(10.0);
1934        }
1935        assert!(spikes_with > 0, "Golgi cell with K_M should fire");
1936        assert!(spikes_no > 0, "Golgi cell without K_M should fire");
1937        assert!(
1938            spikes_with != spikes_no,
1939            "K_M should measurably modulate firing: with_km={spikes_with}, without={spikes_no}"
1940        );
1941    }
1942
1943    #[test]
1944    fn golgi_ih_sag() {
1945        // Ih activates on hyperpolarisation → sag towards resting
1946        let mut with_h = GolgiCell::new();
1947        let mut no_h = GolgiCell::new();
1948        no_h.g_h = 0.0;
1949        // Mild hyperpolarisation (g_h=0.1 is small, so don't drive to clamp)
1950        for _ in 0..10_000 {
1951            with_h.step(-1.0);
1952            no_h.step(-1.0);
1953        }
1954        // Ih should depolarise relative to no-Ih (sag)
1955        assert!(
1956            with_h.v > no_h.v,
1957            "Ih should cause sag (less hyperpolarised): with_h={:.1} vs no_h={:.1}",
1958            with_h.v,
1959            no_h.v
1960        );
1961    }
1962
1963    #[test]
1964    fn golgi_bk_fast_ahp() {
1965        // BK channels contribute to fast AHP — removing them should widen spikes
1966        let mut with_bk = GolgiCell::new();
1967        let mut no_bk = GolgiCell::new();
1968        no_bk.g_bk = 0.0;
1969        // Drive both to fire, measure voltage after spike
1970        let mut spikes_with = 0;
1971        let mut spikes_no = 0;
1972        for _ in 0..10_000 {
1973            spikes_with += with_bk.step(10.0);
1974            spikes_no += no_bk.step(10.0);
1975        }
1976        // Without BK, model should still fire (test stability)
1977        assert!(
1978            spikes_with > 0 && spikes_no > 0,
1979            "Both should fire: with_bk={spikes_with}, no_bk={spikes_no}"
1980        );
1981    }
1982
1983    #[test]
1984    fn golgi_sk_slow_adaptation() {
1985        // SK channels provide slow AHP → spike frequency adaptation
1986        let mut with_sk = GolgiCell::new();
1987        let mut no_sk = GolgiCell::new();
1988        no_sk.g_sk = 0.0;
1989        let mut spikes_with = 0;
1990        let mut spikes_no = 0;
1991        for _ in 0..20_000 {
1992            spikes_with += with_sk.step(8.0);
1993            spikes_no += no_sk.step(8.0);
1994        }
1995        assert!(
1996            spikes_no >= spikes_with,
1997            "SK removal should increase firing: with_sk={spikes_with}, no_sk={spikes_no}"
1998        );
1999    }
2000
2001    #[test]
2002    fn golgi_performance_1k_steps() {
2003        let start = std::time::Instant::now();
2004        let mut n = GolgiCell::new();
2005        for _ in 0..1_000 {
2006            std::hint::black_box(n.step(5.0));
2007        }
2008        let elapsed = start.elapsed();
2009        assert!(
2010            elapsed.as_millis() < 50,
2011            "1k steps must complete in <50ms, took {}ms",
2012            elapsed.as_millis()
2013        );
2014    }
2015
2016    // -- Stellate Cell tests --
2017
2018    #[test]
2019    fn stellate_fires_with_input() {
2020        let mut n = StellateCell::new();
2021        let mut spikes = 0;
2022        for _ in 0..2_000 {
2023            spikes += n.step(2.0);
2024        }
2025        assert!(
2026            spikes > 5,
2027            "Stellate cell must fire with input, got {spikes}"
2028        );
2029    }
2030
2031    #[test]
2032    fn stellate_silent_without_input() {
2033        let mut n = StellateCell::new();
2034        let mut spikes = 0;
2035        for _ in 0..10_000 {
2036            spikes += n.step(0.0);
2037        }
2038        assert_eq!(
2039            spikes, 0,
2040            "Stellate cell must be silent without input, got {spikes}"
2041        );
2042    }
2043
2044    #[test]
2045    fn stellate_high_frequency() {
2046        // Fast-spiking: should sustain high rates
2047        let mut n = StellateCell::new();
2048        let mut spikes = 0;
2049        for _ in 0..2_000 {
2050            spikes += n.step(20.0);
2051        }
2052        // 2000 steps * 0.5ms = 1000 ms; >100 spikes = >100 Hz
2053        assert!(
2054            spikes > 50,
2055            "FS stellate should fire at high rate, got {spikes}"
2056        );
2057    }
2058
2059    #[test]
2060    fn stellate_minimal_adaptation() {
2061        // Compare early vs late firing — should show little adaptation
2062        let mut n = StellateCell::new();
2063        let input = 10.0;
2064        let mut spikes_early = 0;
2065        for _ in 0..2000 {
2066            spikes_early += n.step(input);
2067        }
2068        let mut spikes_late = 0;
2069        for _ in 0..2000 {
2070            spikes_late += n.step(input);
2071        }
2072        // No AHP → minimal adaptation
2073        let diff = (spikes_early - spikes_late).abs();
2074        assert!(
2075            diff < 20,
2076            "FS should have minimal adaptation: early={spikes_early}, late={spikes_late}"
2077        );
2078    }
2079
2080    #[test]
2081    fn stellate_kv3_narrows_spikes() {
2082        // Kv3.1 should allow faster repolarisation → more spikes
2083        let mut with_kv3 = StellateCell::new();
2084        let mut no_kv3 = StellateCell::new();
2085        no_kv3.g_kv3 = 0.0;
2086
2087        let mut spikes_kv3 = 0;
2088        let mut spikes_no = 0;
2089        for _ in 0..2000 {
2090            spikes_kv3 += with_kv3.step(15.0);
2091            spikes_no += no_kv3.step(15.0);
2092        }
2093        // Kv3.1 should enable higher frequency (more spikes at same input)
2094        assert!(spikes_kv3 > 0, "With Kv3.1 must fire, got {spikes_kv3}");
2095        assert!(
2096            spikes_no >= 0,
2097            "No-Kv3.1 baseline must not panic, got {spikes_no}"
2098        );
2099    }
2100
2101    #[test]
2102    fn stellate_negative_input_no_crash() {
2103        let mut n = StellateCell::new();
2104        for _ in 0..10_000 {
2105            n.step(-100.0);
2106        }
2107        assert!(n.v.is_finite());
2108        assert!(n.v >= -100.0);
2109    }
2110
2111    #[test]
2112    fn stellate_nan_input_stays_finite() {
2113        let mut n = StellateCell::new();
2114        let before = n.clone();
2115        n.step(f64::NAN);
2116        assert!(n.v.is_finite());
2117        assert_eq!(n.v, before.v);
2118        assert_eq!(n.h, before.h);
2119        assert_eq!(n.n, before.n);
2120        assert_eq!(n.p, before.p);
2121    }
2122
2123    #[test]
2124    fn stellate_corrupted_state_preserved_on_step() {
2125        let mut n = StellateCell::new();
2126        n.h = -0.1;
2127        let before = n.clone();
2128        assert_eq!(n.step(8.0), 0);
2129        assert_eq!(n.v, before.v);
2130        assert_eq!(n.h, before.h);
2131        assert_eq!(n.n, before.n);
2132        assert_eq!(n.p, before.p);
2133    }
2134
2135    #[test]
2136    fn stellate_invalid_voltage_preserved_on_step() {
2137        let mut n = StellateCell::new();
2138        n.v = 60.1;
2139        let before = n.clone();
2140        assert_eq!(n.step(8.0), 0);
2141        assert_eq!(n.v, before.v);
2142        assert_eq!(n.h, before.h);
2143        assert_eq!(n.n, before.n);
2144        assert_eq!(n.p, before.p);
2145    }
2146
2147    #[test]
2148    fn stellate_closed_form_gate_kinetics() {
2149        let mut n = StellateCell::new();
2150        n.g_na = 0.0;
2151        n.g_k = 0.0;
2152        n.g_kv3 = 0.0;
2153        n.g_l = 0.0;
2154        n.gain = 0.0;
2155
2156        let alpha_h = 0.07 * StellateCell::safe_exp(-(n.v + 58.0) / 20.0);
2157        let beta_h = StellateCell::boltz(n.v, -28.0, 10.0);
2158        let alpha_n = safe_rate(0.01, 34.0, n.v, 10.0, 0.1);
2159        let beta_n = 0.125 * StellateCell::safe_exp(-(n.v + 44.0) / 80.0);
2160        let p_inf = StellateCell::boltz(n.v, -10.0, 10.0);
2161        let tau_p = 1.0 + 4.0 / (1.0 + StellateCell::safe_exp((n.v + 20.0) / 15.0));
2162
2163        let expected_h = exact_hh_gate_stellate(n.h, alpha_h, beta_h, n.phi, n.dt);
2164        let expected_n = exact_hh_gate_stellate(n.n, alpha_n, beta_n, n.phi, n.dt);
2165        let expected_p = exact_relax_stellate(n.p, p_inf, tau_p, n.dt);
2166        let expected_v = n.v;
2167
2168        assert_eq!(n.step(0.0), 0);
2169        assert_close_stellate(n.v, expected_v, 1e-12);
2170        assert_close_stellate(n.h, expected_h, 1e-12);
2171        assert_close_stellate(n.n, expected_n, 1e-12);
2172        assert_close_stellate(n.p, expected_p, 1e-12);
2173    }
2174
2175    fn exact_relax_stellate(value: f64, target: f64, tau: f64, dt: f64) -> f64 {
2176        if tau <= f64::EPSILON {
2177            target.clamp(0.0, 1.0)
2178        } else {
2179            (target + (value - target) * (-dt / tau).exp()).clamp(0.0, 1.0)
2180        }
2181    }
2182
2183    fn exact_hh_gate_stellate(value: f64, alpha: f64, beta: f64, phi: f64, dt: f64) -> f64 {
2184        let total = alpha + beta;
2185        if total <= f64::EPSILON {
2186            value.clamp(0.0, 1.0)
2187        } else {
2188            let steady = alpha / total;
2189            (steady + (value - steady) * (-phi * total * dt).exp()).clamp(0.0, 1.0)
2190        }
2191    }
2192
2193    fn assert_close_stellate(actual: f64, expected: f64, tolerance: f64) {
2194        assert!(
2195            (actual - expected).abs() <= tolerance,
2196            "actual={actual:.16e} expected={expected:.16e} tolerance={tolerance:.3e}"
2197        );
2198    }
2199
2200    #[test]
2201    fn stellate_extreme_input_bounded() {
2202        let mut n = StellateCell::new();
2203        for _ in 0..1000 {
2204            n.step(1e6);
2205        }
2206        assert!(n.v.is_finite() && n.v <= 60.0);
2207    }
2208
2209    #[test]
2210    fn stellate_reset_clears_state() {
2211        let mut n = StellateCell::new();
2212        for _ in 0..1000 {
2213            n.step(20.0);
2214        }
2215        n.reset();
2216        assert_eq!(n.v, -65.0);
2217        assert_eq!(n.p, 0.0);
2218    }
2219
2220    #[test]
2221    fn stellate_gates_bounded() {
2222        let mut n = StellateCell::new();
2223        for _ in 0..10_000 {
2224            n.step(15.0);
2225        }
2226        assert!(n.h >= 0.0 && n.h <= 1.0);
2227        assert!(n.n >= 0.0 && n.n <= 1.0);
2228        assert!(n.p >= 0.0 && n.p <= 1.0);
2229    }
2230
2231    #[test]
2232    fn stellate_performance_1k_steps() {
2233        let start = std::time::Instant::now();
2234        let mut n = StellateCell::new();
2235        for _ in 0..1_000 {
2236            std::hint::black_box(n.step(10.0));
2237        }
2238        let elapsed = start.elapsed();
2239        assert!(
2240            elapsed.as_millis() < 200,
2241            "1k steps must complete in <200ms, took {}ms",
2242            elapsed.as_millis()
2243        );
2244    }
2245
2246    // -- Lugaro Cell tests --
2247
2248    #[test]
2249    fn lugaro_fires_with_input() {
2250        let mut n = LugaroCell::new();
2251        let mut spikes = 0;
2252        for _ in 0..10_000 {
2253            spikes += n.step(5.0);
2254        }
2255        assert!(
2256            spikes > 10,
2257            "Lugaro must fire with excitatory input, got {spikes}"
2258        );
2259    }
2260
2261    #[test]
2262    fn lugaro_low_threshold() {
2263        // Near-threshold rest → fires easily with moderate input
2264        let mut n = LugaroCell::new();
2265        let mut spikes = 0;
2266        for _ in 0..10_000 {
2267            spikes += n.step(4.0);
2268        }
2269        assert!(
2270            spikes > 10,
2271            "Lugaro should fire easily with moderate input, got {spikes}"
2272        );
2273    }
2274
2275    #[test]
2276    fn lugaro_adaptation() {
2277        let mut n = LugaroCell::new();
2278        let input = 10.0;
2279        let mut spikes_early = 0;
2280        for _ in 0..2000 {
2281            spikes_early += n.step(input);
2282        }
2283        let mut spikes_late = 0;
2284        for _ in 0..2000 {
2285            spikes_late += n.step(input);
2286        }
2287        assert!(
2288            spikes_early >= spikes_late,
2289            "Adaptation should slow firing: early={spikes_early}, late={spikes_late}"
2290        );
2291    }
2292
2293    #[test]
2294    fn lugaro_serotonin_increases_firing() {
2295        let mut no_5ht = LugaroCell::new();
2296        let mut with_5ht = LugaroCell::with_serotonin(1.0);
2297
2298        let input = 3.0;
2299        let mut spikes_no = 0;
2300        let mut spikes_5ht = 0;
2301        for _ in 0..10_000 {
2302            spikes_no += no_5ht.step(input);
2303            spikes_5ht += with_5ht.step(input);
2304        }
2305        assert!(
2306            spikes_5ht >= spikes_no,
2307            "5-HT must increase firing: 5HT={spikes_5ht} vs none={spikes_no}"
2308        );
2309    }
2310
2311    #[test]
2312    fn lugaro_negative_input_no_crash() {
2313        let mut n = LugaroCell::new();
2314        for _ in 0..10_000 {
2315            n.step(-100.0);
2316        }
2317        assert!(n.v.is_finite());
2318        assert!(n.v >= -100.0);
2319    }
2320
2321    #[test]
2322    fn lugaro_nan_input_stays_finite() {
2323        let mut n = LugaroCell::new();
2324        let before = n.clone();
2325        n.step(f64::NAN);
2326        assert!(n.v.is_finite());
2327        assert_eq!(n.v, before.v);
2328        assert_eq!(n.adapt, before.adapt);
2329    }
2330
2331    #[test]
2332    fn lugaro_corrupted_state_preserved_on_step() {
2333        let mut n = LugaroCell::new();
2334        n.adapt = f64::NAN;
2335        let before = n.clone();
2336        assert_eq!(n.step(5.0), 0);
2337        assert_eq!(n.v, before.v);
2338        assert!(n.adapt.is_nan());
2339    }
2340
2341    #[test]
2342    fn lugaro_invalid_voltage_preserved_on_step() {
2343        let mut n = LugaroCell::new();
2344        n.v = 60.1;
2345        let before = n.clone();
2346        assert_eq!(n.step(5.0), 0);
2347        assert_eq!(n.v, before.v);
2348        assert_eq!(n.adapt, before.adapt);
2349    }
2350
2351    #[test]
2352    fn lugaro_closed_form_membrane_and_adaptation_relaxation() {
2353        let mut n = LugaroCell::new();
2354        n.v = -56.0;
2355        n.adapt = 0.2;
2356        n.gain = 0.0;
2357
2358        let v_inf = n.v_rest - n.adapt;
2359        let expected_v = exact_relax_lugaro(n.v, v_inf, n.tau_m, n.dt);
2360        let adapt_inf = (n.a_adapt * (expected_v - n.v_rest).max(0.0)).max(0.0);
2361        let expected_adapt = exact_relax_lugaro(n.adapt, adapt_inf, n.tau_adapt, n.dt).max(0.0);
2362
2363        assert_eq!(n.step(0.0), 0);
2364        assert_close_lugaro(n.v, expected_v, 1e-12);
2365        assert_close_lugaro(n.adapt, expected_adapt, 1e-12);
2366    }
2367
2368    fn exact_relax_lugaro(value: f64, target: f64, tau: f64, dt: f64) -> f64 {
2369        target + (value - target) * (-dt / tau).exp()
2370    }
2371
2372    fn assert_close_lugaro(actual: f64, expected: f64, tolerance: f64) {
2373        assert!(
2374            (actual - expected).abs() <= tolerance,
2375            "actual={actual:.16e} expected={expected:.16e} tolerance={tolerance:.3e}"
2376        );
2377    }
2378
2379    #[test]
2380    fn lugaro_extreme_input_bounded() {
2381        let mut n = LugaroCell::new();
2382        for _ in 0..1000 {
2383            n.step(1e6);
2384        }
2385        assert!(n.v.is_finite() && n.v <= 60.0);
2386    }
2387
2388    #[test]
2389    fn lugaro_reset_clears_state() {
2390        let mut n = LugaroCell::new();
2391        for _ in 0..1000 {
2392            n.step(10.0);
2393        }
2394        n.reset();
2395        assert_eq!(n.v, -55.0);
2396        assert_eq!(n.adapt, 0.0);
2397        assert_eq!(n.serotonin, 0.0);
2398    }
2399
2400    #[test]
2401    fn lugaro_adapt_increases_during_spiking() {
2402        let mut n = LugaroCell::new();
2403        let initial = n.adapt;
2404        for _ in 0..5000 {
2405            n.step(10.0);
2406        }
2407        assert!(
2408            n.adapt > initial,
2409            "Adaptation must increase during spiking, adapt={}",
2410            n.adapt
2411        );
2412    }
2413
2414    #[test]
2415    fn lugaro_performance_10k_steps() {
2416        let start = std::time::Instant::now();
2417        let mut n = LugaroCell::new();
2418        for _ in 0..10_000 {
2419            std::hint::black_box(n.step(5.0));
2420        }
2421        let elapsed = start.elapsed();
2422        assert!(
2423            elapsed.as_millis() < 50,
2424            "10k steps must complete in <50ms, took {}ms",
2425            elapsed.as_millis()
2426        );
2427    }
2428
2429    // -- Unipolar Brush Cell tests --
2430
2431    #[test]
2432    fn ubc_fires_with_input() {
2433        let mut n = UnipolarBrushCell::new();
2434        let mut spikes = 0;
2435        for _ in 0..10_000 {
2436            spikes += n.step(5.0);
2437        }
2438        assert!(
2439            spikes > 10,
2440            "UBC must fire with excitatory input, got {spikes}"
2441        );
2442    }
2443
2444    #[test]
2445    fn ubc_silent_without_input() {
2446        let mut n = UnipolarBrushCell::new();
2447        let mut spikes = 0;
2448        for _ in 0..10_000 {
2449            spikes += n.step(0.0);
2450        }
2451        assert_eq!(spikes, 0, "UBC must be silent without input");
2452    }
2453
2454    fn ubc_exact_relaxation(previous: f64, steady_state: f64, dt: f64, tau: f64) -> f64 {
2455        previous + (steady_state - previous) * (-(-dt / tau).exp_m1())
2456    }
2457
2458    #[test]
2459    fn ubc_uses_closed_form_persistent_and_membrane_relaxation() {
2460        let mut n = UnipolarBrushCell::new();
2461
2462        let spike = n.step(1.0);
2463
2464        let input_drive = n.gain;
2465        let expected_persistent =
2466            ubc_exact_relaxation(0.0, n.persistent_gain * input_drive, n.dt, n.tau_persistent);
2467        let expected_v = ubc_exact_relaxation(
2468            n.v_rest,
2469            n.v_rest + input_drive + expected_persistent,
2470            n.dt,
2471            n.tau_m,
2472        );
2473        assert_eq!(spike, 0);
2474        assert!(
2475            (n.persistent - expected_persistent).abs() <= 1e-12,
2476            "persistent={} expected={}",
2477            n.persistent,
2478            expected_persistent
2479        );
2480        assert!(
2481            (n.v - expected_v).abs() <= 1e-12,
2482            "v={} expected={}",
2483            n.v,
2484            expected_v
2485        );
2486    }
2487
2488    #[test]
2489    fn ubc_corrupted_state_is_preserved_on_step() {
2490        let mut n = UnipolarBrushCell::new();
2491        n.v = f64::NAN;
2492        n.persistent = 2.0;
2493
2494        assert_eq!(n.step(10.0), 0);
2495
2496        assert!(n.v.is_nan());
2497        assert_eq!(n.persistent, 2.0);
2498    }
2499
2500    #[test]
2501    fn ubc_persistent_activity() {
2502        // After input stops, persistent current should sustain some depolarisation
2503        let mut n = UnipolarBrushCell::new();
2504        // Drive with input to build persistent current
2505        for _ in 0..2000 {
2506            n.step(10.0);
2507        }
2508        assert!(
2509            n.persistent > 0.0,
2510            "Persistent current must build during input"
2511        );
2512
2513        // Now remove input — persistent current should persist
2514        let persistent_before = n.persistent;
2515        for _ in 0..100 {
2516            n.step(0.0);
2517        }
2518        assert!(
2519            n.persistent > 0.0,
2520            "Persistent current must persist after input removal"
2521        );
2522        assert!(
2523            n.persistent < persistent_before,
2524            "Persistent current must decay"
2525        );
2526    }
2527
2528    #[test]
2529    fn ubc_persistent_spikes_after_input() {
2530        // UBC should continue firing briefly after input stops
2531        let mut n = UnipolarBrushCell::new();
2532        // Build up persistent current
2533        for _ in 0..5000 {
2534            n.step(10.0);
2535        }
2536        // Count spikes after input removal
2537        let post_spikes: i32 = (0..500).map(|_| n.step(0.0)).sum();
2538        // May or may not spike depending on persistent level — just test it doesn't crash
2539        assert!(post_spikes >= 0, "post_spikes must be non-negative");
2540        assert!(n.v.is_finite());
2541    }
2542
2543    #[test]
2544    fn ubc_negative_input_no_crash() {
2545        let mut n = UnipolarBrushCell::new();
2546        for _ in 0..10_000 {
2547            n.step(-100.0);
2548        }
2549        assert!(n.v.is_finite());
2550    }
2551
2552    #[test]
2553    fn ubc_nan_input_stays_finite() {
2554        let mut n = UnipolarBrushCell::new();
2555        n.step(f64::NAN);
2556        assert!(n.v.is_finite());
2557    }
2558
2559    #[test]
2560    fn ubc_extreme_input_bounded() {
2561        let mut n = UnipolarBrushCell::new();
2562        for _ in 0..1000 {
2563            n.step(1e6);
2564        }
2565        assert!(n.v.is_finite() && n.v <= 60.0);
2566    }
2567
2568    #[test]
2569    fn ubc_reset_clears_state() {
2570        let mut n = UnipolarBrushCell::new();
2571        for _ in 0..1000 {
2572            n.step(10.0);
2573        }
2574        n.reset();
2575        assert_eq!(n.v, -65.0);
2576        assert_eq!(n.persistent, 0.0);
2577    }
2578
2579    #[test]
2580    fn ubc_performance_10k_steps() {
2581        let start = std::time::Instant::now();
2582        let mut n = UnipolarBrushCell::new();
2583        for _ in 0..10_000 {
2584            std::hint::black_box(n.step(5.0));
2585        }
2586        let elapsed = start.elapsed();
2587        assert!(elapsed.as_millis() < 50, "10k steps must complete in <50ms");
2588    }
2589
2590    // -- DCN Neuron tests --
2591
2592    #[test]
2593    fn dcn_fires_with_input() {
2594        let mut n = DCNNeuron::new();
2595        let mut spikes = 0;
2596        for _ in 0..2_000 {
2597            spikes += n.step(5.0);
2598        }
2599        assert!(
2600            spikes > 3,
2601            "DCN must fire with excitatory input, got {spikes}"
2602        );
2603    }
2604
2605    #[test]
2606    fn dcn_spontaneous_activity() {
2607        // DCN neurons fire spontaneously (Llinás & Mühlethaler 1988)
2608        // INaP + Ih + depolarised leak drive autonomous firing
2609        let mut n = DCNNeuron::new();
2610        let mut spikes = 0;
2611        for _ in 0..20_000 {
2612            spikes += n.step(0.0);
2613        }
2614        // Should show some spontaneous activity (low rate)
2615        // Without INaP, should be reduced
2616        let mut no_nap = DCNNeuron::new();
2617        no_nap.g_nap = 0.0;
2618        let mut spikes_no = 0;
2619        for _ in 0..20_000 {
2620            spikes_no += no_nap.step(0.0);
2621        }
2622        assert!(
2623            spikes >= spikes_no,
2624            "INaP should contribute to spontaneous firing: with={spikes}, without={spikes_no}"
2625        );
2626    }
2627
2628    #[test]
2629    fn dcn_rebound_burst() {
2630        // Hyperpolarisation → T-type de-inactivation → rebound burst
2631        let mut n = DCNNeuron::new();
2632        // Hyperpolarise to de-inactivate T-type
2633        for _ in 0..2000 {
2634            n.step(-5.0);
2635        }
2636        assert!(
2637            n.s > 0.5,
2638            "T-type must de-inactivate during hyperpolarisation, s={}",
2639            n.s
2640        );
2641
2642        // Now provide excitation — T-type should help fire
2643        let mut spikes = 0;
2644        for _ in 0..200 {
2645            spikes += n.step(3.0);
2646        }
2647        // Compare with pre-inactivated T-type
2648        let mut n2 = DCNNeuron::new();
2649        n2.s = 0.05; // pre-inactivated
2650        let mut spikes2 = 0;
2651        for _ in 0..200 {
2652            spikes2 += n2.step(3.0);
2653        }
2654        assert!(
2655            spikes >= spikes2,
2656            "De-inactivated T-type should facilitate rebound: rebound={spikes} vs inact={spikes2}"
2657        );
2658    }
2659
2660    #[test]
2661    fn dcn_ih_depolarises() {
2662        // Ih should depolarise from hyperpolarised potentials
2663        let mut with_ih = DCNNeuron::new();
2664        with_ih.v = -80.0;
2665        let mut no_ih = DCNNeuron::new();
2666        no_ih.v = -80.0;
2667        no_ih.g_h = 0.0;
2668
2669        for _ in 0..1000 {
2670            with_ih.step(0.0);
2671            no_ih.step(0.0);
2672        }
2673        assert!(
2674            with_ih.v > no_ih.v,
2675            "Ih should depolarise from hyperpolarised state: Ih={:.1} vs no_Ih={:.1}",
2676            with_ih.v,
2677            no_ih.v
2678        );
2679    }
2680
2681    #[test]
2682    fn dcn_gate_and_calcium_kinetics_use_closed_form_relaxation() {
2683        let mut n = DCNNeuron::new();
2684        n.g_na = 0.0;
2685        n.g_nap = 0.0;
2686        n.g_k = 0.0;
2687        n.g_t = 0.0;
2688        n.g_ahp = 0.0;
2689        n.g_h = 0.0;
2690        n.g_l = 0.0;
2691        n.gain = 0.0;
2692        let (v0, h0, n0, p0, s0, r0, ca0) = (n.v, n.h, n.n, n.p, n.s, n.r, n.ca);
2693        let alpha_h = 0.07 * (-(v0 + 58.0) / 20.0).exp();
2694        let beta_h = 1.0 / (1.0 + (-(v0 + 28.0) / 10.0).exp());
2695        let alpha_n = safe_rate(0.01, 34.0, v0, 10.0, 0.1);
2696        let beta_n = 0.125 * (-(v0 + 44.0) / 80.0).exp();
2697        let p_inf = 1.0 / (1.0 + (-(v0 + 48.0) / 5.0).exp());
2698        let tau_p = 5.0 + 15.0 / (1.0 + ((v0 + 48.0) / 10.0).powi(2)).max(0.01);
2699        let s_inf = 1.0 / (1.0 + ((v0 + 60.0) / 6.5).exp());
2700        let tau_s = 20.0 + 50.0 / (1.0 + ((v0 + 65.0) / 10.0).exp());
2701        let r_inf = 1.0 / (1.0 + ((v0 + 80.0) / 10.0).exp());
2702        let tau_r = 100.0 + 200.0 / (1.0 + ((v0 + 70.0) / 10.0).exp());
2703
2704        n.step(0.0);
2705
2706        assert_close(n.v, v0);
2707        assert_close(n.h, dcn_exact_hh_gate(h0, alpha_h, beta_h, n.phi, n.dt));
2708        assert_close(n.n, dcn_exact_hh_gate(n0, alpha_n, beta_n, n.phi, n.dt));
2709        assert_close(n.p, dcn_exact_relax(p0, p_inf, tau_p, n.dt));
2710        assert_close(n.s, dcn_exact_relax(s0, s_inf, tau_s, n.dt));
2711        assert_close(n.r, dcn_exact_relax(r0, r_inf, tau_r, n.dt));
2712        assert_close(n.ca, dcn_exact_relax(ca0, 0.0, n.tau_ca, n.dt));
2713    }
2714
2715    #[test]
2716    fn dcn_negative_input_no_crash() {
2717        let mut n = DCNNeuron::new();
2718        for _ in 0..10_000 {
2719            n.step(-100.0);
2720        }
2721        assert!(n.v.is_finite());
2722        assert!(n.v >= -100.0);
2723    }
2724
2725    #[test]
2726    fn dcn_nan_input_stays_finite() {
2727        let mut n = DCNNeuron::new();
2728        let before = n.clone();
2729        n.step(f64::NAN);
2730        assert!(n.v.is_finite());
2731        assert_eq!(n.v, before.v);
2732        assert_eq!(n.ca, before.ca);
2733    }
2734
2735    #[test]
2736    fn dcn_extreme_input_bounded() {
2737        let mut n = DCNNeuron::new();
2738        for _ in 0..1000 {
2739            n.step(1e6);
2740        }
2741        assert!(n.v.is_finite() && n.v <= 60.0);
2742    }
2743
2744    #[test]
2745    fn dcn_corrupted_state_preserved_on_step() {
2746        let mut n = DCNNeuron::new();
2747        n.h = -0.1;
2748        let before_v = n.v;
2749        let before_ca = n.ca;
2750        assert_eq!(n.step(10.0), 0);
2751        assert_eq!(n.v, before_v);
2752        assert_eq!(n.ca, before_ca);
2753    }
2754
2755    #[test]
2756    fn dcn_reset_clears_state() {
2757        let mut n = DCNNeuron::new();
2758        for _ in 0..1000 {
2759            n.step(10.0);
2760        }
2761        n.reset();
2762        assert_eq!(n.v, -60.0);
2763        assert_eq!(n.s, 0.8);
2764        assert_eq!(n.r, 0.1);
2765    }
2766
2767    #[test]
2768    fn dcn_gates_bounded() {
2769        let mut n = DCNNeuron::new();
2770        for _ in 0..10_000 {
2771            n.step(10.0);
2772        }
2773        for (name, val) in [("h", n.h), ("n", n.n), ("p", n.p), ("s", n.s), ("r", n.r)] {
2774            assert!((0.0..=1.0).contains(&val), "{name} out of bounds: {val}");
2775        }
2776        assert!(n.ca >= 0.0, "Ca²⁺ must be non-negative: {}", n.ca);
2777    }
2778
2779    #[test]
2780    fn dcn_nap_increases_excitability() {
2781        // INaP amplifies subthreshold depolarisation
2782        let mut with_nap = DCNNeuron::new();
2783        let mut no_nap = DCNNeuron::new();
2784        no_nap.g_nap = 0.0;
2785        let mut spikes_with = 0;
2786        let mut spikes_no = 0;
2787        for _ in 0..5_000 {
2788            spikes_with += with_nap.step(3.0);
2789            spikes_no += no_nap.step(3.0);
2790        }
2791        assert!(
2792            spikes_with >= spikes_no,
2793            "INaP should increase excitability: with={spikes_with}, without={spikes_no}"
2794        );
2795    }
2796
2797    #[test]
2798    fn dcn_ahp_limits_rate() {
2799        // Ca²⁺-AHP should reduce sustained firing rate
2800        let mut with_ahp = DCNNeuron::new();
2801        let mut no_ahp = DCNNeuron::new();
2802        no_ahp.g_ahp = 0.0;
2803        let mut spikes_with = 0;
2804        let mut spikes_no = 0;
2805        for _ in 0..5_000 {
2806            spikes_with += with_ahp.step(8.0);
2807            spikes_no += no_ahp.step(8.0);
2808        }
2809        assert!(
2810            spikes_no >= spikes_with,
2811            "AHP removal should increase firing: with={spikes_with}, without={spikes_no}"
2812        );
2813    }
2814
2815    #[test]
2816    fn dcn_ca_rises_during_spiking() {
2817        let mut n = DCNNeuron::new();
2818        let ca_init = n.ca;
2819        for _ in 0..5_000 {
2820            n.step(10.0);
2821        }
2822        assert!(
2823            n.ca > ca_init,
2824            "Ca²⁺ must rise during spiking: init={ca_init}, now={}",
2825            n.ca
2826        );
2827    }
2828
2829    #[test]
2830    fn dcn_has_seven_currents() {
2831        // Na_t, Na_p, K_dr, Ca_T, AHP, Ih, leak = 7
2832        let n = DCNNeuron::new();
2833        assert!(n.g_na > 0.0, "Na_t missing");
2834        assert!(n.g_nap > 0.0, "Na_p missing");
2835        assert!(n.g_k > 0.0, "K_dr missing");
2836        assert!(n.g_t > 0.0, "Ca_T missing");
2837        assert!(n.g_ahp > 0.0, "AHP missing");
2838        assert!(n.g_h > 0.0, "Ih missing");
2839        assert!(n.g_l > 0.0, "Leak missing");
2840    }
2841
2842    #[test]
2843    fn dcn_performance_1k_steps() {
2844        let start = std::time::Instant::now();
2845        let mut n = DCNNeuron::new();
2846        for _ in 0..1_000 {
2847            std::hint::black_box(n.step(5.0));
2848        }
2849        let elapsed = start.elapsed();
2850        assert!(
2851            elapsed.as_millis() < 200,
2852            "1k steps must complete in <200ms"
2853        );
2854    }
2855
2856    fn assert_close(observed: f64, expected: f64) {
2857        assert!(
2858            (observed - expected).abs() <= 1.0e-12,
2859            "observed {:.17e}, expected {:.17e}",
2860            observed,
2861            expected,
2862        );
2863    }
2864}