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        1.0 / (1.0 + (-(v - vh) / k).exp())
121    }
122
123    pub fn step(&mut self, current: f64) -> i32 {
124        let input = self.gain * current;
125        let dt_sub = self.dt / self.sub_steps as f64;
126        let v_prev = self.v;
127
128        for _ in 0..self.sub_steps {
129            let v = self.v;
130
131            // Na m gate (fast activation, Boltzmann + tau)
132            let m_inf = Self::boltz(v, -30.0, 7.0);
133            let tau_m = 0.1 + 0.3 / (1.0 + ((v + 30.0) / 10.0).powi(2)).max(0.01);
134            self.m += dt_sub * (m_inf - self.m) / tau_m;
135
136            // Na h gate (inactivation)
137            let h_inf = Self::boltz(v, -52.0, -6.0);
138            let tau_h = 0.5 + 5.0 / (1.0 + ((v + 50.0) / 15.0).powi(2)).max(0.01);
139            self.h += dt_sub * (h_inf - self.h) / tau_h;
140
141            // K_dr n gate
142            let n_inf = Self::boltz(v, -35.0, 8.0);
143            let tau_n = 1.0 + 5.0 / (1.0 + ((v + 35.0) / 15.0).powi(2)).max(0.01);
144            self.n += dt_sub * (n_inf - self.n) / tau_n;
145
146            // K_A a gate (fast activation)
147            let a_inf = Self::boltz(v, -50.0, 20.0);
148            let tau_a = 2.0;
149            self.a += dt_sub * (a_inf - self.a) / tau_a;
150
151            // K_A b gate (slow inactivation)
152            let b_inf = Self::boltz(v, -70.0, -6.0);
153            let tau_b = 50.0;
154            self.b += dt_sub * (b_inf - self.b) / tau_b;
155
156            // T-type Ca²⁺ m_t (fast activation)
157            let mt_inf = Self::boltz(v, -52.0, 5.0);
158            let tau_mt = 1.0;
159            self.m_t += dt_sub * (mt_inf - self.m_t) / tau_mt;
160
161            // T-type Ca²⁺ s (slow inactivation)
162            let s_inf = Self::boltz(v, -60.0, -6.5);
163            let tau_s = 20.0 + 50.0 / (1.0 + ((v + 65.0) / 10.0).powi(2)).max(0.01);
164            self.s += dt_sub * (s_inf - self.s) / tau_s;
165
166            // Ih r gate (slow activation at hyperpolarised V)
167            let r_inf = Self::boltz(v, -80.0, -10.0);
168            let tau_r = 50.0 + 200.0 / (1.0 + ((v + 80.0) / 20.0).powi(2)).max(0.01);
169            self.r += dt_sub * (r_inf - self.r) / tau_r;
170
171            // Clamp gates
172            self.m = self.m.clamp(0.0, 1.0);
173            self.h = self.h.clamp(0.0, 1.0);
174            self.n = self.n.clamp(0.0, 1.0);
175            self.a = self.a.clamp(0.0, 1.0);
176            self.b = self.b.clamp(0.0, 1.0);
177            self.m_t = self.m_t.clamp(0.0, 1.0);
178            self.s = self.s.clamp(0.0, 1.0);
179            self.r = self.r.clamp(0.0, 1.0);
180
181            // Ca²⁺ dynamics
182            let i_ca_t = self.g_t * self.m_t * self.m_t * self.s * (v - self.e_ca);
183            let ca_entry = if i_ca_t < 0.0 { -i_ca_t * 0.001 } else { 0.0 }; // Inward Ca²⁺
184            self.ca += dt_sub * (-self.ca / self.tau_ca + ca_entry);
185            self.ca = self.ca.max(0.0);
186
187            // K_Ca (Hill function of Ca²⁺)
188            let kca_inf = self.ca * self.ca / (self.ca * self.ca + self.kd_kca * self.kd_kca);
189
190            // Ionic currents
191            let i_na = self.g_na * self.m.powi(3) * self.h * (v - self.e_na);
192            let i_kdr = self.g_kdr * self.n.powi(4) * (v - self.e_k);
193            let i_ka = self.g_ka * self.a.powi(3) * self.b * (v - self.e_k);
194            let i_kca = self.g_kca * kca_inf * (v - self.e_k);
195            let i_h = self.g_h * self.r * (v - self.e_h);
196            let i_l = self.g_l * (v - self.e_l);
197            let i_gaba = self.g_tonic * (v - self.e_gaba);
198
199            let dv =
200                (-(i_na + i_kdr + i_ka + i_ca_t + i_kca + i_h + i_l + i_gaba) + input) / self.c_m;
201            self.v += dt_sub * dv;
202        }
203
204        // Safety
205        self.v = self.v.clamp(-100.0, 60.0);
206        if !self.v.is_finite() {
207            self.v = -70.0;
208        }
209        if !self.m.is_finite() {
210            self.m = 0.02;
211        }
212        if !self.h.is_finite() {
213            self.h = 0.85;
214        }
215        if !self.n.is_finite() {
216            self.n = 0.05;
217        }
218        if !self.ca.is_finite() {
219            self.ca = 0.05;
220        }
221
222        // Spike: V crosses 0 mV
223        if self.v >= 0.0 && v_prev < 0.0 {
224            1
225        } else {
226            0
227        }
228    }
229
230    pub fn reset(&mut self) {
231        *self = Self::new();
232    }
233}
234
235// ═══════════════════════════════════════════════════════════════════
236// Golgi Cell
237// ═══════════════════════════════════════════════════════════════════
238
239/// Cerebellar Golgi cell — Solinas et al. 2007 full model.
240///
241/// Large inhibitory interneuron in the granular layer. Provides tonic
242/// and phasic GABAergic/glycinergic inhibition to granule cells.
243/// Spontaneously active at 3-10 Hz due to intrinsic pacemaker currents.
244///
245/// Full Solinas 2007 model with 11 ionic currents:
246/// - **INa_t** (transient Na, m³h): fast spike generation
247/// - **INa_p** (persistent Na, p): subthreshold oscillations, pacemaking
248/// - **IK_dr** (delayed rectifier K, n⁴): repolarisation
249/// - **IK_A** (A-type K, a³b): onset delay, inter-spike interval
250/// - **IK_M** (muscarinic/slow K, w): spike frequency adaptation
251/// - **ICa_T** (T-type Ca²⁺, m_t²s): rebound, subthreshold oscillations
252/// - **ICa_N** (N-type Ca²⁺, c²): high-voltage activated, AHP trigger
253/// - **IBK** (BK, Ca²⁺+V dependent): fast AHP
254/// - **ISK** (SK, Ca²⁺ dependent): slow AHP, pacemaker regulation
255/// - **Ih** (HCN, r): sag, resting potential, pacemaker contribution
256/// - **IL** (leak)
257///
258/// 10 sub-steps (dt_sub = 0.05 ms) for Na gating stability.
259///
260/// Solinas et al., Front Cell Neurosci 1:2, 2007.
261#[derive(Clone, Debug)]
262pub struct GolgiCell {
263    pub v: f64,
264    pub m: f64,    // Na_t activation
265    pub h: f64,    // Na_t inactivation
266    pub p_na: f64, // Na_p persistent activation
267    pub n: f64,    // K_dr activation
268    pub a: f64,    // K_A activation
269    pub b: f64,    // K_A inactivation
270    pub w: f64,    // K_M (muscarinic) activation
271    pub m_t: f64,  // Ca_T activation
272    pub s: f64,    // Ca_T inactivation
273    pub c_n: f64,  // Ca_N activation
274    pub r: f64,    // Ih activation
275    pub ca: f64,   // Intracellular Ca²⁺ (µM)
276    // Conductances (mS/cm²)
277    pub g_na_t: f64,
278    pub g_na_p: f64,
279    pub g_kdr: f64,
280    pub g_ka: f64,
281    pub g_km: f64,
282    pub g_cat: f64,
283    pub g_can: f64,
284    pub g_bk: f64,
285    pub g_sk: f64,
286    pub g_h: f64,
287    pub g_l: f64,
288    // Reversals
289    pub e_na: f64,
290    pub e_k: f64,
291    pub e_ca: f64,
292    pub e_h: f64,
293    pub e_l: f64,
294    pub c_m: f64,
295    pub tau_ca: f64,
296    pub kd_bk: f64,
297    pub kd_sk: f64,
298    pub dt: f64,
299    pub sub_steps: usize,
300    pub gain: f64,
301}
302
303impl Default for GolgiCell {
304    fn default() -> Self {
305        Self::new()
306    }
307}
308
309impl GolgiCell {
310    pub fn new() -> Self {
311        Self {
312            v: -60.0,
313            m: 0.02,
314            h: 0.85,
315            p_na: 0.01,
316            n: 0.05,
317            a: 0.1,
318            b: 0.8,
319            w: 0.01,
320            m_t: 0.01,
321            s: 0.9,
322            c_n: 0.01,
323            r: 0.1,
324            ca: 0.05,
325            g_na_t: 48.0, // Solinas 2007 Table 1
326            g_na_p: 0.2,  // Persistent Na (small but critical for pacemaking)
327            g_kdr: 16.0,
328            g_ka: 8.0,  // A-type
329            g_km: 1.0,  // Muscarinic slow K
330            g_cat: 0.5, // T-type Ca²⁺
331            g_can: 1.0, // N-type Ca²⁺ (high-voltage)
332            g_bk: 3.0,  // BK fast AHP
333            g_sk: 1.0,  // SK slow AHP
334            g_h: 0.1,   // Ih
335            g_l: 0.05,
336            e_na: 55.0,
337            e_k: -90.0,
338            e_ca: 120.0,
339            e_h: -40.0,
340            e_l: -55.0, // Depolarised leak for spontaneous activity
341            c_m: 1.0,
342            tau_ca: 200.0,
343            kd_bk: 1.0,
344            kd_sk: 0.5,
345            dt: 0.5,
346            sub_steps: 10,
347            gain: 1.0,
348        }
349    }
350
351    #[inline]
352    fn boltz(v: f64, vh: f64, k: f64) -> f64 {
353        1.0 / (1.0 + (-(v - vh) / k).exp())
354    }
355
356    pub fn step(&mut self, current: f64) -> i32 {
357        let input = self.gain * current;
358        let dt_sub = self.dt / self.sub_steps as f64;
359        let v_prev = self.v;
360
361        for _ in 0..self.sub_steps {
362            let v = self.v;
363
364            // Na_t: m³h (fast, WB-style alpha/beta)
365            let alpha_m = safe_rate(0.1, 35.0, v, 10.0, 1.0);
366            let beta_m = 4.0 * (-(v + 60.0) / 18.0).exp();
367            let alpha_h = 0.07 * (-(v + 58.0) / 20.0).exp();
368            let beta_h = 1.0 / (1.0 + (-(v + 28.0) / 10.0).exp());
369            self.m += dt_sub * 5.0 * (alpha_m * (1.0 - self.m) - beta_m * self.m);
370            self.h += dt_sub * 5.0 * (alpha_h * (1.0 - self.h) - beta_h * self.h);
371
372            // Na_p: persistent (Boltzmann, slow)
373            let pna_inf = Self::boltz(v, -48.0, 5.0);
374            let tau_pna = 5.0 + 20.0 / (1.0 + ((v + 48.0) / 10.0).powi(2)).max(0.01);
375            self.p_na += dt_sub * (pna_inf - self.p_na) / tau_pna;
376
377            // K_dr: n⁴
378            let alpha_n = safe_rate(0.01, 34.0, v, 10.0, 0.1);
379            let beta_n = 0.125 * (-(v + 44.0) / 80.0).exp();
380            self.n += dt_sub * 5.0 * (alpha_n * (1.0 - self.n) - beta_n * self.n);
381
382            // K_A: a³b (Solinas 2007: V1/2_act ≈ -27 mV, V1/2_inact ≈ -80 mV)
383            let a_inf = Self::boltz(v, -27.0, 16.0);
384            self.a += dt_sub * (a_inf - self.a) / 2.0;
385            let b_inf = Self::boltz(v, -80.0, -6.0);
386            self.b += dt_sub * (b_inf - self.b) / 15.0;
387
388            // K_M: w (slow muscarinic)
389            let w_inf = Self::boltz(v, -35.0, 10.0);
390            let tau_w = 100.0 / (3.3 * ((v + 35.0) / 20.0).exp() + (-(v + 35.0) / 20.0).exp());
391            self.w += dt_sub * (w_inf - self.w) / tau_w;
392
393            // Ca_T: m_t²s
394            let mt_inf = Self::boltz(v, -52.0, 5.0);
395            self.m_t += dt_sub * (mt_inf - self.m_t) / 1.0;
396            let s_inf = Self::boltz(v, -60.0, -6.5);
397            let tau_s = 20.0 + 50.0 / (1.0 + ((v + 65.0) / 10.0).powi(2)).max(0.01);
398            self.s += dt_sub * (s_inf - self.s) / tau_s;
399
400            // Ca_N: c² (high-voltage activated)
401            let cn_inf = Self::boltz(v, -20.0, 5.0);
402            let tau_cn = 2.0 + 10.0 / (1.0 + ((v + 20.0) / 10.0).powi(2)).max(0.01);
403            self.c_n += dt_sub * (cn_inf - self.c_n) / tau_cn;
404
405            // Ih: r (slow, hyperpolarisation-activated)
406            let r_inf = Self::boltz(v, -80.0, -10.0);
407            let tau_r = 50.0 + 200.0 / (1.0 + ((v + 80.0) / 20.0).powi(2)).max(0.01);
408            self.r += dt_sub * (r_inf - self.r) / tau_r;
409
410            // Clamp all gates
411            self.m = self.m.clamp(0.0, 1.0);
412            self.h = self.h.clamp(0.0, 1.0);
413            self.p_na = self.p_na.clamp(0.0, 1.0);
414            self.n = self.n.clamp(0.0, 1.0);
415            self.a = self.a.clamp(0.0, 1.0);
416            self.b = self.b.clamp(0.0, 1.0);
417            self.w = self.w.clamp(0.0, 1.0);
418            self.m_t = self.m_t.clamp(0.0, 1.0);
419            self.s = self.s.clamp(0.0, 1.0);
420            self.c_n = self.c_n.clamp(0.0, 1.0);
421            self.r = self.r.clamp(0.0, 1.0);
422
423            // Ca²⁺ dynamics (entry via Ca_T + Ca_N, decay)
424            let i_cat = self.g_cat * self.m_t.powi(2) * self.s * (v - self.e_ca);
425            let i_can = self.g_can * self.c_n.powi(2) * (v - self.e_ca);
426            let ca_entry = if i_cat + i_can < 0.0 {
427                -(i_cat + i_can) * 0.001
428            } else {
429                0.0
430            };
431            self.ca += dt_sub * (ca_entry - self.ca / self.tau_ca);
432            self.ca = self.ca.max(0.0);
433
434            // BK: voltage + Ca²⁺ dependent (Hill n=2 for Ca²⁺ shift)
435            // V1/2 shifts from +100 mV (low Ca) to -20 mV (high Ca)
436            let ca2 = self.ca * self.ca;
437            let kd2 = self.kd_bk * self.kd_bk;
438            let bk_v = Self::boltz(v, 100.0 - 120.0 * ca2 / (ca2 + kd2), 15.0);
439            // SK: Ca²⁺ dependent (Hill n=2)
440            let sk_inf = self.ca.powi(2) / (self.ca.powi(2) + self.kd_sk.powi(2));
441
442            // All ionic currents
443            let i_na_t = self.g_na_t * self.m.powi(3) * self.h * (v - self.e_na);
444            let i_na_p = self.g_na_p * self.p_na * (v - self.e_na);
445            let i_kdr = self.g_kdr * self.n.powi(4) * (v - self.e_k);
446            let i_ka = self.g_ka * self.a.powi(3) * self.b * (v - self.e_k);
447            let i_km = self.g_km * self.w * (v - self.e_k);
448            let i_bk = self.g_bk * bk_v * (v - self.e_k);
449            let i_sk = self.g_sk * sk_inf * (v - self.e_k);
450            let i_h = self.g_h * self.r * (v - self.e_h);
451            let i_l = self.g_l * (v - self.e_l);
452
453            let dv = (-(i_na_t
454                + i_na_p
455                + i_kdr
456                + i_ka
457                + i_km
458                + i_cat
459                + i_can
460                + i_bk
461                + i_sk
462                + i_h
463                + i_l)
464                + input)
465                / self.c_m;
466            self.v += dt_sub * dv;
467        }
468
469        // Safety
470        self.v = self.v.clamp(-100.0, 60.0);
471        if !self.v.is_finite() {
472            self.v = -60.0;
473        }
474        if !self.m.is_finite() {
475            self.m = 0.02;
476        }
477        if !self.h.is_finite() {
478            self.h = 0.85;
479        }
480        if !self.ca.is_finite() {
481            self.ca = 0.05;
482        }
483
484        // Spike: V crosses 0 mV
485        if self.v >= 0.0 && v_prev < 0.0 {
486            1
487        } else {
488            0
489        }
490    }
491
492    pub fn reset(&mut self) {
493        *self = Self::new();
494    }
495}
496
497// ═══════════════════════════════════════════════════════════════════
498// Stellate Cell
499// ═══════════════════════════════════════════════════════════════════
500
501/// Cerebellar stellate cell — fast-spiking interneuron in the molecular layer.
502///
503/// Biophysics: Wang-Buzsáki Na+/K+ core extended with Kv3.1 for narrow
504/// action potentials and high-frequency firing. Provides feedforward
505/// inhibition onto Purkinje cell dendrites. Receives excitatory input
506/// from parallel fibres (granule cell axons).
507///
508/// Stellate cells are smaller than basket cells and innervate more distal
509/// Purkinje cell dendrites. They show minimal spike frequency adaptation
510/// and can sustain high firing rates.
511///
512/// Sultan & Bower, J Comp Neurol 409:63, 1999; Häusser & Clark, Neuron 19:665, 1997.
513#[derive(Clone, Debug)]
514pub struct StellateCell {
515    pub v: f64,
516    pub h: f64, // Na+ inactivation
517    pub n: f64, // Kdr activation
518    pub p: f64, // Kv3.1 activation
519    // Conductances (mS/cm²)
520    pub g_na: f64,
521    pub g_k: f64,
522    pub g_kv3: f64,
523    pub g_l: f64,
524    // Reversal potentials (mV)
525    pub e_na: f64,
526    pub e_k: f64,
527    pub e_l: f64,
528    pub c_m: f64,
529    pub phi: f64,
530    pub dt: f64,
531    pub v_threshold: f64,
532    pub gain: f64,
533}
534
535impl Default for StellateCell {
536    fn default() -> Self {
537        Self::new()
538    }
539}
540
541impl StellateCell {
542    pub fn new() -> Self {
543        Self {
544            v: -65.0,
545            h: 0.6,
546            n: 0.32,
547            p: 0.0,
548            g_na: 35.0,
549            g_k: 9.0,
550            g_kv3: 3.0, // Less Kv3.1 than PV+ basket
551            g_l: 0.1,
552            e_na: 55.0,
553            e_k: -90.0,
554            e_l: -65.0,
555            c_m: 0.5, // Smaller cell → lower capacitance
556            phi: 5.0,
557            dt: 0.5,
558            v_threshold: -20.0,
559            gain: 1.0,
560        }
561    }
562
563    pub fn step(&mut self, current: f64) -> i32 {
564        let input = self.gain * current;
565        let sub_steps = 50;
566        let sub_dt = self.dt / sub_steps as f64;
567        let mut fired = 0i32;
568
569        for _ in 0..sub_steps {
570            let v = self.v;
571
572            // WB alpha/beta rates
573            let alpha_m = safe_rate(0.1, 35.0, v, 10.0, 1.0);
574            let beta_m = 4.0 * (-(v + 60.0) / 18.0).exp();
575            let m_inf = alpha_m / (alpha_m + beta_m);
576
577            let alpha_h = 0.07 * (-(v + 58.0) / 20.0).exp();
578            let beta_h = 1.0 / (1.0 + (-(v + 28.0) / 10.0).exp());
579
580            let alpha_n = safe_rate(0.01, 34.0, v, 10.0, 0.1);
581            let beta_n = 0.125 * (-(v + 44.0) / 80.0).exp();
582
583            // Kv3.1 gating (fast activation, no inactivation)
584            let p_inf = 1.0 / (1.0 + (-(v + 10.0) / 10.0).exp());
585            let tau_p = 1.0 + 4.0 / (1.0 + ((v + 20.0) / 15.0).exp());
586
587            // Gate updates
588            self.h += sub_dt * self.phi * (alpha_h * (1.0 - self.h) - beta_h * self.h);
589            self.n += sub_dt * self.phi * (alpha_n * (1.0 - self.n) - beta_n * self.n);
590            self.p += sub_dt * (p_inf - self.p) / tau_p;
591
592            // Currents (m uses steady-state for speed)
593            let i_na = self.g_na * m_inf.powi(3) * self.h * (v - self.e_na);
594            let i_k = self.g_k * self.n.powi(4) * (v - self.e_k);
595            let i_kv3 = self.g_kv3 * self.p.powi(2) * (v - self.e_k);
596            let i_l = self.g_l * (v - self.e_l);
597
598            let dv = (-i_na - i_k - i_kv3 - i_l + input) / self.c_m;
599            self.v += sub_dt * dv;
600
601            if self.v >= self.v_threshold {
602                fired = 1;
603                self.v = -65.0;
604            }
605        }
606
607        // Safety bounds
608        self.v = self.v.clamp(-100.0, 60.0);
609        if !self.v.is_finite() {
610            self.v = -65.0;
611            self.h = 0.6;
612            self.n = 0.32;
613        }
614        self.h = self.h.clamp(0.0, 1.0);
615        self.n = self.n.clamp(0.0, 1.0);
616        self.p = self.p.clamp(0.0, 1.0);
617
618        fired
619    }
620
621    pub fn reset(&mut self) {
622        *self = Self::new();
623    }
624}
625
626// ═══════════════════════════════════════════════════════════════════
627// Lugaro Cell
628// ═══════════════════════════════════════════════════════════════════
629
630/// Cerebellar Lugaro cell — rare fusiform interneuron in the granular layer.
631///
632/// Biophysics: LIF with adaptation for regular spiking, serotonin modulation
633/// (5-HT increases gain), and a depolarised leak for spontaneous firing.
634/// Inhibits Golgi cells and molecular layer interneurons (stellate, basket).
635///
636/// Lugaro cells are distinguished by their horizontal axonal projection,
637/// large fusiform soma, and sensitivity to serotonergic afferents from
638/// the brainstem raphe nuclei.
639///
640/// Dieudonné & Bhatt, J Physiol 548:97, 2003; Lainé & Bhatt, Front Syst Neurosci 1:4, 2007.
641#[derive(Clone, Debug)]
642pub struct LugaroCell {
643    pub v: f64,
644    pub adapt: f64, // Adaptation current
645    pub v_rest: f64,
646    pub v_reset: f64,
647    pub v_threshold: f64,
648    pub tau_m: f64,
649    pub tau_adapt: f64,
650    pub a_adapt: f64, // Adaptation coupling strength
651    pub gain: f64,
652    pub serotonin: f64, // 5-HT modulation factor [0, 1]
653    pub dt: f64,
654}
655
656impl Default for LugaroCell {
657    fn default() -> Self {
658        Self::new()
659    }
660}
661
662impl LugaroCell {
663    pub fn new() -> Self {
664        Self {
665            v: -55.0,
666            adapt: 0.0,
667            v_rest: -55.0, // Depolarised rest for spontaneous firing
668            v_reset: -65.0,
669            v_threshold: -48.0,
670            tau_m: 10.0,
671            tau_adapt: 150.0,
672            a_adapt: 0.05,
673            gain: 2.0,
674            serotonin: 0.0, // No 5-HT modulation by default
675            dt: 0.5,
676        }
677    }
678
679    /// Create with serotonin modulation active.
680    pub fn with_serotonin(serotonin_level: f64) -> Self {
681        let mut n = Self::new();
682        n.serotonin = serotonin_level.clamp(0.0, 1.0);
683        n
684    }
685
686    pub fn step(&mut self, current: f64) -> i32 {
687        // 5-HT modulation: increases effective gain
688        let effective_gain = self.gain * (1.0 + 0.5 * self.serotonin);
689        let input = effective_gain * current;
690
691        // LIF dynamics with adaptation
692        let dv = (-(self.v - self.v_rest) - self.adapt + input) / self.tau_m;
693        self.v += self.dt * dv;
694
695        // Adaptation dynamics
696        let da = (self.a_adapt * (self.v - self.v_rest) - self.adapt) / self.tau_adapt;
697        self.adapt += self.dt * da;
698
699        // Spike detection
700        if self.v >= self.v_threshold {
701            self.v = self.v_reset;
702            self.adapt += 1.0; // Spike-triggered adaptation increment
703            return 1;
704        }
705
706        // Safety bounds
707        self.v = self.v.clamp(-100.0, 60.0);
708        if !self.v.is_finite() {
709            self.v = self.v_reset;
710        }
711        if !self.adapt.is_finite() {
712            self.adapt = 0.0;
713        }
714
715        0
716    }
717
718    pub fn reset(&mut self) {
719        *self = Self::new();
720    }
721}
722
723// ═══════════════════════════════════════════════════════════════════
724// Unipolar Brush Cell
725// ═══════════════════════════════════════════════════════════════════
726
727/// Cerebellar unipolar brush cell (UBC) — excitatory interneuron in vestibular cerebellum.
728///
729/// Biophysics: LIF with a slow persistent (NMDA-like) current that sustains
730/// depolarisation long after input ceases. The single brush-like dendrite
731/// forms a giant synapse with a mossy fibre rosette, creating a 1:1 relay
732/// that amplifies and prolongs the input signal.
733///
734/// UBCs are unique excitatory interneurons in the granular layer. They
735/// transform brief mossy fibre bursts into prolonged granule cell
736/// activation, important for vestibular signal processing and timing.
737///
738/// Bhatt et al., J Comp Neurol 349:560, 1994; Diana et al., J Neurosci 27:4374, 2007.
739#[derive(Clone, Debug)]
740pub struct UnipolarBrushCell {
741    pub v: f64,
742    pub persistent: f64, // Slow NMDA-like persistent current
743    pub v_rest: f64,
744    pub v_reset: f64,
745    pub v_threshold: f64,
746    pub tau_m: f64,
747    pub tau_persistent: f64,  // Slow decay of persistent current (ms)
748    pub persistent_gain: f64, // How much input drives persistent current
749    pub gain: f64,
750    pub dt: f64,
751}
752
753impl Default for UnipolarBrushCell {
754    fn default() -> Self {
755        Self::new()
756    }
757}
758
759impl UnipolarBrushCell {
760    pub fn new() -> Self {
761        Self {
762            v: -65.0,
763            persistent: 0.0,
764            v_rest: -65.0,
765            v_reset: -70.0,
766            v_threshold: -50.0,
767            tau_m: 8.0,
768            tau_persistent: 200.0,
769            persistent_gain: 0.5,
770            gain: 2.5,
771            dt: 0.5,
772        }
773    }
774
775    pub fn step(&mut self, current: f64) -> i32 {
776        let input = self.gain * current.max(0.0);
777
778        // Persistent current dynamics: driven by input, decays slowly
779        let dp = (self.persistent_gain * input - self.persistent) / self.tau_persistent;
780        self.persistent += self.dt * dp;
781        if self.persistent < 0.0 {
782            self.persistent = 0.0;
783        }
784
785        // LIF with persistent current
786        let dv = (-(self.v - self.v_rest) + input + self.persistent) / self.tau_m;
787        self.v += self.dt * dv;
788
789        // Spike detection
790        if self.v >= self.v_threshold {
791            self.v = self.v_reset;
792            return 1;
793        }
794
795        // Safety bounds
796        self.v = self.v.clamp(-100.0, 60.0);
797        if !self.v.is_finite() {
798            self.v = self.v_reset;
799        }
800        if !self.persistent.is_finite() {
801            self.persistent = 0.0;
802        }
803
804        0
805    }
806
807    pub fn reset(&mut self) {
808        *self = Self::new();
809    }
810}
811
812// ═══════════════════════════════════════════════════════════════════
813// Deep Cerebellar Nuclei Neuron
814// ═══════════════════════════════════════════════════════════════════
815
816/// Deep cerebellar nuclei (DCN) neuron — main output of the cerebellum.
817///
818/// Biophysics: WB Na+/K+ core with T-type Ca²⁺ for post-inhibitory rebound
819/// bursting, Ih (HCN) for pacemaker-like activity, persistent Na (INaP) for
820/// subthreshold depolarisation, and Ca²⁺-dependent AHP for spike frequency
821/// adaptation.
822///
823/// 7 currents: INa_t, INaP, IK_dr, ICa_T, IAHP, Ih, IL
824///
825/// Rebound bursting: when Purkinje inhibition is released, T-type Ca²⁺
826/// channels that de-inactivated during hyperpolarisation produce a burst.
827/// INaP amplifies subthreshold depolarisation. AHP limits burst duration.
828///
829/// Llinás & Mühlethaler, J Physiol 404:241, 1988; Jahnsen, J Physiol 372:129, 1986.
830#[derive(Clone, Debug)]
831pub struct DCNNeuron {
832    pub v: f64,
833    pub h: f64,  // Na_t inactivation
834    pub n: f64,  // K_dr activation
835    pub p: f64,  // Na_p persistent activation
836    pub s: f64,  // T-type Ca²⁺ inactivation (slow)
837    pub r: f64,  // Ih activation
838    pub ca: f64, // Intracellular Ca²⁺ (µM)
839    // Conductances (mS/cm²)
840    pub g_na: f64,
841    pub g_nap: f64, // Persistent Na
842    pub g_k: f64,
843    pub g_t: f64,   // T-type Ca²⁺
844    pub g_ahp: f64, // Ca²⁺-dependent AHP
845    pub g_h: f64,   // Ih
846    pub g_l: f64,
847    // Reversal potentials
848    pub e_na: f64,
849    pub e_k: f64,
850    pub e_ca: f64,
851    pub e_h: f64,
852    pub e_l: f64,
853    pub c_m: f64,
854    pub phi: f64,
855    pub tau_ca: f64,
856    pub kd_ahp: f64,
857    pub dt: f64,
858    pub v_threshold: f64,
859    pub gain: f64,
860}
861
862impl Default for DCNNeuron {
863    fn default() -> Self {
864        Self::new()
865    }
866}
867
868impl DCNNeuron {
869    pub fn new() -> Self {
870        Self {
871            v: -60.0,
872            h: 0.6,
873            n: 0.32,
874            p: 0.01,  // NaP activation (low at rest)
875            s: 0.8,   // T-type de-inactivated at rest
876            r: 0.1,   // Ih partially active
877            ca: 0.05, // Resting Ca²⁺ (µM)
878            g_na: 35.0,
879            g_nap: 0.5, // Persistent Na — amplifies subthreshold
880            g_k: 9.0,
881            g_t: 0.1,   // T-type Ca²⁺
882            g_ahp: 2.0, // Ca²⁺-dependent AHP
883            g_h: 0.02,  // Ih — modest
884            g_l: 0.2,   // Leak
885            e_na: 55.0,
886            e_k: -90.0,
887            e_ca: 120.0,
888            e_h: -40.0,
889            e_l: -65.0,
890            c_m: 1.0,
891            phi: 5.0,
892            tau_ca: 150.0,
893            kd_ahp: 0.5,
894            dt: 0.5,
895            v_threshold: -20.0,
896            gain: 1.0,
897        }
898    }
899
900    pub fn step(&mut self, current: f64) -> i32 {
901        let input = self.gain * current;
902        let sub_steps = 20;
903        let sub_dt = self.dt / sub_steps as f64;
904        let mut fired = 0i32;
905
906        for _ in 0..sub_steps {
907            let v = self.v;
908
909            // Na_t: WB alpha/beta rates (m³h, m quasi-static)
910            let alpha_m = safe_rate(0.1, 35.0, v, 10.0, 1.0);
911            let beta_m = 4.0 * (-(v + 60.0) / 18.0).exp();
912            let m_inf = alpha_m / (alpha_m + beta_m);
913
914            let alpha_h = 0.07 * (-(v + 58.0) / 20.0).exp();
915            let beta_h = 1.0 / (1.0 + (-(v + 28.0) / 10.0).exp());
916
917            // K_dr: n⁴
918            let alpha_n = safe_rate(0.01, 34.0, v, 10.0, 0.1);
919            let beta_n = 0.125 * (-(v + 44.0) / 80.0).exp();
920
921            // Na_p: persistent Na (Boltzmann, V1/2=-48, k=5)
922            let p_inf = 1.0 / (1.0 + (-(v + 48.0) / 5.0).exp());
923            let tau_p = 5.0 + 15.0 / (1.0 + ((v + 48.0) / 10.0).powi(2)).max(0.01);
924
925            // T-type Ca²⁺ gating
926            let m_t_inf = 1.0 / (1.0 + (-(v + 52.0) / 5.0).exp());
927            let s_inf = 1.0 / (1.0 + ((v + 60.0) / 6.5).exp());
928            let tau_s = 20.0 + 50.0 / (1.0 + ((v + 65.0) / 10.0).exp());
929
930            // Ih gating
931            let r_inf = 1.0 / (1.0 + ((v + 80.0) / 10.0).exp());
932            let tau_r = 100.0 + 200.0 / (1.0 + ((v + 70.0) / 10.0).exp());
933
934            // Gate updates
935            self.h += sub_dt * self.phi * (alpha_h * (1.0 - self.h) - beta_h * self.h);
936            self.n += sub_dt * self.phi * (alpha_n * (1.0 - self.n) - beta_n * self.n);
937            self.p += sub_dt * (p_inf - self.p) / tau_p;
938            self.s += sub_dt * (s_inf - self.s) / tau_s;
939            self.r += sub_dt * (r_inf - self.r) / tau_r;
940
941            // Ca²⁺ dynamics: entry via T-type, decay
942            let i_t = self.g_t * m_t_inf.powi(2) * self.s * (v - self.e_ca);
943            let ca_entry = if i_t < 0.0 { -i_t * 0.001 } else { 0.0 };
944            self.ca += sub_dt * (ca_entry - self.ca / self.tau_ca);
945            self.ca = self.ca.max(0.0);
946
947            // AHP: Ca²⁺-dependent K (Hill n=2)
948            let ahp_inf = self.ca.powi(2) / (self.ca.powi(2) + self.kd_ahp.powi(2));
949
950            // Currents
951            let i_na = self.g_na * m_inf.powi(3) * self.h * (v - self.e_na);
952            let i_nap = self.g_nap * self.p * (v - self.e_na);
953            let i_k = self.g_k * self.n.powi(4) * (v - self.e_k);
954            let i_ahp = self.g_ahp * ahp_inf * (v - self.e_k);
955            let i_h = self.g_h * self.r * (v - self.e_h);
956            let i_l = self.g_l * (v - self.e_l);
957
958            let dv = (-i_na - i_nap - i_k - i_t - i_ahp - i_h - i_l + input) / self.c_m;
959            self.v += sub_dt * dv;
960
961            if self.v >= self.v_threshold {
962                fired = 1;
963                self.v = -60.0;
964                self.s *= 0.5; // T-type inactivation on spike
965                self.ca += 0.5; // Ca²⁺ entry on spike
966            }
967        }
968
969        // Safety bounds
970        self.v = self.v.clamp(-100.0, 60.0);
971        if !self.v.is_finite() {
972            self.v = -60.0;
973            self.h = 0.6;
974            self.n = 0.32;
975        }
976        self.h = self.h.clamp(0.0, 1.0);
977        self.n = self.n.clamp(0.0, 1.0);
978        self.p = self.p.clamp(0.0, 1.0);
979        self.s = self.s.clamp(0.0, 1.0);
980        self.r = self.r.clamp(0.0, 1.0);
981
982        fired
983    }
984
985    pub fn reset(&mut self) {
986        *self = Self::new();
987    }
988}
989
990// ═══════════════════════════════════════════════════════════════════
991// Tests
992// ═══════════════════════════════════════════════════════════════════
993
994#[cfg(test)]
995mod tests {
996    use super::*;
997
998    // -- Granule Cell tests --
999
1000    #[test]
1001    fn granule_fires_with_strong_input() {
1002        let mut n = GranuleCell::new();
1003        let mut spikes = 0;
1004        for _ in 0..10_000 {
1005            spikes += n.step(15.0);
1006        }
1007        assert!(
1008            spikes > 10,
1009            "Granule cell must fire with strong excitatory input, got {spikes}"
1010        );
1011    }
1012
1013    #[test]
1014    fn granule_silent_at_rest() {
1015        let mut n = GranuleCell::new();
1016        let mut spikes = 0;
1017        for _ in 0..10_000 {
1018            spikes += n.step(0.0);
1019        }
1020        assert_eq!(
1021            spikes, 0,
1022            "Granule cell must be silent without input (tonic GABA inhibition)"
1023        );
1024    }
1025
1026    #[test]
1027    fn granule_no_fire_weak_input() {
1028        // Tonic GABA raises effective threshold
1029        let mut n = GranuleCell::new();
1030        let mut spikes = 0;
1031        for _ in 0..10_000 {
1032            spikes += n.step(1.0);
1033        }
1034        assert!(
1035            spikes == 0,
1036            "Weak input should not overcome tonic GABA, got {spikes}"
1037        );
1038    }
1039
1040    #[test]
1041    fn granule_tonic_gaba_raises_threshold() {
1042        // Compare firing with and without tonic GABA
1043        let mut with_gaba = GranuleCell::new();
1044        let mut no_gaba = GranuleCell::new();
1045        no_gaba.g_tonic = 0.0;
1046
1047        let input = 8.0;
1048        let mut spikes_gaba = 0;
1049        let mut spikes_no_gaba = 0;
1050        for _ in 0..10_000 {
1051            spikes_gaba += with_gaba.step(input);
1052            spikes_no_gaba += no_gaba.step(input);
1053        }
1054        assert!(
1055            spikes_no_gaba > spikes_gaba,
1056            "Removing tonic GABA must increase firing: no_gaba={spikes_no_gaba} vs gaba={spikes_gaba}"
1057        );
1058    }
1059
1060    #[test]
1061    fn granule_has_seven_currents() {
1062        // D'Angelo 2001 model must have all 7 ionic currents
1063        let n = GranuleCell::new();
1064        assert!(n.g_na > 0.0, "Must have INa");
1065        assert!(n.g_kdr > 0.0, "Must have IK_dr");
1066        assert!(n.g_ka > 0.0, "Must have IK_A");
1067        assert!(n.g_t > 0.0, "Must have ICa_T");
1068        assert!(n.g_kca > 0.0, "Must have IK_Ca");
1069        assert!(n.g_h > 0.0, "Must have Ih");
1070        assert!(n.g_l > 0.0, "Must have IL");
1071    }
1072
1073    #[test]
1074    fn granule_t_type_deinactivates_at_rest() {
1075        // T-type inactivation s should be high at rest (de-inactivated)
1076        let mut n = GranuleCell::new();
1077        for _ in 0..5000 {
1078            n.step(0.0);
1079        }
1080        assert!(
1081            n.s > 0.5,
1082            "T-type must be partially de-inactivated at rest, s={}",
1083            n.s
1084        );
1085    }
1086
1087    #[test]
1088    fn granule_ca_rises_with_spiking() {
1089        // Ca²⁺ should increase during spiking activity
1090        let mut n = GranuleCell::new();
1091        let ca0 = n.ca;
1092        for _ in 0..5000 {
1093            n.step(15.0);
1094        }
1095        assert!(
1096            n.ca > ca0,
1097            "Ca²⁺ should rise during spiking: ca0={ca0}, ca_now={}",
1098            n.ca
1099        );
1100    }
1101
1102    #[test]
1103    fn granule_negative_input_no_crash() {
1104        let mut n = GranuleCell::new();
1105        for _ in 0..10_000 {
1106            n.step(-100.0);
1107        }
1108        assert!(n.v.is_finite(), "Must stay finite with negative input");
1109        assert!(n.v >= -100.0, "Must be bounded");
1110    }
1111
1112    #[test]
1113    fn granule_nan_input_stays_finite() {
1114        let mut n = GranuleCell::new();
1115        n.step(f64::NAN);
1116        assert!(n.v.is_finite(), "NaN input must not corrupt state");
1117    }
1118
1119    #[test]
1120    fn granule_extreme_input_bounded() {
1121        let mut n = GranuleCell::new();
1122        for _ in 0..1000 {
1123            n.step(1e6);
1124        }
1125        assert!(
1126            n.v.is_finite() && n.v <= 60.0,
1127            "Extreme input must stay bounded"
1128        );
1129    }
1130
1131    #[test]
1132    fn granule_reset_clears_state() {
1133        let mut n = GranuleCell::new();
1134        for _ in 0..1000 {
1135            n.step(20.0);
1136        }
1137        n.reset();
1138        assert_eq!(n.v, -70.0);
1139        assert_eq!(n.s, 0.95);
1140        assert_eq!(n.m, 0.02);
1141    }
1142
1143    #[test]
1144    fn granule_high_input_resistance() {
1145        // Small soma → large voltage response to small current
1146        let mut n = GranuleCell::new();
1147        let v_before = n.v;
1148        // Single step with moderate input
1149        n.step(5.0);
1150        let dv = n.v - v_before;
1151        assert!(
1152            dv > 0.5,
1153            "High Rin should give large voltage change, got dv={dv}"
1154        );
1155    }
1156
1157    #[test]
1158    fn granule_performance_10k_steps() {
1159        let start = std::time::Instant::now();
1160        let mut n = GranuleCell::new();
1161        for _ in 0..10_000 {
1162            std::hint::black_box(n.step(10.0));
1163        }
1164        let elapsed = start.elapsed();
1165        assert!(
1166            elapsed.as_millis() < 50,
1167            "10k steps must complete in <50ms, took {}ms",
1168            elapsed.as_millis()
1169        );
1170    }
1171
1172    // -- Golgi Cell tests --
1173
1174    #[test]
1175    fn golgi_fires_with_input() {
1176        let mut n = GolgiCell::new();
1177        let mut spikes = 0;
1178        for _ in 0..10_000 {
1179            spikes += n.step(15.0);
1180        }
1181        assert!(
1182            spikes > 10,
1183            "Golgi cell must fire with excitatory input, got {spikes}"
1184        );
1185    }
1186
1187    #[test]
1188    fn golgi_spontaneous_firing() {
1189        // Golgi cells are spontaneously active due to depolarised leak
1190        let mut n = GolgiCell::new();
1191        let _spikes: i32 = (0..20_000).map(|_| n.step(0.0)).sum();
1192        // With e_l = -60 and v_t = -56.2, may or may not spontaneously fire
1193        // The key property is that they fire easily with minimal input
1194        let mut n2 = GolgiCell::new();
1195        let mut spikes_small = 0;
1196        for _ in 0..20_000 {
1197            spikes_small += n2.step(0.5);
1198        }
1199        assert!(
1200            spikes_small > 0,
1201            "Golgi cell should fire with minimal input (near-threshold), got {spikes_small}"
1202        );
1203    }
1204
1205    #[test]
1206    fn golgi_ahp_reduces_rate_at_high_drive() {
1207        // BK + SK provide AHP — removing them should increase sustained firing
1208        let mut with_ahp = GolgiCell::new();
1209        let mut no_ahp = GolgiCell::new();
1210        no_ahp.g_bk = 0.0;
1211        no_ahp.g_sk = 0.0;
1212        let mut spikes_with = 0;
1213        let mut spikes_no = 0;
1214        for _ in 0..10_000 {
1215            spikes_with += with_ahp.step(10.0);
1216            spikes_no += no_ahp.step(10.0);
1217        }
1218        assert!(
1219            spikes_no >= spikes_with,
1220            "AHP removal should increase firing: with={spikes_with}, without={spikes_no}"
1221        );
1222    }
1223
1224    #[test]
1225    fn golgi_ka_is_transient() {
1226        // K_A (A-type) is transient: activates fast, inactivates fast.
1227        // In full 11-current Golgi model, removing K_A changes firing pattern.
1228        let mut with_a = GolgiCell::new();
1229        let mut no_a = GolgiCell::new();
1230        no_a.g_ka = 0.0;
1231        let mut spikes_with = 0;
1232        let mut spikes_no = 0;
1233        for _ in 0..10_000 {
1234            spikes_with += with_a.step(5.0);
1235            spikes_no += no_a.step(5.0);
1236        }
1237        // Both configurations must fire (K_A doesn't prevent spiking)
1238        assert!(spikes_with > 0, "Must fire with K_A");
1239        // K_A modulates rate — the difference should be measurable
1240        assert!(
1241            spikes_with != spikes_no,
1242            "K_A should affect firing rate: with={spikes_with}, without={spikes_no}"
1243        );
1244    }
1245
1246    #[test]
1247    fn golgi_ca_accumulates_during_spiking() {
1248        let mut n = GolgiCell::new();
1249        let ca_init = n.ca;
1250        for _ in 0..5000 {
1251            n.step(10.0);
1252        }
1253        assert!(
1254            n.ca > ca_init,
1255            "Ca²⁺ must rise during spiking: init={ca_init}, now={}",
1256            n.ca
1257        );
1258    }
1259
1260    #[test]
1261    fn golgi_negative_input_no_crash() {
1262        let mut n = GolgiCell::new();
1263        for _ in 0..10_000 {
1264            n.step(-100.0);
1265        }
1266        assert!(n.v.is_finite(), "Must stay finite with negative input");
1267        assert!(n.v >= -100.0);
1268    }
1269
1270    #[test]
1271    fn golgi_nan_input_stays_finite() {
1272        let mut n = GolgiCell::new();
1273        n.step(f64::NAN);
1274        assert!(n.v.is_finite(), "NaN input must not corrupt state");
1275    }
1276
1277    #[test]
1278    fn golgi_extreme_input_bounded() {
1279        let mut n = GolgiCell::new();
1280        for _ in 0..1000 {
1281            n.step(1e6);
1282        }
1283        assert!(
1284            n.v.is_finite() && n.v <= 60.0,
1285            "Extreme input must stay bounded"
1286        );
1287    }
1288
1289    #[test]
1290    fn golgi_reset_clears_state() {
1291        let mut n = GolgiCell::new();
1292        for _ in 0..5000 {
1293            n.step(10.0);
1294        }
1295        n.reset();
1296        let fresh = GolgiCell::new();
1297        assert_eq!(n.v, fresh.v);
1298        assert_eq!(n.ca, fresh.ca);
1299        assert_eq!(n.m, fresh.m);
1300        assert_eq!(n.h, fresh.h);
1301        assert_eq!(n.p_na, fresh.p_na);
1302        assert_eq!(n.w, fresh.w);
1303        assert_eq!(n.r, fresh.r);
1304    }
1305
1306    #[test]
1307    fn golgi_gates_bounded() {
1308        let mut n = GolgiCell::new();
1309        for _ in 0..10_000 {
1310            n.step(15.0);
1311        }
1312        // All 11 gating variables must be in [0, 1]
1313        for (name, val) in [
1314            ("m", n.m),
1315            ("h", n.h),
1316            ("p_na", n.p_na),
1317            ("n", n.n),
1318            ("a", n.a),
1319            ("b", n.b),
1320            ("w", n.w),
1321            ("m_t", n.m_t),
1322            ("s", n.s),
1323            ("c_n", n.c_n),
1324            ("r", n.r),
1325        ] {
1326            assert!((0.0..=1.0).contains(&val), "{name} out of bounds: {val}");
1327        }
1328        assert!(n.ca >= 0.0, "Ca²⁺ must be non-negative: {}", n.ca);
1329    }
1330
1331    #[test]
1332    fn golgi_has_eleven_currents() {
1333        // Solinas 2007: Na_t, Na_p, K_dr, K_A, K_M, Ca_T, Ca_N, BK, SK, Ih, leak = 11
1334        let n = GolgiCell::new();
1335        assert!(n.g_na_t > 0.0, "Na_t missing");
1336        assert!(n.g_na_p > 0.0, "Na_p missing");
1337        assert!(n.g_kdr > 0.0, "K_dr missing");
1338        assert!(n.g_ka > 0.0, "K_A missing");
1339        assert!(n.g_km > 0.0, "K_M missing");
1340        assert!(n.g_cat > 0.0, "Ca_T missing");
1341        assert!(n.g_can > 0.0, "Ca_N missing");
1342        assert!(n.g_bk > 0.0, "BK missing");
1343        assert!(n.g_sk > 0.0, "SK missing");
1344        assert!(n.g_h > 0.0, "Ih missing");
1345        assert!(n.g_l > 0.0, "Leak missing");
1346    }
1347
1348    #[test]
1349    fn golgi_persistent_na_depolarises() {
1350        // Na_p contributes to pacemaking — removing it should reduce excitability
1351        let mut with_nap = GolgiCell::new();
1352        let mut no_nap = GolgiCell::new();
1353        no_nap.g_na_p = 0.0;
1354        let mut spikes_with = 0;
1355        let mut spikes_no = 0;
1356        for _ in 0..10_000 {
1357            spikes_with += with_nap.step(2.0);
1358            spikes_no += no_nap.step(2.0);
1359        }
1360        assert!(
1361            spikes_with >= spikes_no,
1362            "Na_p should increase excitability: with={spikes_with} vs without={spikes_no}"
1363        );
1364    }
1365
1366    #[test]
1367    fn golgi_km_slows_firing() {
1368        // K_M (muscarinic) is slow K+ that limits high-frequency firing
1369        let mut with_km = GolgiCell::new();
1370        let mut no_km = GolgiCell::new();
1371        no_km.g_km = 0.0;
1372        let mut spikes_with = 0;
1373        let mut spikes_no = 0;
1374        for _ in 0..10_000 {
1375            spikes_with += with_km.step(10.0);
1376            spikes_no += no_km.step(10.0);
1377        }
1378        assert!(
1379            spikes_no >= spikes_with,
1380            "K_M should reduce firing rate: with_km={spikes_with}, without={spikes_no}"
1381        );
1382    }
1383
1384    #[test]
1385    fn golgi_ih_sag() {
1386        // Ih activates on hyperpolarisation → sag towards resting
1387        let mut with_h = GolgiCell::new();
1388        let mut no_h = GolgiCell::new();
1389        no_h.g_h = 0.0;
1390        // Mild hyperpolarisation (g_h=0.1 is small, so don't drive to clamp)
1391        for _ in 0..10_000 {
1392            with_h.step(-1.0);
1393            no_h.step(-1.0);
1394        }
1395        // Ih should depolarise relative to no-Ih (sag)
1396        assert!(
1397            with_h.v > no_h.v,
1398            "Ih should cause sag (less hyperpolarised): with_h={:.1} vs no_h={:.1}",
1399            with_h.v,
1400            no_h.v
1401        );
1402    }
1403
1404    #[test]
1405    fn golgi_bk_fast_ahp() {
1406        // BK channels contribute to fast AHP — removing them should widen spikes
1407        let mut with_bk = GolgiCell::new();
1408        let mut no_bk = GolgiCell::new();
1409        no_bk.g_bk = 0.0;
1410        // Drive both to fire, measure voltage after spike
1411        let mut spikes_with = 0;
1412        let mut spikes_no = 0;
1413        for _ in 0..10_000 {
1414            spikes_with += with_bk.step(10.0);
1415            spikes_no += no_bk.step(10.0);
1416        }
1417        // Without BK, model should still fire (test stability)
1418        assert!(
1419            spikes_with > 0 && spikes_no > 0,
1420            "Both should fire: with_bk={spikes_with}, no_bk={spikes_no}"
1421        );
1422    }
1423
1424    #[test]
1425    fn golgi_sk_slow_adaptation() {
1426        // SK channels provide slow AHP → spike frequency adaptation
1427        let mut with_sk = GolgiCell::new();
1428        let mut no_sk = GolgiCell::new();
1429        no_sk.g_sk = 0.0;
1430        let mut spikes_with = 0;
1431        let mut spikes_no = 0;
1432        for _ in 0..20_000 {
1433            spikes_with += with_sk.step(8.0);
1434            spikes_no += no_sk.step(8.0);
1435        }
1436        assert!(
1437            spikes_no >= spikes_with,
1438            "SK removal should increase firing: with_sk={spikes_with}, no_sk={spikes_no}"
1439        );
1440    }
1441
1442    #[test]
1443    fn golgi_performance_1k_steps() {
1444        let start = std::time::Instant::now();
1445        let mut n = GolgiCell::new();
1446        for _ in 0..1_000 {
1447            std::hint::black_box(n.step(5.0));
1448        }
1449        let elapsed = start.elapsed();
1450        assert!(
1451            elapsed.as_millis() < 50,
1452            "1k steps must complete in <50ms, took {}ms",
1453            elapsed.as_millis()
1454        );
1455    }
1456
1457    // -- Stellate Cell tests --
1458
1459    #[test]
1460    fn stellate_fires_with_input() {
1461        let mut n = StellateCell::new();
1462        let mut spikes = 0;
1463        for _ in 0..2_000 {
1464            spikes += n.step(2.0);
1465        }
1466        assert!(
1467            spikes > 5,
1468            "Stellate cell must fire with input, got {spikes}"
1469        );
1470    }
1471
1472    #[test]
1473    fn stellate_silent_without_input() {
1474        let mut n = StellateCell::new();
1475        let mut spikes = 0;
1476        for _ in 0..10_000 {
1477            spikes += n.step(0.0);
1478        }
1479        assert_eq!(
1480            spikes, 0,
1481            "Stellate cell must be silent without input, got {spikes}"
1482        );
1483    }
1484
1485    #[test]
1486    fn stellate_high_frequency() {
1487        // Fast-spiking: should sustain high rates
1488        let mut n = StellateCell::new();
1489        let mut spikes = 0;
1490        for _ in 0..2_000 {
1491            spikes += n.step(20.0);
1492        }
1493        // 2000 steps * 0.5ms = 1000 ms; >100 spikes = >100 Hz
1494        assert!(
1495            spikes > 50,
1496            "FS stellate should fire at high rate, got {spikes}"
1497        );
1498    }
1499
1500    #[test]
1501    fn stellate_minimal_adaptation() {
1502        // Compare early vs late firing — should show little adaptation
1503        let mut n = StellateCell::new();
1504        let input = 10.0;
1505        let mut spikes_early = 0;
1506        for _ in 0..2000 {
1507            spikes_early += n.step(input);
1508        }
1509        let mut spikes_late = 0;
1510        for _ in 0..2000 {
1511            spikes_late += n.step(input);
1512        }
1513        // No AHP → minimal adaptation
1514        let diff = (spikes_early - spikes_late).abs();
1515        assert!(
1516            diff < 20,
1517            "FS should have minimal adaptation: early={spikes_early}, late={spikes_late}"
1518        );
1519    }
1520
1521    #[test]
1522    fn stellate_kv3_narrows_spikes() {
1523        // Kv3.1 should allow faster repolarisation → more spikes
1524        let mut with_kv3 = StellateCell::new();
1525        let mut no_kv3 = StellateCell::new();
1526        no_kv3.g_kv3 = 0.0;
1527
1528        let mut spikes_kv3 = 0;
1529        let mut spikes_no = 0;
1530        for _ in 0..2000 {
1531            spikes_kv3 += with_kv3.step(15.0);
1532            spikes_no += no_kv3.step(15.0);
1533        }
1534        // Kv3.1 should enable higher frequency (more spikes at same input)
1535        assert!(spikes_kv3 > 0, "With Kv3.1 must fire, got {spikes_kv3}");
1536        assert!(
1537            spikes_no >= 0,
1538            "No-Kv3.1 baseline must not panic, got {spikes_no}"
1539        );
1540    }
1541
1542    #[test]
1543    fn stellate_negative_input_no_crash() {
1544        let mut n = StellateCell::new();
1545        for _ in 0..10_000 {
1546            n.step(-100.0);
1547        }
1548        assert!(n.v.is_finite());
1549        assert!(n.v >= -100.0);
1550    }
1551
1552    #[test]
1553    fn stellate_nan_input_stays_finite() {
1554        let mut n = StellateCell::new();
1555        n.step(f64::NAN);
1556        assert!(n.v.is_finite());
1557    }
1558
1559    #[test]
1560    fn stellate_extreme_input_bounded() {
1561        let mut n = StellateCell::new();
1562        for _ in 0..1000 {
1563            n.step(1e6);
1564        }
1565        assert!(n.v.is_finite() && n.v <= 60.0);
1566    }
1567
1568    #[test]
1569    fn stellate_reset_clears_state() {
1570        let mut n = StellateCell::new();
1571        for _ in 0..1000 {
1572            n.step(20.0);
1573        }
1574        n.reset();
1575        assert_eq!(n.v, -65.0);
1576        assert_eq!(n.p, 0.0);
1577    }
1578
1579    #[test]
1580    fn stellate_gates_bounded() {
1581        let mut n = StellateCell::new();
1582        for _ in 0..10_000 {
1583            n.step(15.0);
1584        }
1585        assert!(n.h >= 0.0 && n.h <= 1.0);
1586        assert!(n.n >= 0.0 && n.n <= 1.0);
1587        assert!(n.p >= 0.0 && n.p <= 1.0);
1588    }
1589
1590    #[test]
1591    fn stellate_performance_1k_steps() {
1592        let start = std::time::Instant::now();
1593        let mut n = StellateCell::new();
1594        for _ in 0..1_000 {
1595            std::hint::black_box(n.step(10.0));
1596        }
1597        let elapsed = start.elapsed();
1598        assert!(
1599            elapsed.as_millis() < 200,
1600            "1k steps must complete in <200ms, took {}ms",
1601            elapsed.as_millis()
1602        );
1603    }
1604
1605    // -- Lugaro Cell tests --
1606
1607    #[test]
1608    fn lugaro_fires_with_input() {
1609        let mut n = LugaroCell::new();
1610        let mut spikes = 0;
1611        for _ in 0..10_000 {
1612            spikes += n.step(5.0);
1613        }
1614        assert!(
1615            spikes > 10,
1616            "Lugaro must fire with excitatory input, got {spikes}"
1617        );
1618    }
1619
1620    #[test]
1621    fn lugaro_low_threshold() {
1622        // Near-threshold rest → fires easily with moderate input
1623        let mut n = LugaroCell::new();
1624        let mut spikes = 0;
1625        for _ in 0..10_000 {
1626            spikes += n.step(4.0);
1627        }
1628        assert!(
1629            spikes > 10,
1630            "Lugaro should fire easily with moderate input, got {spikes}"
1631        );
1632    }
1633
1634    #[test]
1635    fn lugaro_adaptation() {
1636        let mut n = LugaroCell::new();
1637        let input = 10.0;
1638        let mut spikes_early = 0;
1639        for _ in 0..2000 {
1640            spikes_early += n.step(input);
1641        }
1642        let mut spikes_late = 0;
1643        for _ in 0..2000 {
1644            spikes_late += n.step(input);
1645        }
1646        assert!(
1647            spikes_early >= spikes_late,
1648            "Adaptation should slow firing: early={spikes_early}, late={spikes_late}"
1649        );
1650    }
1651
1652    #[test]
1653    fn lugaro_serotonin_increases_firing() {
1654        let mut no_5ht = LugaroCell::new();
1655        let mut with_5ht = LugaroCell::with_serotonin(1.0);
1656
1657        let input = 3.0;
1658        let mut spikes_no = 0;
1659        let mut spikes_5ht = 0;
1660        for _ in 0..10_000 {
1661            spikes_no += no_5ht.step(input);
1662            spikes_5ht += with_5ht.step(input);
1663        }
1664        assert!(
1665            spikes_5ht >= spikes_no,
1666            "5-HT must increase firing: 5HT={spikes_5ht} vs none={spikes_no}"
1667        );
1668    }
1669
1670    #[test]
1671    fn lugaro_negative_input_no_crash() {
1672        let mut n = LugaroCell::new();
1673        for _ in 0..10_000 {
1674            n.step(-100.0);
1675        }
1676        assert!(n.v.is_finite());
1677        assert!(n.v >= -100.0);
1678    }
1679
1680    #[test]
1681    fn lugaro_nan_input_stays_finite() {
1682        let mut n = LugaroCell::new();
1683        n.step(f64::NAN);
1684        assert!(n.v.is_finite());
1685    }
1686
1687    #[test]
1688    fn lugaro_extreme_input_bounded() {
1689        let mut n = LugaroCell::new();
1690        for _ in 0..1000 {
1691            n.step(1e6);
1692        }
1693        assert!(n.v.is_finite() && n.v <= 60.0);
1694    }
1695
1696    #[test]
1697    fn lugaro_reset_clears_state() {
1698        let mut n = LugaroCell::new();
1699        for _ in 0..1000 {
1700            n.step(10.0);
1701        }
1702        n.reset();
1703        assert_eq!(n.v, -55.0);
1704        assert_eq!(n.adapt, 0.0);
1705        assert_eq!(n.serotonin, 0.0);
1706    }
1707
1708    #[test]
1709    fn lugaro_adapt_increases_during_spiking() {
1710        let mut n = LugaroCell::new();
1711        let initial = n.adapt;
1712        for _ in 0..5000 {
1713            n.step(10.0);
1714        }
1715        assert!(
1716            n.adapt > initial,
1717            "Adaptation must increase during spiking, adapt={}",
1718            n.adapt
1719        );
1720    }
1721
1722    #[test]
1723    fn lugaro_performance_10k_steps() {
1724        let start = std::time::Instant::now();
1725        let mut n = LugaroCell::new();
1726        for _ in 0..10_000 {
1727            std::hint::black_box(n.step(5.0));
1728        }
1729        let elapsed = start.elapsed();
1730        assert!(
1731            elapsed.as_millis() < 50,
1732            "10k steps must complete in <50ms, took {}ms",
1733            elapsed.as_millis()
1734        );
1735    }
1736
1737    // -- Unipolar Brush Cell tests --
1738
1739    #[test]
1740    fn ubc_fires_with_input() {
1741        let mut n = UnipolarBrushCell::new();
1742        let mut spikes = 0;
1743        for _ in 0..10_000 {
1744            spikes += n.step(5.0);
1745        }
1746        assert!(
1747            spikes > 10,
1748            "UBC must fire with excitatory input, got {spikes}"
1749        );
1750    }
1751
1752    #[test]
1753    fn ubc_silent_without_input() {
1754        let mut n = UnipolarBrushCell::new();
1755        let mut spikes = 0;
1756        for _ in 0..10_000 {
1757            spikes += n.step(0.0);
1758        }
1759        assert_eq!(spikes, 0, "UBC must be silent without input");
1760    }
1761
1762    #[test]
1763    fn ubc_persistent_activity() {
1764        // After input stops, persistent current should sustain some depolarisation
1765        let mut n = UnipolarBrushCell::new();
1766        // Drive with input to build persistent current
1767        for _ in 0..2000 {
1768            n.step(10.0);
1769        }
1770        assert!(
1771            n.persistent > 0.0,
1772            "Persistent current must build during input"
1773        );
1774
1775        // Now remove input — persistent current should persist
1776        let persistent_before = n.persistent;
1777        for _ in 0..100 {
1778            n.step(0.0);
1779        }
1780        assert!(
1781            n.persistent > 0.0,
1782            "Persistent current must persist after input removal"
1783        );
1784        assert!(
1785            n.persistent < persistent_before,
1786            "Persistent current must decay"
1787        );
1788    }
1789
1790    #[test]
1791    fn ubc_persistent_spikes_after_input() {
1792        // UBC should continue firing briefly after input stops
1793        let mut n = UnipolarBrushCell::new();
1794        // Build up persistent current
1795        for _ in 0..5000 {
1796            n.step(10.0);
1797        }
1798        // Count spikes after input removal
1799        let post_spikes: i32 = (0..500).map(|_| n.step(0.0)).sum();
1800        // May or may not spike depending on persistent level — just test it doesn't crash
1801        assert!(post_spikes >= 0, "post_spikes must be non-negative");
1802        assert!(n.v.is_finite());
1803    }
1804
1805    #[test]
1806    fn ubc_negative_input_no_crash() {
1807        let mut n = UnipolarBrushCell::new();
1808        for _ in 0..10_000 {
1809            n.step(-100.0);
1810        }
1811        assert!(n.v.is_finite());
1812    }
1813
1814    #[test]
1815    fn ubc_nan_input_stays_finite() {
1816        let mut n = UnipolarBrushCell::new();
1817        n.step(f64::NAN);
1818        assert!(n.v.is_finite());
1819    }
1820
1821    #[test]
1822    fn ubc_extreme_input_bounded() {
1823        let mut n = UnipolarBrushCell::new();
1824        for _ in 0..1000 {
1825            n.step(1e6);
1826        }
1827        assert!(n.v.is_finite() && n.v <= 60.0);
1828    }
1829
1830    #[test]
1831    fn ubc_reset_clears_state() {
1832        let mut n = UnipolarBrushCell::new();
1833        for _ in 0..1000 {
1834            n.step(10.0);
1835        }
1836        n.reset();
1837        assert_eq!(n.v, -65.0);
1838        assert_eq!(n.persistent, 0.0);
1839    }
1840
1841    #[test]
1842    fn ubc_performance_10k_steps() {
1843        let start = std::time::Instant::now();
1844        let mut n = UnipolarBrushCell::new();
1845        for _ in 0..10_000 {
1846            std::hint::black_box(n.step(5.0));
1847        }
1848        let elapsed = start.elapsed();
1849        assert!(elapsed.as_millis() < 50, "10k steps must complete in <50ms");
1850    }
1851
1852    // -- DCN Neuron tests --
1853
1854    #[test]
1855    fn dcn_fires_with_input() {
1856        let mut n = DCNNeuron::new();
1857        let mut spikes = 0;
1858        for _ in 0..2_000 {
1859            spikes += n.step(5.0);
1860        }
1861        assert!(
1862            spikes > 3,
1863            "DCN must fire with excitatory input, got {spikes}"
1864        );
1865    }
1866
1867    #[test]
1868    fn dcn_spontaneous_activity() {
1869        // DCN neurons fire spontaneously (Llinás & Mühlethaler 1988)
1870        // INaP + Ih + depolarised leak drive autonomous firing
1871        let mut n = DCNNeuron::new();
1872        let mut spikes = 0;
1873        for _ in 0..20_000 {
1874            spikes += n.step(0.0);
1875        }
1876        // Should show some spontaneous activity (low rate)
1877        // Without INaP, should be reduced
1878        let mut no_nap = DCNNeuron::new();
1879        no_nap.g_nap = 0.0;
1880        let mut spikes_no = 0;
1881        for _ in 0..20_000 {
1882            spikes_no += no_nap.step(0.0);
1883        }
1884        assert!(
1885            spikes >= spikes_no,
1886            "INaP should contribute to spontaneous firing: with={spikes}, without={spikes_no}"
1887        );
1888    }
1889
1890    #[test]
1891    fn dcn_rebound_burst() {
1892        // Hyperpolarisation → T-type de-inactivation → rebound burst
1893        let mut n = DCNNeuron::new();
1894        // Hyperpolarise to de-inactivate T-type
1895        for _ in 0..2000 {
1896            n.step(-5.0);
1897        }
1898        assert!(
1899            n.s > 0.5,
1900            "T-type must de-inactivate during hyperpolarisation, s={}",
1901            n.s
1902        );
1903
1904        // Now provide excitation — T-type should help fire
1905        let mut spikes = 0;
1906        for _ in 0..200 {
1907            spikes += n.step(3.0);
1908        }
1909        // Compare with pre-inactivated T-type
1910        let mut n2 = DCNNeuron::new();
1911        n2.s = 0.05; // pre-inactivated
1912        let mut spikes2 = 0;
1913        for _ in 0..200 {
1914            spikes2 += n2.step(3.0);
1915        }
1916        assert!(
1917            spikes >= spikes2,
1918            "De-inactivated T-type should facilitate rebound: rebound={spikes} vs inact={spikes2}"
1919        );
1920    }
1921
1922    #[test]
1923    fn dcn_ih_depolarises() {
1924        // Ih should depolarise from hyperpolarised potentials
1925        let mut with_ih = DCNNeuron::new();
1926        with_ih.v = -80.0;
1927        let mut no_ih = DCNNeuron::new();
1928        no_ih.v = -80.0;
1929        no_ih.g_h = 0.0;
1930
1931        for _ in 0..1000 {
1932            with_ih.step(0.0);
1933            no_ih.step(0.0);
1934        }
1935        assert!(
1936            with_ih.v > no_ih.v,
1937            "Ih should depolarise from hyperpolarised state: Ih={:.1} vs no_Ih={:.1}",
1938            with_ih.v,
1939            no_ih.v
1940        );
1941    }
1942
1943    #[test]
1944    fn dcn_negative_input_no_crash() {
1945        let mut n = DCNNeuron::new();
1946        for _ in 0..10_000 {
1947            n.step(-100.0);
1948        }
1949        assert!(n.v.is_finite());
1950        assert!(n.v >= -100.0);
1951    }
1952
1953    #[test]
1954    fn dcn_nan_input_stays_finite() {
1955        let mut n = DCNNeuron::new();
1956        n.step(f64::NAN);
1957        assert!(n.v.is_finite());
1958    }
1959
1960    #[test]
1961    fn dcn_extreme_input_bounded() {
1962        let mut n = DCNNeuron::new();
1963        for _ in 0..1000 {
1964            n.step(1e6);
1965        }
1966        assert!(n.v.is_finite() && n.v <= 60.0);
1967    }
1968
1969    #[test]
1970    fn dcn_reset_clears_state() {
1971        let mut n = DCNNeuron::new();
1972        for _ in 0..1000 {
1973            n.step(10.0);
1974        }
1975        n.reset();
1976        assert_eq!(n.v, -60.0);
1977        assert_eq!(n.s, 0.8);
1978        assert_eq!(n.r, 0.1);
1979    }
1980
1981    #[test]
1982    fn dcn_gates_bounded() {
1983        let mut n = DCNNeuron::new();
1984        for _ in 0..10_000 {
1985            n.step(10.0);
1986        }
1987        for (name, val) in [("h", n.h), ("n", n.n), ("p", n.p), ("s", n.s), ("r", n.r)] {
1988            assert!((0.0..=1.0).contains(&val), "{name} out of bounds: {val}");
1989        }
1990        assert!(n.ca >= 0.0, "Ca²⁺ must be non-negative: {}", n.ca);
1991    }
1992
1993    #[test]
1994    fn dcn_nap_increases_excitability() {
1995        // INaP amplifies subthreshold depolarisation
1996        let mut with_nap = DCNNeuron::new();
1997        let mut no_nap = DCNNeuron::new();
1998        no_nap.g_nap = 0.0;
1999        let mut spikes_with = 0;
2000        let mut spikes_no = 0;
2001        for _ in 0..5_000 {
2002            spikes_with += with_nap.step(3.0);
2003            spikes_no += no_nap.step(3.0);
2004        }
2005        assert!(
2006            spikes_with >= spikes_no,
2007            "INaP should increase excitability: with={spikes_with}, without={spikes_no}"
2008        );
2009    }
2010
2011    #[test]
2012    fn dcn_ahp_limits_rate() {
2013        // Ca²⁺-AHP should reduce sustained firing rate
2014        let mut with_ahp = DCNNeuron::new();
2015        let mut no_ahp = DCNNeuron::new();
2016        no_ahp.g_ahp = 0.0;
2017        let mut spikes_with = 0;
2018        let mut spikes_no = 0;
2019        for _ in 0..5_000 {
2020            spikes_with += with_ahp.step(8.0);
2021            spikes_no += no_ahp.step(8.0);
2022        }
2023        assert!(
2024            spikes_no >= spikes_with,
2025            "AHP removal should increase firing: with={spikes_with}, without={spikes_no}"
2026        );
2027    }
2028
2029    #[test]
2030    fn dcn_ca_rises_during_spiking() {
2031        let mut n = DCNNeuron::new();
2032        let ca_init = n.ca;
2033        for _ in 0..5_000 {
2034            n.step(10.0);
2035        }
2036        assert!(
2037            n.ca > ca_init,
2038            "Ca²⁺ must rise during spiking: init={ca_init}, now={}",
2039            n.ca
2040        );
2041    }
2042
2043    #[test]
2044    fn dcn_has_seven_currents() {
2045        // Na_t, Na_p, K_dr, Ca_T, AHP, Ih, leak = 7
2046        let n = DCNNeuron::new();
2047        assert!(n.g_na > 0.0, "Na_t missing");
2048        assert!(n.g_nap > 0.0, "Na_p missing");
2049        assert!(n.g_k > 0.0, "K_dr missing");
2050        assert!(n.g_t > 0.0, "Ca_T missing");
2051        assert!(n.g_ahp > 0.0, "AHP missing");
2052        assert!(n.g_h > 0.0, "Ih missing");
2053        assert!(n.g_l > 0.0, "Leak missing");
2054    }
2055
2056    #[test]
2057    fn dcn_performance_1k_steps() {
2058        let start = std::time::Instant::now();
2059        let mut n = DCNNeuron::new();
2060        for _ in 0..1_000 {
2061            std::hint::black_box(n.step(5.0));
2062        }
2063        let elapsed = start.elapsed();
2064        assert!(
2065            elapsed.as_millis() < 200,
2066            "1k steps must complete in <200ms"
2067        );
2068    }
2069}