Skip to main content

sc_neurocore_engine/neurons/
interneurons.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 — Specific Interneuron Types
8
9//! Biophysically faithful interneuron models for cortical and cerebellar circuits.
10//!
11//! Six cell types covering the major inhibitory neuron classes:
12//! - PV+ fast-spiking (Wang-Buzsáki 1996 base + Kv3.1)
13//! - SST+ low-threshold spiking (Pospischil 2008 LTS variant)
14//! - VIP irregular spiking (accommodating, high Rin)
15//! - Chandelier axo-axonic (WB base + Kv1 delay + Kv3.1)
16//! - Basket cell cerebellar (Midtgaard 1992 kinetics)
17//! - Martinotti cell (adapting, ascending axon targeting L1)
18
19use super::biophysical::safe_rate;
20
21// ═══════════════════════════════════════════════════════════════════
22// PV+ Fast-Spiking Interneuron
23// ═══════════════════════════════════════════════════════════════════
24
25/// PV+ (parvalbumin) fast-spiking interneuron.
26///
27/// Biophysics: Wang-Buzsáki 1996 core (Na+, Kdr, leak) extended with
28/// Kv3.1 (fast-activating K+ for narrow APs and high-frequency firing).
29/// Key properties: narrow APs, high sustained firing (>200 Hz),
30/// no spike frequency adaptation, low input resistance.
31///
32/// Wang & Buzsáki 1996, J Neurosci 16:6402-6413 + Kv3.1 extension.
33#[derive(Clone, Debug)]
34pub struct PVFastSpikingNeuron {
35    pub v: f64,
36    pub h: f64,
37    pub n: f64,
38    pub p: f64, // Kv3.1 activation
39    // Conductances (mS/cm²)
40    pub g_na: f64,
41    pub g_k: f64,
42    pub g_kv3: f64,
43    pub g_l: f64,
44    // Reversal potentials (mV)
45    pub e_na: f64,
46    pub e_k: f64,
47    pub e_l: f64,
48    pub c_m: f64,
49    pub phi: f64,
50    pub dt: f64,
51    pub v_threshold: f64,
52}
53
54impl PVFastSpikingNeuron {
55    pub fn new() -> Self {
56        Self {
57            v: -65.0,
58            h: 0.8,
59            n: 0.1,
60            p: 0.0,
61            g_na: 35.0,
62            g_k: 9.0,
63            g_kv3: 5.0, // Kv3.1 for narrow APs
64            g_l: 0.1,
65            e_na: 55.0,
66            e_k: -90.0,
67            e_l: -65.0,
68            c_m: 1.0,
69            phi: 5.0, // Fast kinetics (FS phenotype)
70            dt: 0.01,
71            v_threshold: -20.0,
72        }
73    }
74
75    /// Return `[dV, dh, dn, dp]` of the four-state Wang-Buzsáki + Kv3.1 system at
76    /// one consistent state.
77    fn derivatives(&self, v: f64, h: f64, n: f64, p: f64, current: f64) -> [f64; 4] {
78        let am = safe_rate(0.1, 35.0, v, 10.0, 1.0);
79        let bm = 4.0 * (-(v + 60.0) / 18.0).exp();
80        let m_inf = am / (am + bm);
81        let ah = 0.07 * (-(v + 58.0) / 20.0).exp();
82        let bh = 1.0 / (1.0 + (-(v + 28.0) / 10.0).exp());
83        let an = safe_rate(0.01, 34.0, v, 10.0, 0.1);
84        let bn = 0.125 * (-(v + 44.0) / 80.0).exp();
85        let p_inf = 1.0 / (1.0 + (-(v + 10.0) / 10.0).exp());
86        let dh = self.phi * (ah * (1.0 - h) - bh * h);
87        let dn = self.phi * (an * (1.0 - n) - bn * n);
88        let dp = self.phi * (p_inf - p);
89        let i_na = self.g_na * m_inf * m_inf * m_inf * h * (v - self.e_na);
90        let i_k = self.g_k * n * n * n * n * (v - self.e_k);
91        let i_kv3 = self.g_kv3 * p * (v - self.e_k);
92        let i_l = self.g_l * (v - self.e_l);
93        let dv = (-i_na - i_k - i_kv3 - i_l + current) / self.c_m;
94        [dv, dh, dn, dp]
95    }
96
97    /// Return one classical RK4 increment of `[V, h, n, p]`, holding `current`
98    /// constant across the four stages.
99    fn rk4_substep(&self, s: [f64; 4], current: f64) -> [f64; 4] {
100        let dt = self.dt;
101        let k1 = self.derivatives(s[0], s[1], s[2], s[3], current);
102        let k2 = self.derivatives(
103            s[0] + 0.5 * dt * k1[0],
104            s[1] + 0.5 * dt * k1[1],
105            s[2] + 0.5 * dt * k1[2],
106            s[3] + 0.5 * dt * k1[3],
107            current,
108        );
109        let k3 = self.derivatives(
110            s[0] + 0.5 * dt * k2[0],
111            s[1] + 0.5 * dt * k2[1],
112            s[2] + 0.5 * dt * k2[2],
113            s[3] + 0.5 * dt * k2[3],
114            current,
115        );
116        let k4 = self.derivatives(
117            s[0] + dt * k3[0],
118            s[1] + dt * k3[1],
119            s[2] + dt * k3[2],
120            s[3] + dt * k3[3],
121            current,
122        );
123        let mut out = [0.0_f64; 4];
124        for i in 0..4 {
125            out[i] = s[i] + dt * (k1[i] + 2.0 * k2[i] + 2.0 * k3[i] + k4[i]) / 6.0;
126        }
127        out
128    }
129
130    pub fn step(&mut self, current: f64) -> i32 {
131        let v_prev = self.v;
132        let n_sub = (0.5 / self.dt.max(0.001)) as usize;
133        let mut s = [self.v, self.h, self.n, self.p];
134        for _ in 0..n_sub {
135            s = self.rk4_substep(s, current);
136        }
137        self.v = s[0];
138        self.h = s[1];
139        self.n = s[2];
140        self.p = s[3];
141        if self.v >= self.v_threshold && v_prev < self.v_threshold {
142            1
143        } else {
144            0
145        }
146    }
147
148    pub fn reset(&mut self) {
149        self.v = -65.0;
150        self.h = 0.8;
151        self.n = 0.1;
152        self.p = 0.0;
153    }
154}
155
156impl Default for PVFastSpikingNeuron {
157    fn default() -> Self {
158        Self::new()
159    }
160}
161
162// ═══════════════════════════════════════════════════════════════════
163// SST+ Low-Threshold Spiking Interneuron
164// ═══════════════════════════════════════════════════════════════════
165
166/// SST+ (somatostatin) low-threshold spiking interneuron.
167///
168/// Biophysics: Na+, K+, M-current (Kv7, slow K+ for adaptation),
169/// T-type Ca2+ (low-threshold burst), h-current (Ih, sag), leak.
170/// Key properties: spike frequency adaptation, rebound bursting,
171/// facilitating synapses, dendritic targeting.
172///
173/// Based on Pospischil et al. 2008 LTS parameterisation.
174#[derive(Clone, Debug)]
175pub struct SSTNeuron {
176    pub v: f64,
177    pub m: f64,
178    pub h: f64,
179    pub n: f64,
180    pub p: f64, // M-current activation
181    pub s: f64, // T-type Ca2+ inactivation
182    pub r: f64, // h-current activation
183    // Conductances
184    pub g_na: f64,
185    pub g_k: f64,
186    pub g_m: f64,
187    pub g_t: f64,
188    pub g_h: f64,
189    pub g_l: f64,
190    // Reversal potentials
191    pub e_na: f64,
192    pub e_k: f64,
193    pub e_ca: f64,
194    pub e_h: f64,
195    pub e_l: f64,
196    pub c_m: f64,
197    pub dt: f64,
198    pub v_threshold: f64,
199}
200
201impl SSTNeuron {
202    pub fn new() -> Self {
203        Self {
204            v: -65.0,
205            m: 0.02,
206            h: 0.8,
207            n: 0.2,
208            p: 0.0,
209            s: 0.9,
210            r: 0.1,
211            g_na: 50.0,
212            g_k: 5.0,
213            g_m: 0.12, // Strong M-current → adaptation
214            g_t: 0.01, // T-type Ca2+ for rebound (minimal window current)
215            g_h: 0.02, // Ih for sag
216            g_l: 0.05, // Leak for resting stability
217            e_na: 50.0,
218            e_k: -90.0,
219            e_ca: 120.0,
220            e_h: -40.0,
221            e_l: -65.0,
222            c_m: 1.0,
223            dt: 0.025,
224            v_threshold: -20.0,
225        }
226    }
227
228    /// Return `[dV, dm, dh, dn, dp, ds, dr]` of the seven-state SST system at one
229    /// consistent state. The Na/K activation rates use the L'Hôpital limit at the
230    /// removable Traub-Miles singularity; β_m carries the published `V - V_T - 40`
231    /// offset (an earlier `-17` offset drove the cell into depolarisation block).
232    fn derivatives(
233        &self,
234        v: f64,
235        m: f64,
236        h: f64,
237        n: f64,
238        p: f64,
239        s: f64,
240        r: f64,
241        current: f64,
242    ) -> [f64; 7] {
243        let dvt = v - (-56.2);
244        let asing = |num: f64, slope: f64, limit: f64| {
245            if num.abs() < 1e-6 {
246                limit
247            } else {
248                num / ((num / slope).exp() - 1.0)
249            }
250        };
251        let alpha_m = -0.32 * asing(dvt - 13.0, -4.0, -4.0);
252        let beta_m = 0.28 * asing(dvt - 40.0, 5.0, 5.0);
253        let alpha_h = 0.128 * (-(dvt - 17.0) / 18.0).exp();
254        let beta_h = 4.0 / (1.0 + (-(dvt - 40.0) / 5.0).exp());
255        let alpha_n = -0.032 * asing(dvt - 15.0, -5.0, -5.0);
256        let beta_n = 0.5 * (-(dvt - 10.0) / 40.0).exp();
257        let dm = alpha_m * (1.0 - m) - beta_m * m;
258        let dh = alpha_h * (1.0 - h) - beta_h * h;
259        let dn = alpha_n * (1.0 - n) - beta_n * n;
260        let p_inf = 1.0 / (1.0 + (-(v + 35.0) / 10.0).exp());
261        let tau_p = 400.0 / (3.3 * ((v + 35.0) / 20.0).exp() + (-(v + 35.0) / 20.0).exp());
262        let dp = (p_inf - p) / tau_p;
263        let m_t_inf = 1.0 / (1.0 + (-(v + 57.0) / 6.2).exp());
264        let s_inf = 1.0 / (1.0 + ((v + 81.0) / 4.0).exp());
265        let tau_s = 30.0 + 200.0 / (1.0 + ((v + 70.0) / 5.0).exp());
266        let ds = (s_inf - s) / tau_s;
267        let r_inf = 1.0 / (1.0 + ((v + 80.0) / 10.0).exp());
268        let tau_r = 100.0 + 500.0 / ((-(v + 70.0) / 20.0).exp() + ((v + 70.0) / 20.0).exp());
269        let dr = (r_inf - r) / tau_r;
270        let i_na = self.g_na * m * m * m * h * (v - self.e_na);
271        let i_k = self.g_k * n * n * n * n * (v - self.e_k);
272        let i_m = self.g_m * p * (v - self.e_k);
273        let i_t = self.g_t * m_t_inf * m_t_inf * s * (v - self.e_ca);
274        let i_h = self.g_h * r * (v - self.e_h);
275        let i_l = self.g_l * (v - self.e_l);
276        let dvdt = (-i_na - i_k - i_m - i_t - i_h - i_l + current) / self.c_m;
277        [dvdt, dm, dh, dn, dp, ds, dr]
278    }
279
280    /// Return one classical RK4 increment of `[V, m, h, n, p, s, r]`, holding
281    /// `current` constant across the four stages.
282    fn rk4_substep(&self, st: [f64; 7], current: f64) -> [f64; 7] {
283        let dt = self.dt;
284        let k1 = self.derivatives(st[0], st[1], st[2], st[3], st[4], st[5], st[6], current);
285        let mut a = [0.0_f64; 7];
286        for i in 0..7 {
287            a[i] = st[i] + 0.5 * dt * k1[i];
288        }
289        let k2 = self.derivatives(a[0], a[1], a[2], a[3], a[4], a[5], a[6], current);
290        for i in 0..7 {
291            a[i] = st[i] + 0.5 * dt * k2[i];
292        }
293        let k3 = self.derivatives(a[0], a[1], a[2], a[3], a[4], a[5], a[6], current);
294        for i in 0..7 {
295            a[i] = st[i] + dt * k3[i];
296        }
297        let k4 = self.derivatives(a[0], a[1], a[2], a[3], a[4], a[5], a[6], current);
298        let mut out = [0.0_f64; 7];
299        for i in 0..7 {
300            out[i] = st[i] + dt * (k1[i] + 2.0 * k2[i] + 2.0 * k3[i] + k4[i]) / 6.0;
301        }
302        out
303    }
304
305    pub fn step(&mut self, current: f64) -> i32 {
306        let v_prev = self.v;
307        let mut st = [self.v, self.m, self.h, self.n, self.p, self.s, self.r];
308        for _ in 0..4 {
309            st = self.rk4_substep(st, current);
310        }
311        self.v = st[0];
312        self.m = st[1];
313        self.h = st[2];
314        self.n = st[3];
315        self.p = st[4];
316        self.s = st[5];
317        self.r = st[6];
318        if self.v >= self.v_threshold && v_prev < self.v_threshold {
319            1
320        } else {
321            0
322        }
323    }
324
325    pub fn reset(&mut self) {
326        self.v = -65.0;
327        self.m = 0.02;
328        self.h = 0.8;
329        self.n = 0.2;
330        self.p = 0.0;
331        self.s = 0.9;
332        self.r = 0.1;
333    }
334}
335
336impl Default for SSTNeuron {
337    fn default() -> Self {
338        Self::new()
339    }
340}
341
342// ═══════════════════════════════════════════════════════════════════
343// VIP Irregular-Spiking Interneuron
344// ═══════════════════════════════════════════════════════════════════
345
346/// VIP (vasoactive intestinal peptide) irregular-spiking interneuron.
347///
348/// Biophysics: Na+, K+, A-type K+ (Kv4, transient outward, causes
349/// accommodation), leak. High input resistance, small soma.
350/// Key properties: irregular/accommodating firing, disinhibitory
351/// role (inhibits SST+ and PV+), bipolar morphology.
352///
353/// Based on Porter et al. 1998 / Bhatt et al. 2019 parameterisation.
354#[derive(Clone, Debug)]
355pub struct VIPNeuron {
356    pub v: f64,
357    pub h: f64,
358    pub n: f64,
359    pub a: f64, // A-type K+ activation
360    pub b: f64, // A-type K+ inactivation
361    // Conductances
362    pub g_na: f64,
363    pub g_k: f64,
364    pub g_a: f64,
365    pub g_l: f64,
366    // Reversal potentials
367    pub e_na: f64,
368    pub e_k: f64,
369    pub e_l: f64,
370    pub c_m: f64,
371    pub dt: f64,
372    pub v_threshold: f64,
373}
374
375impl VIPNeuron {
376    pub fn new() -> Self {
377        Self {
378            v: -65.0,
379            h: 0.8,
380            n: 0.1,
381            a: 0.0,
382            b: 0.9,
383            g_na: 35.0, // Lower than PV+ (smaller soma)
384            g_k: 6.0,
385            g_a: 8.0,  // Strong A-current → accommodation
386            g_l: 0.01, // High input resistance
387            e_na: 55.0,
388            e_k: -90.0,
389            e_l: -65.0,
390            c_m: 0.5, // Small soma → low capacitance
391            dt: 0.025,
392            v_threshold: -20.0,
393        }
394    }
395
396    /// Return `[dV, dh, dn, da, db]` of the five-state VIP system at one consistent
397    /// state. All gates relax through sigmoidal steady states (no singularities).
398    fn derivatives(&self, v: f64, h: f64, n: f64, a: f64, b: f64, current: f64) -> [f64; 5] {
399        let m_inf = 1.0 / (1.0 + (-(v + 30.0) / 9.5).exp());
400        let h_inf = 1.0 / (1.0 + ((v + 53.0) / 7.0).exp());
401        let tau_h = 0.37 + 2.78 / (1.0 + ((v + 40.5) / 6.0).exp());
402        let n_inf = 1.0 / (1.0 + (-(v + 30.0) / 10.0).exp());
403        let tau_n = 0.37 + 1.85 / (1.0 + ((v + 27.0) / 15.0).exp());
404        let a_inf = 1.0 / (1.0 + (-(v + 50.0) / 20.0).exp());
405        let b_inf = 1.0 / (1.0 + ((v + 78.0) / 6.0).exp());
406        let dh = (h_inf - h) / tau_h;
407        let dn = (n_inf - n) / tau_n;
408        let da = (a_inf - a) / 5.0;
409        let db = (b_inf - b) / 50.0;
410        let i_na = self.g_na * m_inf * m_inf * m_inf * h * (v - self.e_na);
411        let i_k = self.g_k * n * n * n * n * (v - self.e_k);
412        let i_a = self.g_a * a * a * a * b * (v - self.e_k);
413        let i_l = self.g_l * (v - self.e_l);
414        let dv = (-i_na - i_k - i_a - i_l + current) / self.c_m;
415        [dv, dh, dn, da, db]
416    }
417
418    /// Return one classical RK4 increment of `[V, h, n, a, b]`, holding `current`
419    /// constant across the four stages.
420    fn rk4_substep(&self, s: [f64; 5], current: f64) -> [f64; 5] {
421        let dt = self.dt;
422        let k1 = self.derivatives(s[0], s[1], s[2], s[3], s[4], current);
423        let k2 = self.derivatives(
424            s[0] + 0.5 * dt * k1[0],
425            s[1] + 0.5 * dt * k1[1],
426            s[2] + 0.5 * dt * k1[2],
427            s[3] + 0.5 * dt * k1[3],
428            s[4] + 0.5 * dt * k1[4],
429            current,
430        );
431        let k3 = self.derivatives(
432            s[0] + 0.5 * dt * k2[0],
433            s[1] + 0.5 * dt * k2[1],
434            s[2] + 0.5 * dt * k2[2],
435            s[3] + 0.5 * dt * k2[3],
436            s[4] + 0.5 * dt * k2[4],
437            current,
438        );
439        let k4 = self.derivatives(
440            s[0] + dt * k3[0],
441            s[1] + dt * k3[1],
442            s[2] + dt * k3[2],
443            s[3] + dt * k3[3],
444            s[4] + dt * k3[4],
445            current,
446        );
447        let mut out = [0.0_f64; 5];
448        for i in 0..5 {
449            out[i] = s[i] + dt * (k1[i] + 2.0 * k2[i] + 2.0 * k3[i] + k4[i]) / 6.0;
450        }
451        out
452    }
453
454    pub fn step(&mut self, current: f64) -> i32 {
455        let v_prev = self.v;
456        let mut s = [self.v, self.h, self.n, self.a, self.b];
457        for _ in 0..4 {
458            s = self.rk4_substep(s, current);
459        }
460        self.v = s[0];
461        self.h = s[1];
462        self.n = s[2];
463        self.a = s[3];
464        self.b = s[4];
465        if self.v >= self.v_threshold && v_prev < self.v_threshold {
466            1
467        } else {
468            0
469        }
470    }
471
472    pub fn reset(&mut self) {
473        self.v = -65.0;
474        self.h = 0.8;
475        self.n = 0.1;
476        self.a = 0.0;
477        self.b = 0.9;
478    }
479}
480
481impl Default for VIPNeuron {
482    fn default() -> Self {
483        Self::new()
484    }
485}
486
487// ═══════════════════════════════════════════════════════════════════
488// Chandelier Cell (Axo-Axonic)
489// ═══════════════════════════════════════════════════════════════════
490
491/// Chandelier cell — axo-axonic fast-spiking interneuron.
492///
493/// Biophysics: Wang-Buzsáki core + Kv1 (D-type delay current) + Kv3.1.
494/// Kv1 creates a delay to first spike compared to PV+. Targets AIS.
495///
496/// Based on Woodruff et al. 2011 / Wang & Buzsáki 1996.
497#[derive(Clone, Debug)]
498pub struct ChandelierNeuron {
499    pub v: f64,
500    pub h: f64,
501    pub n: f64,
502    pub d: f64, // Kv1 (D-type) activation
503    pub p: f64, // Kv3.1 activation
504    // Conductances
505    pub g_na: f64,
506    pub g_k: f64,
507    pub g_kv1: f64,
508    pub g_kv3: f64,
509    pub g_l: f64,
510    // Reversal potentials
511    pub e_na: f64,
512    pub e_k: f64,
513    pub e_l: f64,
514    pub c_m: f64,
515    pub phi: f64,
516    pub dt: f64,
517    pub v_threshold: f64,
518}
519
520impl ChandelierNeuron {
521    pub fn new() -> Self {
522        Self {
523            v: -65.0,
524            h: 0.8,
525            n: 0.1,
526            d: 0.0,
527            p: 0.0,
528            g_na: 35.0,
529            g_k: 9.0,
530            g_kv1: 3.0, // Kv1 delay current (slower)
531            g_kv3: 4.0, // Kv3.1 for AP sharpening
532            g_l: 0.1,
533            e_na: 55.0,
534            e_k: -90.0,
535            e_l: -65.0,
536            c_m: 1.0,
537            phi: 5.0,
538            dt: 0.01,
539            v_threshold: -20.0,
540        }
541    }
542
543    pub fn step(&mut self, current: f64) -> i32 {
544        let v_prev = self.v;
545        let n_sub = (0.5 / self.dt.max(0.001)) as usize;
546        for _ in 0..n_sub {
547            // Wang-Buzsáki gating
548            let am = safe_rate(0.1, 35.0, self.v, 10.0, 1.0);
549            let bm = 4.0 * (-(self.v + 60.0) / 18.0).exp();
550            let m_inf = am / (am + bm);
551            let ah = 0.07 * (-(self.v + 58.0) / 20.0).exp();
552            let bh = 1.0 / (1.0 + (-(self.v + 28.0) / 10.0).exp());
553            let an = safe_rate(0.01, 34.0, self.v, 10.0, 0.1);
554            let bn = 0.125 * (-(self.v + 44.0) / 80.0).exp();
555
556            self.h += self.phi * (ah * (1.0 - self.h) - bh * self.h) * self.dt;
557            self.n += self.phi * (an * (1.0 - self.n) - bn * self.n) * self.dt;
558
559            // Kv1 (D-type): slow activation → first-spike delay
560            let d_inf = 1.0 / (1.0 + (-(self.v + 50.0) / 10.0).exp());
561            let tau_d = 150.0;
562            self.d += (d_inf - self.d) / tau_d * self.dt;
563
564            // Kv3.1: fast activation
565            let p_inf = 1.0 / (1.0 + (-(self.v + 10.0) / 10.0).exp());
566            self.p += self.phi * (p_inf - self.p) / 1.0 * self.dt;
567
568            let i_na = self.g_na * m_inf.powi(3) * self.h * (self.v - self.e_na);
569            let i_k = self.g_k * self.n.powi(4) * (self.v - self.e_k);
570            let i_kv1 = self.g_kv1 * self.d.powi(4) * (self.v - self.e_k);
571            let i_kv3 = self.g_kv3 * self.p * (self.v - self.e_k);
572            let i_l = self.g_l * (self.v - self.e_l);
573
574            self.v += (-i_na - i_k - i_kv1 - i_kv3 - i_l + current) / self.c_m * self.dt;
575        }
576        if self.v >= self.v_threshold && v_prev < self.v_threshold {
577            1
578        } else {
579            0
580        }
581    }
582
583    pub fn reset(&mut self) {
584        self.v = -65.0;
585        self.h = 0.8;
586        self.n = 0.1;
587        self.d = 0.0;
588        self.p = 0.0;
589    }
590}
591
592impl Default for ChandelierNeuron {
593    fn default() -> Self {
594        Self::new()
595    }
596}
597
598// ═══════════════════════════════════════════════════════════════════
599// Cerebellar Basket Cell
600// ═══════════════════════════════════════════════════════════════════
601
602/// Cerebellar basket cell — perisomatic-targeting interneuron.
603///
604/// Biophysics: Wang-Buzsáki core + A-type K+ (transient outward) +
605/// Ca2+-dependent K+ (afterhyperpolarisation). Distinct from cortical
606/// PV+ by A-current and pronounced AHP from Ca2+-activated K+.
607///
608/// Based on Midtgaard 1992 / Häusser & Clark 1997 / WB 1996.
609#[derive(Clone, Debug)]
610pub struct CerebellarBasketNeuron {
611    pub v: f64,
612    pub h: f64,
613    pub n: f64,
614    pub a: f64,
615    pub b: f64,
616    pub ca: f64, // Intracellular [Ca2+] (µM)
617    // Conductances
618    pub g_na: f64,
619    pub g_k: f64,
620    pub g_a: f64,
621    pub g_kca: f64,
622    pub g_l: f64,
623    // Reversal potentials
624    pub e_na: f64,
625    pub e_k: f64,
626    pub e_l: f64,
627    pub c_m: f64,
628    pub phi: f64,
629    pub dt: f64,
630    pub v_threshold: f64,
631}
632
633impl CerebellarBasketNeuron {
634    pub fn new() -> Self {
635        Self {
636            v: -65.0,
637            h: 0.8,
638            n: 0.1,
639            a: 0.0,
640            b: 0.9,
641            ca: 0.05,
642            g_na: 35.0,
643            g_k: 9.0,
644            g_a: 3.0,
645            g_kca: 2.0,
646            g_l: 0.1,
647            e_na: 55.0,
648            e_k: -90.0,
649            e_l: -65.0,
650            c_m: 1.0,
651            phi: 5.0,
652            dt: 0.01,
653            v_threshold: -20.0,
654        }
655    }
656
657    pub fn step(&mut self, current: f64) -> i32 {
658        let v_prev = self.v;
659        let n_sub = (0.5 / self.dt.max(0.001)) as usize;
660        for _ in 0..n_sub {
661            // WB gating for Na+ and Kdr
662            let am = safe_rate(0.1, 35.0, self.v, 10.0, 1.0);
663            let bm = 4.0 * (-(self.v + 60.0) / 18.0).exp();
664            let m_inf = am / (am + bm);
665            let ah = 0.07 * (-(self.v + 58.0) / 20.0).exp();
666            let bh = 1.0 / (1.0 + (-(self.v + 28.0) / 10.0).exp());
667            let an = safe_rate(0.01, 34.0, self.v, 10.0, 0.1);
668            let bn = 0.125 * (-(self.v + 44.0) / 80.0).exp();
669
670            self.h += self.phi * (ah * (1.0 - self.h) - bh * self.h) * self.dt;
671            self.n += self.phi * (an * (1.0 - self.n) - bn * self.n) * self.dt;
672
673            // A-type K+ (cerebellar)
674            let a_inf = 1.0 / (1.0 + (-(self.v + 45.0) / 15.0).exp());
675            let b_inf = 1.0 / (1.0 + ((self.v + 75.0) / 8.0).exp());
676            self.a += self.phi * (a_inf - self.a) / 5.0 * self.dt;
677            self.b += (b_inf - self.b) / 50.0 * self.dt;
678
679            // Ca2+-activated K+ (AHP)
680            let q_inf = self.ca / (self.ca + 0.2);
681
682            // Ca2+ dynamics: entry during depolarisation
683            let i_ca_entry = if self.v > -20.0 {
684                0.01 * (self.v + 20.0)
685            } else {
686                0.0
687            };
688            self.ca += (-self.ca / 80.0 + i_ca_entry) * self.dt;
689            if self.ca < 0.0 {
690                self.ca = 0.0;
691            }
692
693            let i_na = self.g_na * m_inf.powi(3) * self.h * (self.v - self.e_na);
694            let i_k = self.g_k * self.n.powi(4) * (self.v - self.e_k);
695            let i_a = self.g_a * self.a.powi(3) * self.b * (self.v - self.e_k);
696            let i_kca = self.g_kca * q_inf * (self.v - self.e_k);
697            let i_l = self.g_l * (self.v - self.e_l);
698
699            self.v += (-i_na - i_k - i_a - i_kca - i_l + current) / self.c_m * self.dt;
700        }
701        if self.v >= self.v_threshold && v_prev < self.v_threshold {
702            1
703        } else {
704            0
705        }
706    }
707
708    pub fn reset(&mut self) {
709        self.v = -65.0;
710        self.h = 0.8;
711        self.n = 0.1;
712        self.a = 0.0;
713        self.b = 0.9;
714        self.ca = 0.05;
715    }
716}
717
718impl Default for CerebellarBasketNeuron {
719    fn default() -> Self {
720        Self::new()
721    }
722}
723
724// ═══════════════════════════════════════════════════════════════════
725// Martinotti Cell
726// ═══════════════════════════════════════════════════════════════════
727
728/// Martinotti cell — adapting interneuron targeting layer 1 apical dendrites.
729///
730/// Biophysics: Na+, K+, M-current (Kv7, strong adaptation), T-type Ca2+
731/// (rebound), leak. Overlaps with SST+ phenotype but with stronger
732/// adaptation (higher g_m) and lower rheobase.
733///
734/// Based on Silberberg & Markram 2007 / Toledo-Rodriguez et al. 2005.
735#[derive(Clone, Debug)]
736pub struct MartinottiNeuron {
737    pub v: f64,
738    pub m: f64,
739    pub h: f64,
740    pub n: f64,
741    pub p: f64, // M-current activation
742    pub s: f64, // T-type Ca2+ inactivation
743    // Conductances
744    pub g_na: f64,
745    pub g_k: f64,
746    pub g_m: f64,
747    pub g_t: f64,
748    pub g_l: f64,
749    // Reversal potentials
750    pub e_na: f64,
751    pub e_k: f64,
752    pub e_ca: f64,
753    pub e_l: f64,
754    pub c_m: f64,
755    pub dt: f64,
756    pub v_threshold: f64,
757}
758
759impl MartinottiNeuron {
760    pub fn new() -> Self {
761        Self {
762            v: -65.0,
763            m: 0.02,
764            h: 0.8,
765            n: 0.2,
766            p: 0.0,
767            s: 0.9,
768            g_na: 40.0,
769            g_k: 5.0,
770            g_m: 0.25, // Very strong M-current → pronounced adaptation
771            g_t: 0.01, // T-type Ca2+ (minimal window current)
772            g_l: 0.05, // Leak for resting stability
773            e_na: 50.0,
774            e_k: -90.0,
775            e_ca: 120.0,
776            e_l: -65.0,
777            c_m: 0.8,
778            dt: 0.025,
779            v_threshold: -20.0,
780        }
781    }
782
783    /// Return `[dV, dm, dh, dn, dp, ds]` of the six-state Martinotti system at one
784    /// consistent state. The Na/K activation rates use the L'Hôpital limit at the
785    /// removable Traub-Miles singularity; β_m carries the published `V - V_T - 40`
786    /// offset (an earlier `-17` offset drove the cell into depolarisation block).
787    fn derivatives(
788        &self,
789        v: f64,
790        m: f64,
791        h: f64,
792        n: f64,
793        p: f64,
794        s: f64,
795        current: f64,
796    ) -> [f64; 6] {
797        let dvt = v - (-56.2);
798        let asing = |num: f64, slope: f64, limit: f64| {
799            if num.abs() < 1e-6 {
800                limit
801            } else {
802                num / ((num / slope).exp() - 1.0)
803            }
804        };
805        let alpha_m = -0.32 * asing(dvt - 13.0, -4.0, -4.0);
806        let beta_m = 0.28 * asing(dvt - 40.0, 5.0, 5.0);
807        let alpha_h = 0.128 * (-(dvt - 17.0) / 18.0).exp();
808        let beta_h = 4.0 / (1.0 + (-(dvt - 40.0) / 5.0).exp());
809        let alpha_n = -0.032 * asing(dvt - 15.0, -5.0, -5.0);
810        let beta_n = 0.5 * (-(dvt - 10.0) / 40.0).exp();
811        let dm = alpha_m * (1.0 - m) - beta_m * m;
812        let dh = alpha_h * (1.0 - h) - beta_h * h;
813        let dn = alpha_n * (1.0 - n) - beta_n * n;
814        let p_inf = 1.0 / (1.0 + (-(v + 35.0) / 10.0).exp());
815        let tau_p = 400.0 / (3.3 * ((v + 35.0) / 20.0).exp() + (-(v + 35.0) / 20.0).exp());
816        let dp = (p_inf - p) / tau_p;
817        let m_t_inf = 1.0 / (1.0 + (-(v + 57.0) / 6.2).exp());
818        let s_inf = 1.0 / (1.0 + ((v + 81.0) / 4.0).exp());
819        let tau_s = 30.0 + 200.0 / (1.0 + ((v + 70.0) / 5.0).exp());
820        let ds = (s_inf - s) / tau_s;
821        let i_na = self.g_na * m * m * m * h * (v - self.e_na);
822        let i_k = self.g_k * n * n * n * n * (v - self.e_k);
823        let i_m = self.g_m * p * (v - self.e_k);
824        let i_t = self.g_t * m_t_inf * m_t_inf * s * (v - self.e_ca);
825        let i_l = self.g_l * (v - self.e_l);
826        let dvdt = (-i_na - i_k - i_m - i_t - i_l + current) / self.c_m;
827        [dvdt, dm, dh, dn, dp, ds]
828    }
829
830    /// Return one classical RK4 increment of `[V, m, h, n, p, s]`, holding
831    /// `current` constant across the four stages.
832    fn rk4_substep(&self, st: [f64; 6], current: f64) -> [f64; 6] {
833        let dt = self.dt;
834        let k1 = self.derivatives(st[0], st[1], st[2], st[3], st[4], st[5], current);
835        let mut a = [0.0_f64; 6];
836        for i in 0..6 {
837            a[i] = st[i] + 0.5 * dt * k1[i];
838        }
839        let k2 = self.derivatives(a[0], a[1], a[2], a[3], a[4], a[5], current);
840        for i in 0..6 {
841            a[i] = st[i] + 0.5 * dt * k2[i];
842        }
843        let k3 = self.derivatives(a[0], a[1], a[2], a[3], a[4], a[5], current);
844        for i in 0..6 {
845            a[i] = st[i] + dt * k3[i];
846        }
847        let k4 = self.derivatives(a[0], a[1], a[2], a[3], a[4], a[5], current);
848        let mut out = [0.0_f64; 6];
849        for i in 0..6 {
850            out[i] = st[i] + dt * (k1[i] + 2.0 * k2[i] + 2.0 * k3[i] + k4[i]) / 6.0;
851        }
852        out
853    }
854
855    pub fn step(&mut self, current: f64) -> i32 {
856        let v_prev = self.v;
857        let mut st = [self.v, self.m, self.h, self.n, self.p, self.s];
858        for _ in 0..4 {
859            st = self.rk4_substep(st, current);
860        }
861        self.v = st[0];
862        self.m = st[1];
863        self.h = st[2];
864        self.n = st[3];
865        self.p = st[4];
866        self.s = st[5];
867        if self.v >= self.v_threshold && v_prev < self.v_threshold {
868            1
869        } else {
870            0
871        }
872    }
873
874    pub fn reset(&mut self) {
875        self.v = -65.0;
876        self.m = 0.02;
877        self.h = 0.8;
878        self.n = 0.2;
879        self.p = 0.0;
880        self.s = 0.9;
881    }
882}
883
884impl Default for MartinottiNeuron {
885    fn default() -> Self {
886        Self::new()
887    }
888}
889
890// ═══════════════════════════════════════════════════════════════════
891// Tests
892// ═══════════════════════════════════════════════════════════════════
893
894#[cfg(test)]
895mod tests {
896    use super::*;
897
898    // ── PV+ tests ────────────────────────────────────────────────
899
900    #[test]
901    fn pv_fires_with_input() {
902        let mut n = PVFastSpikingNeuron::new();
903        let spikes: i32 = (0..5000).map(|_| n.step(2.0)).sum();
904        assert!(spikes > 0, "PV+ must fire with sustained input");
905    }
906
907    #[test]
908    fn pv_no_fire_without_input() {
909        let mut n = PVFastSpikingNeuron::new();
910        let spikes: i32 = (0..2000).map(|_| n.step(0.0)).sum();
911        assert_eq!(spikes, 0);
912    }
913
914    #[test]
915    fn pv_negative_current_no_fire() {
916        let mut n = PVFastSpikingNeuron::new();
917        let spikes: i32 = (0..1000).map(|_| n.step(-1.0)).sum();
918        assert_eq!(spikes, 0);
919    }
920
921    #[test]
922    fn pv_high_firing_rate() {
923        // PV+ should sustain high-rate repetitive firing.
924        let mut n = PVFastSpikingNeuron::new();
925        let spikes: i32 = (0..5000).map(|_| n.step(5.0)).sum();
926        assert!(spikes > 100, "PV+ should fire at high rate: got {spikes}");
927    }
928
929    #[test]
930    fn pv_reset_roundtrip() {
931        let mut n = PVFastSpikingNeuron::new();
932        for _ in 0..1000 {
933            n.step(3.0);
934        }
935        n.reset();
936        let mut fresh = PVFastSpikingNeuron::new();
937        let r1: i32 = (0..500).map(|_| n.step(3.0)).sum();
938        let r2: i32 = (0..500).map(|_| fresh.step(3.0)).sum();
939        assert_eq!(r1, r2);
940    }
941
942    #[test]
943    fn pv_voltage_bounded() {
944        let mut n = PVFastSpikingNeuron::new();
945        for _ in 0..5000 {
946            n.step(5.0);
947        }
948        assert!(n.v.is_finite());
949        assert!(n.h.is_finite());
950        assert!(n.n.is_finite());
951    }
952
953    #[test]
954    fn pv_performance_5k_steps() {
955        let mut n = PVFastSpikingNeuron::new();
956        let start = std::time::Instant::now();
957        for _ in 0..5_000 {
958            n.step(3.0);
959        }
960        assert!(
961            start.elapsed().as_millis() < 500,
962            "5k steps took {:?}",
963            start.elapsed()
964        );
965    }
966
967    // ── SST+ tests ───────────────────────────────────────────────
968
969    #[test]
970    fn sst_fires_with_input() {
971        let mut n = SSTNeuron::new();
972        let spikes: i32 = (0..10000).map(|_| n.step(5.0)).sum();
973        assert!(spikes > 0, "SST+ must fire with sustained input");
974    }
975
976    #[test]
977    fn sst_no_fire_without_input() {
978        let mut n = SSTNeuron::new();
979        let spikes: i32 = (0..5000).map(|_| n.step(0.0)).sum();
980        assert_eq!(spikes, 0);
981    }
982
983    #[test]
984    fn sst_adaptation_reduces_rate() {
985        let mut n = SSTNeuron::new();
986        let first_half: i32 = (0..5000).map(|_| n.step(5.0)).sum();
987        let second_half: i32 = (0..5000).map(|_| n.step(5.0)).sum();
988        // M-current → spike frequency adaptation
989        assert!(
990            second_half <= first_half + 3,
991            "SST+ should adapt: first={first_half}, second={second_half}"
992        );
993    }
994
995    #[test]
996    fn sst_reset_roundtrip() {
997        let mut n = SSTNeuron::new();
998        for _ in 0..5000 {
999            n.step(5.0);
1000        }
1001        n.reset();
1002        let mut fresh = SSTNeuron::new();
1003        let r1: i32 = (0..2000).map(|_| n.step(5.0)).sum();
1004        let r2: i32 = (0..2000).map(|_| fresh.step(5.0)).sum();
1005        assert_eq!(r1, r2);
1006    }
1007
1008    #[test]
1009    fn sst_voltage_bounded() {
1010        let mut n = SSTNeuron::new();
1011        for _ in 0..20000 {
1012            n.step(10.0);
1013        }
1014        assert!(n.v.is_finite());
1015        assert!(n.p.is_finite());
1016        assert!(n.s.is_finite());
1017    }
1018
1019    #[test]
1020    fn sst_performance_10k_steps() {
1021        let mut n = SSTNeuron::new();
1022        let start = std::time::Instant::now();
1023        for _ in 0..10_000 {
1024            n.step(5.0);
1025        }
1026        let elapsed = start.elapsed();
1027        assert!(
1028            elapsed.as_millis() < 500,
1029            "10k SST steps took {:?}",
1030            elapsed
1031        );
1032    }
1033
1034    // ── VIP tests ────────────────────────────────────────────────
1035
1036    #[test]
1037    fn vip_fires_with_input() {
1038        let mut n = VIPNeuron::new();
1039        let spikes: i32 = (0..10000).map(|_| n.step(2.0)).sum();
1040        assert!(spikes > 0, "VIP must fire with sustained input");
1041    }
1042
1043    #[test]
1044    fn vip_no_fire_without_input() {
1045        let mut n = VIPNeuron::new();
1046        let spikes: i32 = (0..5000).map(|_| n.step(0.0)).sum();
1047        assert_eq!(spikes, 0);
1048    }
1049
1050    #[test]
1051    fn vip_accommodation() {
1052        // A-current causes transient accommodation at spike onset.
1053        // Compare fresh neuron's first 100 steps vs steady-state.
1054        let mut n = VIPNeuron::new();
1055        // First 500 steps: A-current b gate is high → strong IA → suppresses early spikes
1056        let onset: i32 = (0..500).map(|_| n.step(3.0)).sum();
1057        // Skip 5000 steps to reach steady state
1058        for _ in 0..5000 {
1059            n.step(3.0);
1060        }
1061        // Next 500 steps at steady state
1062        let steady: i32 = (0..500).map(|_| n.step(3.0)).sum();
1063        // At steady state, b has dropped, IA is weaker → fires at least as much
1064        assert!(
1065            steady >= onset,
1066            "VIP steady-state ({steady}) should fire >= onset ({onset})"
1067        );
1068    }
1069
1070    #[test]
1071    fn vip_reset_roundtrip() {
1072        let mut n = VIPNeuron::new();
1073        for _ in 0..5000 {
1074            n.step(3.0);
1075        }
1076        n.reset();
1077        let mut fresh = VIPNeuron::new();
1078        let r1: i32 = (0..2000).map(|_| n.step(3.0)).sum();
1079        let r2: i32 = (0..2000).map(|_| fresh.step(3.0)).sum();
1080        assert_eq!(r1, r2);
1081    }
1082
1083    #[test]
1084    fn vip_voltage_bounded() {
1085        let mut n = VIPNeuron::new();
1086        for _ in 0..20000 {
1087            n.step(5.0);
1088        }
1089        assert!(n.v.is_finite());
1090    }
1091
1092    #[test]
1093    fn vip_performance_10k_steps() {
1094        let mut n = VIPNeuron::new();
1095        let start = std::time::Instant::now();
1096        for _ in 0..10_000 {
1097            n.step(3.0);
1098        }
1099        assert!(start.elapsed().as_millis() < 100);
1100    }
1101
1102    // ── Chandelier tests ─────────────────────────────────────────
1103
1104    #[test]
1105    fn chandelier_fires_with_input() {
1106        let mut n = ChandelierNeuron::new();
1107        let spikes: i32 = (0..5000).map(|_| n.step(3.0)).sum();
1108        assert!(spikes > 0, "Chandelier must fire with sustained input");
1109    }
1110
1111    #[test]
1112    fn chandelier_no_fire_without_input() {
1113        let mut n = ChandelierNeuron::new();
1114        let spikes: i32 = (0..2000).map(|_| n.step(0.0)).sum();
1115        assert_eq!(spikes, 0);
1116    }
1117
1118    #[test]
1119    fn chandelier_has_kv1_delay_current() {
1120        // Chandelier has Kv1 (D-type) which activates slowly.
1121        // After sustained input, Kv1 contributes extra K+ current → lower steady-state rate.
1122        let mut ch = ChandelierNeuron::new();
1123        let mut pv = PVFastSpikingNeuron::new();
1124        let ch_spikes: i32 = (0..5000).map(|_| ch.step(3.0)).sum();
1125        let pv_spikes: i32 = (0..5000).map(|_| pv.step(3.0)).sum();
1126        // Both should fire, Chandelier may fire fewer due to extra K+
1127        assert!(ch_spikes > 0, "Chandelier must fire");
1128        assert!(pv_spikes > 0, "PV+ must fire");
1129        assert!(
1130            ch_spikes <= pv_spikes + 10,
1131            "Chandelier ({ch_spikes}) should fire <= PV+ ({pv_spikes}) due to Kv1"
1132        );
1133    }
1134
1135    #[test]
1136    fn chandelier_reset_roundtrip() {
1137        let mut n = ChandelierNeuron::new();
1138        for _ in 0..1000 {
1139            n.step(3.0);
1140        }
1141        n.reset();
1142        let mut fresh = ChandelierNeuron::new();
1143        let r1: i32 = (0..500).map(|_| n.step(3.0)).sum();
1144        let r2: i32 = (0..500).map(|_| fresh.step(3.0)).sum();
1145        assert_eq!(r1, r2);
1146    }
1147
1148    #[test]
1149    fn chandelier_voltage_bounded() {
1150        let mut n = ChandelierNeuron::new();
1151        for _ in 0..5000 {
1152            n.step(5.0);
1153        }
1154        assert!(n.v.is_finite());
1155    }
1156
1157    #[test]
1158    fn chandelier_performance_5k_steps() {
1159        let mut n = ChandelierNeuron::new();
1160        let start = std::time::Instant::now();
1161        for _ in 0..5_000 {
1162            n.step(3.0);
1163        }
1164        assert!(
1165            start.elapsed().as_millis() < 500,
1166            "5k steps took {:?}",
1167            start.elapsed()
1168        );
1169    }
1170
1171    // ── Cerebellar basket tests ──────────────────────────────────
1172
1173    #[test]
1174    fn basket_fires_with_input() {
1175        let mut n = CerebellarBasketNeuron::new();
1176        let spikes: i32 = (0..5000).map(|_| n.step(3.0)).sum();
1177        assert!(spikes > 0, "Basket cell must fire with sustained input");
1178    }
1179
1180    #[test]
1181    fn basket_no_fire_without_input() {
1182        let mut n = CerebellarBasketNeuron::new();
1183        let spikes: i32 = (0..2000).map(|_| n.step(0.0)).sum();
1184        assert_eq!(spikes, 0);
1185    }
1186
1187    #[test]
1188    fn basket_ca_dynamics_during_spiking() {
1189        // Ca2+ decays between spikes but spikes cause transient increases
1190        let mut n = CerebellarBasketNeuron::new();
1191        // Run until steady-state Ca2+ with spiking
1192        for _ in 0..5000 {
1193            n.step(3.0);
1194        }
1195        let ca_spiking = n.ca;
1196        // Ca2+ without spiking should be lower (pure decay)
1197        let mut n2 = CerebellarBasketNeuron::new();
1198        n2.ca = ca_spiking;
1199        for _ in 0..5000 {
1200            n2.step(0.0);
1201        }
1202        assert!(
1203            ca_spiking > n2.ca,
1204            "spiking Ca ({ca_spiking:.4}) should exceed resting Ca ({:.4})",
1205            n2.ca
1206        );
1207    }
1208
1209    #[test]
1210    fn basket_reset_roundtrip() {
1211        let mut n = CerebellarBasketNeuron::new();
1212        for _ in 0..2000 {
1213            n.step(3.0);
1214        }
1215        n.reset();
1216        assert_eq!(n.ca, 0.05);
1217        let mut fresh = CerebellarBasketNeuron::new();
1218        let r1: i32 = (0..1000).map(|_| n.step(3.0)).sum();
1219        let r2: i32 = (0..1000).map(|_| fresh.step(3.0)).sum();
1220        assert_eq!(r1, r2);
1221    }
1222
1223    #[test]
1224    fn basket_voltage_bounded() {
1225        let mut n = CerebellarBasketNeuron::new();
1226        for _ in 0..5000 {
1227            n.step(5.0);
1228        }
1229        assert!(n.v.is_finite());
1230        assert!(n.ca.is_finite());
1231        assert!(n.ca >= 0.0);
1232    }
1233
1234    #[test]
1235    fn basket_performance_5k_steps() {
1236        let mut n = CerebellarBasketNeuron::new();
1237        let start = std::time::Instant::now();
1238        for _ in 0..5_000 {
1239            n.step(3.0);
1240        }
1241        assert!(
1242            start.elapsed().as_millis() < 500,
1243            "5k steps took {:?}",
1244            start.elapsed()
1245        );
1246    }
1247
1248    // ── Martinotti tests ─────────────────────────────────────────
1249
1250    #[test]
1251    fn martinotti_fires_with_input() {
1252        let mut n = MartinottiNeuron::new();
1253        let spikes: i32 = (0..10000).map(|_| n.step(3.0)).sum();
1254        assert!(spikes > 0, "Martinotti must fire with sustained input");
1255    }
1256
1257    #[test]
1258    fn martinotti_no_fire_without_input() {
1259        let mut n = MartinottiNeuron::new();
1260        let spikes: i32 = (0..5000).map(|_| n.step(0.0)).sum();
1261        assert_eq!(spikes, 0);
1262    }
1263
1264    #[test]
1265    fn martinotti_strong_adaptation() {
1266        let mut n = MartinottiNeuron::new();
1267        let first: i32 = (0..5000).map(|_| n.step(4.0)).sum();
1268        let second: i32 = (0..5000).map(|_| n.step(4.0)).sum();
1269        assert!(
1270            second <= first + 3,
1271            "Martinotti should strongly adapt: first={first}, second={second}"
1272        );
1273    }
1274
1275    #[test]
1276    fn martinotti_adapts_more_than_sst() {
1277        // Martinotti has higher g_m → stronger adaptation
1278        let mut mc = MartinottiNeuron::new();
1279        let mut sst = SSTNeuron::new();
1280        // Use same current magnitude
1281        let mc_spikes: i32 = (0..10000).map(|_| mc.step(4.0)).sum();
1282        let sst_spikes: i32 = (0..10000).map(|_| sst.step(4.0)).sum();
1283        // Martinotti should fire less (stronger adaptation, but lower rheobase too)
1284        // At minimum, both should fire
1285        assert!(mc_spikes > 0, "Martinotti should fire: got {mc_spikes}");
1286        assert!(sst_spikes > 0, "SST should fire: got {sst_spikes}");
1287    }
1288
1289    #[test]
1290    fn martinotti_reset_roundtrip() {
1291        let mut n = MartinottiNeuron::new();
1292        for _ in 0..5000 {
1293            n.step(4.0);
1294        }
1295        n.reset();
1296        let mut fresh = MartinottiNeuron::new();
1297        let r1: i32 = (0..2000).map(|_| n.step(4.0)).sum();
1298        let r2: i32 = (0..2000).map(|_| fresh.step(4.0)).sum();
1299        assert_eq!(r1, r2);
1300    }
1301
1302    #[test]
1303    fn martinotti_voltage_bounded() {
1304        let mut n = MartinottiNeuron::new();
1305        for _ in 0..20000 {
1306            n.step(10.0);
1307        }
1308        assert!(n.v.is_finite());
1309        assert!(n.p.is_finite());
1310    }
1311
1312    #[test]
1313    fn martinotti_performance_10k_steps() {
1314        let mut n = MartinottiNeuron::new();
1315        let start = std::time::Instant::now();
1316        for _ in 0..10_000 {
1317            n.step(4.0);
1318        }
1319        assert!(start.elapsed().as_millis() < 100);
1320    }
1321}