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