Skip to main content

sc_neurocore_engine/neurons/
motor.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 — Motor Neuron Models
8
9//! Motor neuron models for spinal and cortical motor circuits.
10//!
11//! Motor model group: alpha motor, gamma motor, upper motor, Renshaw cell, motor unit.
12//! Added one by one with full 7-point checklist verification.
13
14use super::biophysical::safe_rate;
15
16// ═══════════════════════════════════════════════════════════════════
17// Alpha Motor Neuron
18// ═══════════════════════════════════════════════════════════════════
19
20/// Alpha motor neuron — spinal cord, innervates extrafusal muscle fibres.
21///
22/// Biophysics: Wang-Buzsáki Na+/K+ core, persistent inward current (PIC)
23/// for bistable firing (plateau potentials), Ca2+-dependent AHP for rate
24/// limiting (f-I gain control). Larger soma than cortical neurons → lower
25/// input resistance.
26///
27/// PIC is modelled as a slow L-type Ca2+ current that activates at
28/// depolarised potentials and inactivates very slowly, enabling plateau
29/// potentials and self-sustained firing after brief input.
30///
31/// AHP from Ca2+-activated K+ (SK channels) limits firing rate and
32/// produces the characteristic linear f-I relationship of motor neurons.
33///
34/// Powers & Binder, J. Neurophysiol. 86, 2001.
35/// Heckman & Enoka, Compr. Physiol. 2(4), 2012.
36#[derive(Clone, Debug)]
37pub struct AlphaMotorNeuron {
38    pub v: f64,
39    pub h: f64,
40    pub n: f64,
41    pub m_pic: f64,  // PIC (L-type Ca²⁺) activation
42    pub h_pic: f64,  // PIC slow inactivation (tau ~200 ms)
43    pub ca: f64,     // Intracellular Ca²⁺ (µM)
44    pub ca_buf: f64, // Bound Ca²⁺ (buffered fraction)
45    // Conductances (mS/cm²)
46    pub g_na: f64,
47    pub g_k: f64,
48    pub g_pic: f64, // Persistent inward current
49    pub g_ahp: f64, // Ca²⁺-dependent K⁺ (AHP)
50    pub g_l: f64,
51    // Reversal potentials (mV)
52    pub e_na: f64,
53    pub e_k: f64,
54    pub e_ca: f64,
55    pub e_l: f64,
56    pub c_m: f64,
57    pub phi: f64,
58    pub tau_ca: f64,    // Ca²⁺ decay (ms)
59    pub buf_ratio: f64, // Buffering ratio (fraction of Ca²⁺ bound)
60    pub dt: f64,
61    pub v_threshold: f64,
62}
63
64impl AlphaMotorNeuron {
65    pub fn new() -> Self {
66        Self {
67            v: -65.0,
68            h: 0.8,
69            n: 0.1,
70            m_pic: 0.0,
71            h_pic: 1.0, // PIC inactivation starts de-inactivated
72            ca: 0.0,
73            ca_buf: 0.0,
74            g_na: 35.0,
75            g_k: 9.0,
76            g_pic: 0.15, // PIC for plateau potentials (conservative)
77            g_ahp: 3.0,  // Strong AHP for rate limiting
78            g_l: 0.3,    // Higher leak (larger soma, stabilises rest)
79            e_na: 55.0,
80            e_k: -90.0,
81            e_ca: 120.0,
82            e_l: -65.0,
83            c_m: 1.5, // Larger soma → higher capacitance
84            phi: 4.0,
85            tau_ca: 150.0,    // Slow Ca²⁺ clearance for AHP
86            buf_ratio: 0.003, // ~0.3% free Ca²⁺ (99.7% buffered)
87            dt: 0.01,
88            v_threshold: -20.0,
89        }
90    }
91
92    pub fn step(&mut self, current: f64) -> i32 {
93        let v_prev = self.v;
94        let n_sub = (0.5 / self.dt.max(0.001)) as usize;
95        for _ in 0..n_sub {
96            // WB Na+/K+ gating
97            let am = safe_rate(0.1, 35.0, self.v, 10.0, 1.0);
98            let bm = 4.0 * (-(self.v + 60.0) / 18.0).exp();
99            let m_inf = am / (am + bm);
100            let ah = 0.07 * (-(self.v + 58.0) / 20.0).exp();
101            let bh = 1.0 / (1.0 + (-(self.v + 28.0) / 10.0).exp());
102            let an = safe_rate(0.01, 34.0, self.v, 10.0, 0.1);
103            let bn = 0.125 * (-(self.v + 44.0) / 80.0).exp();
104
105            self.h += self.phi * (ah * (1.0 - self.h) - bh * self.h) * self.dt;
106            self.n += self.phi * (an * (1.0 - self.n) - bn * self.n) * self.dt;
107
108            // PIC (L-type Ca²⁺): activation + slow inactivation
109            // Activation: m_pic, tau ~50 ms, half-act -50 mV
110            let m_pic_inf = 1.0 / (1.0 + (-(self.v + 40.0) / 5.0).exp());
111            self.m_pic += (m_pic_inf - self.m_pic) / 50.0 * self.dt;
112            // Inactivation: h_pic, tau ~200 ms, half-inact -40 mV
113            // L-type inactivation is slow and Ca²⁺-dependent
114            let h_pic_inf = 1.0 / (1.0 + ((self.v + 40.0) / 8.0).exp());
115            let tau_h_pic = 200.0 + 100.0 / (1.0 + ((self.v + 40.0) / 10.0).powi(2)).max(0.01);
116            self.h_pic += (h_pic_inf - self.h_pic) / tau_h_pic * self.dt;
117            self.h_pic = self.h_pic.clamp(0.0, 1.0);
118
119            // Ca²⁺ dynamics with buffering
120            // Total Ca²⁺ entry (PIC-mediated)
121            let i_ca_entry = self.g_pic * self.m_pic * self.h_pic * (self.v - self.e_ca);
122            let ca_influx = if i_ca_entry < 0.0 {
123                -i_ca_entry * 0.001
124            } else {
125                0.0
126            };
127            let ca_spike = if self.v > -10.0 { 0.02 } else { 0.0 };
128            // Only ~0.3% of entering Ca²⁺ is free (rest is buffered)
129            let free_ca_change = (ca_influx + ca_spike) * self.buf_ratio;
130            self.ca += (-self.ca / self.tau_ca + free_ca_change) * self.dt;
131            if self.ca < 0.0 {
132                self.ca = 0.0;
133            }
134            // Buffered pool tracks total entry (slower dynamics)
135            self.ca_buf += ((ca_influx + ca_spike) * (1.0 - self.buf_ratio)
136                - self.ca_buf / (self.tau_ca * 5.0))
137                * self.dt;
138            if self.ca_buf < 0.0 {
139                self.ca_buf = 0.0;
140            }
141
142            // AHP: Ca²⁺-activated K⁺ (SK channels), Hill n=2
143            let ca_total = self.ca + self.ca_buf * 0.01; // Buffered contributes slowly
144            let ahp_inf = ca_total * ca_total / (ca_total * ca_total + 0.25);
145
146            let i_na = self.g_na * m_inf.powi(3) * self.h * (self.v - self.e_na);
147            let i_k = self.g_k * self.n.powi(4) * (self.v - self.e_k);
148            let i_pic = self.g_pic * self.m_pic * self.h_pic * (self.v - self.e_ca);
149            let i_ahp = self.g_ahp * ahp_inf * (self.v - self.e_k);
150            let i_l = self.g_l * (self.v - self.e_l);
151
152            self.v += (-i_na - i_k - i_pic - i_ahp - i_l + current) / self.c_m * self.dt;
153        }
154        if self.v >= self.v_threshold && v_prev < self.v_threshold {
155            1
156        } else {
157            0
158        }
159    }
160
161    pub fn reset(&mut self) {
162        *self = Self::new();
163    }
164}
165
166impl Default for AlphaMotorNeuron {
167    fn default() -> Self {
168        Self::new()
169    }
170}
171
172// ═══════════════════════════════════════════════════════════════════
173// Gamma Motor Neuron
174// ═══════════════════════════════════════════════════════════════════
175
176/// Gamma motor neuron — innervates intrafusal fibres of muscle spindles.
177///
178/// Regulates proprioceptive sensitivity by adjusting spindle tension.
179/// Smaller soma than alpha, lower firing rates (5-30 Hz), no PIC.
180/// Simple LIF with spike-frequency adaptation (slow K+ current).
181/// Two subtypes: dynamic (bag1, velocity-sensitive) and static
182/// (bag2/chain, length-sensitive) — controlled by `dynamic` flag.
183///
184/// Prochazka & Hulliger, Prog. Brain Res. 80, 1989.
185/// Taylor et al., J. Physiol. 519(3), 1999.
186#[derive(Clone, Debug)]
187pub struct GammaMotorNeuron {
188    pub v: f64,
189    pub v_rest: f64,
190    pub v_reset: f64,
191    pub v_threshold: f64,
192    pub tau: f64,
193    pub adapt: f64,     // Slow adaptation current
194    pub tau_adapt: f64, // Adaptation time constant (ms)
195    pub a_adapt: f64,   // Adaptation coupling strength
196    pub gain: f64,      // Input gain (fusimotor drive → mV)
197    pub dynamic: bool,  // true = dynamic (bag1), false = static (bag2/chain)
198    pub dt: f64,
199}
200
201impl GammaMotorNeuron {
202    pub fn new() -> Self {
203        Self::dynamic()
204    }
205
206    /// Dynamic gamma — innervates bag1 intrafusal fibres (velocity-sensitive).
207    pub fn dynamic() -> Self {
208        Self {
209            v: -65.0,
210            v_rest: -65.0,
211            v_reset: -70.0,
212            v_threshold: -50.0,
213            tau: 8.0,
214            adapt: 0.0,
215            tau_adapt: 100.0,
216            a_adapt: 0.3,
217            gain: 1.0,
218            dynamic: true,
219            dt: 0.5,
220        }
221    }
222
223    /// Static gamma — innervates bag2/chain intrafusal fibres (length-sensitive).
224    pub fn static_type() -> Self {
225        Self {
226            tau: 12.0,        // Slower membrane
227            tau_adapt: 200.0, // Larger adaptation time constant
228            a_adapt: 0.5,
229            dynamic: false,
230            ..Self::dynamic()
231        }
232    }
233
234    /// Step with fusimotor drive (arbitrary units, ≥ 0). Returns spike (1/0).
235    pub fn step(&mut self, drive: f64) -> i32 {
236        if !self.is_valid() || !drive.is_finite() {
237            return 0;
238        }
239        let v_old = self.v;
240        let adapt_old = self.adapt;
241        let input = self.gain * drive.max(0.0) - adapt_old;
242        let v_target = self.v_rest + input;
243        let v_candidate = v_target + (v_old - v_target) * (-self.dt / self.tau).exp();
244        let adapt_target = self.a_adapt * (v_candidate - self.v_rest);
245        let adapt_candidate =
246            adapt_target + (adapt_old - adapt_target) * (-self.dt / self.tau_adapt).exp();
247        if !v_candidate.is_finite() || !adapt_candidate.is_finite() {
248            return 0;
249        }
250        self.v = v_candidate;
251        self.adapt = adapt_candidate;
252
253        if v_candidate >= self.v_threshold {
254            self.v = self.v_reset;
255            1
256        } else {
257            0
258        }
259    }
260
261    pub fn reset(&mut self) {
262        self.v = self.v_rest;
263        self.adapt = 0.0;
264    }
265
266    fn is_valid(&self) -> bool {
267        [
268            self.v,
269            self.v_rest,
270            self.v_reset,
271            self.v_threshold,
272            self.tau,
273            self.adapt,
274            self.tau_adapt,
275            self.a_adapt,
276            self.gain,
277            self.dt,
278        ]
279        .iter()
280        .all(|value| value.is_finite())
281            && self.tau > 0.0
282            && self.tau_adapt > 0.0
283            && self.dt > 0.0
284            && self.gain >= 0.0
285            && self.v_reset < self.v_threshold
286    }
287}
288
289impl Default for GammaMotorNeuron {
290    fn default() -> Self {
291        Self::new()
292    }
293}
294
295// ═══════════════════════════════════════════════════════════════════
296// Upper Motor Neuron (Corticospinal L5 Pyramidal)
297// ═══════════════════════════════════════════════════════════════════
298
299/// Upper motor neuron — layer 5 pyramidal cell, corticospinal projection.
300///
301/// Biophysics: Pospischil 2008 RS parameterisation (Na+, K+, M-current)
302/// with added high-threshold Ca2+ current for dendritic Ca2+ spikes.
303/// Regular-spiking with adaptation. Drives alpha/gamma motor neurons
304/// via corticospinal tract.
305///
306/// Pospischil et al., Biol. Cybern. 99(4-5), 2008 (RS variant).
307/// Larkum, Trends Neurosci. 36(3), 2013 (dendritic Ca2+ spikes).
308#[derive(Clone, Debug)]
309pub struct UpperMotorNeuron {
310    pub v: f64,
311    pub m: f64,
312    pub h: f64,
313    pub n: f64,
314    pub p: f64, // M-current (Kv7) activation
315    pub s: f64, // High-threshold Ca2+ activation
316    // Conductances
317    pub g_na: f64,
318    pub g_k: f64,
319    pub g_m: f64,
320    pub g_ca: f64,
321    pub g_l: f64,
322    // Reversal potentials
323    pub e_na: f64,
324    pub e_k: f64,
325    pub e_ca: f64,
326    pub e_l: f64,
327    pub c_m: f64,
328    pub dt: f64,
329    pub v_threshold: f64,
330}
331
332impl UpperMotorNeuron {
333    pub fn new() -> Self {
334        Self {
335            v: -70.0,
336            m: 0.05,
337            h: 0.6,
338            n: 0.3,
339            p: 0.0,
340            s: 0.0,
341            g_na: 50.0,
342            g_k: 5.0,
343            g_m: 0.07, // M-current for adaptation (Pospischil RS)
344            g_ca: 0.3, // High-threshold dendritic Ca2+
345            g_l: 0.1,
346            e_na: 50.0,
347            e_k: -90.0,
348            e_ca: 120.0,
349            e_l: -70.0,
350            c_m: 1.0,
351            dt: 0.025,
352            v_threshold: -20.0,
353        }
354    }
355
356    fn finite(values: &[f64]) -> bool {
357        values.iter().all(|value| value.is_finite())
358    }
359
360    fn rate_exp(value: f64) -> f64 {
361        value.clamp(-60.0, 60.0).exp()
362    }
363
364    fn gate(previous: f64, alpha: f64, beta: f64, dt: f64) -> Option<f64> {
365        let total = alpha + beta;
366        if total <= 0.0 || !total.is_finite() {
367            return None;
368        }
369        let steady = alpha / total;
370        let next = steady + (previous - steady) * Self::rate_exp(-total * dt);
371        next.is_finite().then_some(next.clamp(0.0, 1.0))
372    }
373
374    fn gate_inf(previous: f64, steady: f64, tau: f64, dt: f64) -> Option<f64> {
375        if tau <= 0.0 || !tau.is_finite() {
376            return None;
377        }
378        let next = steady + (previous - steady) * Self::rate_exp(-dt / tau);
379        next.is_finite().then_some(next.clamp(0.0, 1.0))
380    }
381
382    fn valid_configuration(&self) -> bool {
383        Self::finite(&[
384            self.g_na,
385            self.g_k,
386            self.g_m,
387            self.g_ca,
388            self.g_l,
389            self.e_na,
390            self.e_k,
391            self.e_ca,
392            self.e_l,
393            self.c_m,
394            self.dt,
395            self.v_threshold,
396        ]) && self.g_na >= 0.0
397            && self.g_k >= 0.0
398            && self.g_m >= 0.0
399            && self.g_ca >= 0.0
400            && self.g_l >= 0.0
401            && self.c_m > 0.0
402            && self.dt > 0.0
403    }
404
405    fn valid_state(&self) -> bool {
406        Self::finite(&[self.v, self.m, self.h, self.n, self.p, self.s])
407            && (-150.0..=100.0).contains(&self.v)
408            && (0.0..=1.0).contains(&self.m)
409            && (0.0..=1.0).contains(&self.h)
410            && (0.0..=1.0).contains(&self.n)
411            && (0.0..=1.0).contains(&self.p)
412            && (0.0..=1.0).contains(&self.s)
413    }
414
415    fn step_candidate(
416        &self,
417        v: f64,
418        mut m: f64,
419        mut h: f64,
420        mut n: f64,
421        mut p: f64,
422        mut s: f64,
423        current: f64,
424    ) -> Option<(f64, f64, f64, f64, f64, f64)> {
425        let dv = v - (-56.2);
426        let x_m = dv - 13.0;
427        let alpha_m = if x_m.abs() < 1e-6 {
428            0.32 * 4.0
429        } else {
430            -0.32 * x_m / (Self::rate_exp(-x_m / 4.0) - 1.0)
431        };
432        // Published Pospischil beta_m numerator is V - V_T - 40; an earlier revision
433        // shared the -17 offset of alpha_h, which drove the cell into depolarisation
434        // block (three spikes then a fixed point near threshold for any stimulus).
435        let x_bm = dv - 40.0;
436        let beta_m = if x_bm.abs() < 1e-6 {
437            0.28 * 5.0
438        } else {
439            0.28 * x_bm / (Self::rate_exp(x_bm / 5.0) - 1.0)
440        };
441        let alpha_h = 0.128 * Self::rate_exp(-(dv - 17.0) / 18.0);
442        let beta_h = 4.0 / (1.0 + Self::rate_exp(-(dv - 40.0) / 5.0));
443        let x_n = dv - 15.0;
444        let alpha_n = if x_n.abs() < 1e-6 {
445            0.032 * 5.0
446        } else {
447            -0.032 * x_n / (Self::rate_exp(-x_n / 5.0) - 1.0)
448        };
449        let beta_n = 0.5 * Self::rate_exp(-(dv - 10.0) / 40.0);
450        m = Self::gate(m, alpha_m, beta_m, self.dt)?;
451        h = Self::gate(h, alpha_h, beta_h, self.dt)?;
452        n = Self::gate(n, alpha_n, beta_n, self.dt)?;
453        let p_inf = 1.0 / (1.0 + Self::rate_exp(-(v + 35.0) / 10.0));
454        let tau_p =
455            400.0 / (3.3 * Self::rate_exp((v + 35.0) / 20.0) + Self::rate_exp(-(v + 35.0) / 20.0));
456        p = Self::gate_inf(p, p_inf, tau_p, self.dt)?;
457        let s_inf = 1.0 / (1.0 + Self::rate_exp(-(v + 20.0) / 5.0));
458        s = Self::gate_inf(s, s_inf, 10.0, self.dt)?;
459        let g_na = self.g_na * m.powi(3) * h;
460        let g_k = self.g_k * n.powi(4);
461        let g_m = self.g_m * p;
462        let g_ca = self.g_ca * s.powi(2);
463        let g_total = g_na + g_k + g_m + g_ca + self.g_l;
464        if g_total <= 0.0 || !g_total.is_finite() {
465            return None;
466        }
467        let steady_v = (current
468            + g_na * self.e_na
469            + g_k * self.e_k
470            + g_m * self.e_k
471            + g_ca * self.e_ca
472            + self.g_l * self.e_l)
473            / g_total;
474        let next_v = steady_v + (v - steady_v) * Self::rate_exp(-(g_total / self.c_m) * self.dt);
475        Self::finite(&[next_v, m, h, n, p, s]).then_some((
476            next_v.clamp(-150.0, 100.0),
477            m,
478            h,
479            n,
480            p,
481            s,
482        ))
483    }
484
485    pub fn step(&mut self, current: f64) -> i32 {
486        if !self.valid_configuration() || !self.valid_state() || !current.is_finite() {
487            return 0;
488        }
489        let v_prev = self.v;
490        let (mut v, mut m, mut h, mut n, mut p, mut s) =
491            (self.v, self.m, self.h, self.n, self.p, self.s);
492        for _ in 0..4 {
493            let Some(next) = self.step_candidate(v, m, h, n, p, s, current) else {
494                return 0;
495            };
496            (v, m, h, n, p, s) = next;
497        }
498        (self.v, self.m, self.h, self.n, self.p, self.s) = (v, m, h, n, p, s);
499        if v >= self.v_threshold && v_prev < self.v_threshold {
500            1
501        } else {
502            0
503        }
504    }
505
506    pub fn reset(&mut self) {
507        self.v = -70.0;
508        self.m = 0.05;
509        self.h = 0.6;
510        self.n = 0.3;
511        self.p = 0.0;
512        self.s = 0.0;
513    }
514}
515
516impl Default for UpperMotorNeuron {
517    fn default() -> Self {
518        Self::new()
519    }
520}
521
522// ═══════════════════════════════════════════════════════════════════
523// Renshaw Cell (Spinal Inhibitory Interneuron)
524// ═══════════════════════════════════════════════════════════════════
525
526/// Renshaw cell — spinal inhibitory interneuron for recurrent inhibition.
527///
528/// Receives collaterals from alpha motor neuron axons, provides
529/// glycinergic recurrent inhibition back to the motor pool. Characteristic
530/// high-frequency initial burst (cholinergic nicotinic drive from motor
531/// axon collaterals) followed by rapid adaptation.
532///
533/// WB gating core with strong adaptation (M-current analogue) to produce
534/// the burst-then-decay response pattern.
535///
536/// Renshaw 1941 (discovery); Windhorst, Prog. Neurobiol. 46(5), 1996.
537#[derive(Clone, Debug)]
538pub struct RenshawCell {
539    pub v: f64,
540    pub h: f64,
541    pub n: f64,
542    pub adapt: f64,
543    pub g_na: f64,
544    pub g_k: f64,
545    pub g_adapt: f64,
546    pub g_l: f64,
547    pub e_na: f64,
548    pub e_k: f64,
549    pub e_l: f64,
550    pub c_m: f64,
551    pub phi: f64,
552    pub tau_adapt: f64,
553    pub dt: f64,
554    pub v_threshold: f64,
555}
556
557impl RenshawCell {
558    pub fn new() -> Self {
559        Self {
560            v: -65.0,
561            h: 0.8,
562            n: 0.1,
563            adapt: 0.0,
564            g_na: 35.0,
565            g_k: 9.0,
566            g_adapt: 5.0,
567            g_l: 0.12,
568            e_na: 55.0,
569            e_k: -90.0,
570            e_l: -65.0,
571            c_m: 1.0,
572            phi: 5.0,
573            tau_adapt: 50.0,
574            dt: 0.01,
575            v_threshold: -20.0,
576        }
577    }
578
579    fn voltage_valid(value: f64) -> bool {
580        value.is_finite() && (-150.0..=100.0).contains(&value)
581    }
582
583    fn probability(value: f64) -> bool {
584        value.is_finite() && (0.0..=1.0).contains(&value)
585    }
586
587    fn exact_gate(previous: f64, alpha: f64, beta: f64, phi: f64, dt: f64) -> Option<f64> {
588        let total = phi * (alpha + beta);
589        if !previous.is_finite()
590            || !alpha.is_finite()
591            || !beta.is_finite()
592            || !total.is_finite()
593            || !dt.is_finite()
594            || total <= 0.0
595        {
596            return None;
597        }
598        let steady = alpha / (alpha + beta);
599        Some((steady + (previous - steady) * (-total * dt).exp()).clamp(0.0, 1.0))
600    }
601
602    fn exact_relax(previous: f64, steady: f64, tau: f64, dt: f64) -> Option<f64> {
603        if !previous.is_finite()
604            || !steady.is_finite()
605            || !tau.is_finite()
606            || !dt.is_finite()
607            || tau <= 0.0
608        {
609            return None;
610        }
611        Some((steady + (previous - steady) * (-dt / tau).exp()).clamp(0.0, 1.0))
612    }
613
614    fn valid_state(&self) -> bool {
615        Self::voltage_valid(self.v)
616            && Self::probability(self.h)
617            && Self::probability(self.n)
618            && Self::probability(self.adapt)
619            && self.g_na.is_finite()
620            && self.g_k.is_finite()
621            && self.g_adapt.is_finite()
622            && self.g_l.is_finite()
623            && self.e_na.is_finite()
624            && self.e_k.is_finite()
625            && self.e_l.is_finite()
626            && self.c_m.is_finite()
627            && self.phi.is_finite()
628            && self.tau_adapt.is_finite()
629            && self.dt.is_finite()
630            && self.v_threshold.is_finite()
631            && self.g_na >= 0.0
632            && self.g_k >= 0.0
633            && self.g_adapt >= 0.0
634            && self.g_l >= 0.0
635            && self.c_m > 0.0
636            && self.phi > 0.0
637            && self.tau_adapt > 0.0
638            && self.dt > 0.0
639    }
640
641    pub fn step(&mut self, current: f64) -> i32 {
642        if !current.is_finite() || !self.valid_state() {
643            return 0;
644        }
645
646        let v_prev = self.v;
647        let mut v = self.v;
648        let mut h = self.h;
649        let mut n = self.n;
650        let mut adapt = self.adapt;
651        let n_sub = ((0.5 / self.dt.max(0.001)).max(1.0)) as usize;
652        for _ in 0..n_sub {
653            let am = safe_rate(0.1, 35.0, v, 10.0, 1.0);
654            let bm = 4.0 * (-(v + 60.0) / 18.0).exp();
655            let m_inf = am / (am + bm);
656            let ah = 0.07 * (-(v + 58.0) / 20.0).exp();
657            let bh = 1.0 / (1.0 + (-(v + 28.0) / 10.0).exp());
658            let an = safe_rate(0.01, 34.0, v, 10.0, 0.1);
659            let bn = 0.125 * (-(v + 44.0) / 80.0).exp();
660
661            let Some(h_next) = Self::exact_gate(h, ah, bh, self.phi, self.dt) else {
662                return 0;
663            };
664            let Some(n_next) = Self::exact_gate(n, an, bn, self.phi, self.dt) else {
665                return 0;
666            };
667
668            let adapt_inf = 1.0 / (1.0 + (-(v + 30.0) / 5.0).exp());
669            let Some(adapt_next) = Self::exact_relax(adapt, adapt_inf, self.tau_adapt, self.dt)
670            else {
671                return 0;
672            };
673
674            let g_na = self.g_na * m_inf.powi(3) * h_next;
675            let g_k = self.g_k * n_next.powi(4);
676            let g_adapt = self.g_adapt * adapt_next;
677            let g_total = g_na + g_k + g_adapt + self.g_l;
678            if !g_total.is_finite() || g_total <= 0.0 {
679                return 0;
680            }
681
682            let steady_v = (current
683                + g_na * self.e_na
684                + g_k * self.e_k
685                + g_adapt * self.e_k
686                + self.g_l * self.e_l)
687                / g_total;
688            let v_next = steady_v + (v - steady_v) * (-(g_total / self.c_m) * self.dt).exp();
689            if !Self::voltage_valid(v_next)
690                || !Self::probability(h_next)
691                || !Self::probability(n_next)
692                || !Self::probability(adapt_next)
693            {
694                return 0;
695            }
696
697            v = v_next;
698            h = h_next;
699            n = n_next;
700            adapt = adapt_next;
701        }
702
703        self.v = v;
704        self.h = h;
705        self.n = n;
706        self.adapt = adapt;
707        if self.v >= self.v_threshold && v_prev < self.v_threshold {
708            1
709        } else {
710            0
711        }
712    }
713
714    pub fn reset(&mut self) {
715        self.v = -65.0;
716        self.h = 0.8;
717        self.n = 0.1;
718        self.adapt = 0.0;
719    }
720}
721
722impl Default for RenshawCell {
723    fn default() -> Self {
724        Self::new()
725    }
726}
727
728// ═══════════════════════════════════════════════════════════════════
729// Motor Unit (Alpha Motor Neuron + Muscle Fibre)
730// ═══════════════════════════════════════════════════════════════════
731
732/// Motor unit — functional unit of motor control: alpha motor neuron + muscle fibre.
733///
734/// Each spike from the embedded LIF motor neuron triggers a muscle twitch.
735/// Force output is the summation of overlapping twitches (rate coding).
736/// Higher firing rates → more twitch overlap → higher force (tetanus).
737///
738/// Muscle twitch modelled as a critically-damped second-order system:
739/// f(t) = A * (t/τ) * exp(1 - t/τ), giving a smooth rise-then-decay.
740///
741/// Fuglevand et al., J. Neurophysiol. 70(6), 1993.
742/// Heckman & Enoka, Compr. Physiol. 2(4), 2012.
743#[derive(Clone, Debug)]
744pub struct MotorUnit {
745    pub v: f64,
746    pub v_rest: f64,
747    pub v_reset: f64,
748    pub v_threshold: f64,
749    pub tau_m: f64, // Membrane time constant (ms)
750    pub adapt: f64,
751    pub tau_adapt: f64,
752    pub a_adapt: f64,
753    pub gain: f64,
754    // Muscle fibre
755    pub force: f64,       // Current force output (normalised)
756    pub twitch_amp: f64,  // Peak twitch amplitude
757    pub tau_twitch: f64,  // Twitch contraction time (ms)
758    pub force_decay: f64, // Force decay per step
759    pub dt: f64,
760}
761
762impl MotorUnit {
763    pub fn new() -> Self {
764        Self::slow()
765    }
766
767    /// Slow motor unit (type S): small, fatigue-resistant, low force.
768    pub fn slow() -> Self {
769        Self {
770            v: -65.0,
771            v_rest: -65.0,
772            v_reset: -70.0,
773            v_threshold: -50.0,
774            tau_m: 10.0,
775            adapt: 0.0,
776            tau_adapt: 100.0,
777            a_adapt: 0.2,
778            gain: 1.0,
779            force: 0.0,
780            twitch_amp: 0.05,
781            tau_twitch: 90.0,
782            force_decay: 0.0,
783            dt: 0.5,
784        }
785    }
786
787    /// Fast motor unit (type FF): large, fatigable, high force.
788    pub fn fast() -> Self {
789        Self {
790            tau_m: 6.0,
791            tau_adapt: 50.0,
792            a_adapt: 0.1,
793            twitch_amp: 0.3,
794            tau_twitch: 30.0,
795            ..Self::slow()
796        }
797    }
798
799    fn voltage_valid(value: f64) -> bool {
800        value.is_finite() && (-150.0..=100.0).contains(&value)
801    }
802
803    fn force_valid(value: f64) -> bool {
804        value.is_finite() && (0.0..=1.0).contains(&value)
805    }
806
807    fn exact_relax(previous: f64, steady: f64, tau: f64, dt: f64) -> Option<f64> {
808        if !previous.is_finite()
809            || !steady.is_finite()
810            || !tau.is_finite()
811            || !dt.is_finite()
812            || tau <= 0.0
813            || dt <= 0.0
814        {
815            return None;
816        }
817        Some(steady + (previous - steady) * (-dt / tau).exp())
818    }
819
820    fn valid_state(&self) -> bool {
821        Self::voltage_valid(self.v)
822            && Self::voltage_valid(self.v_rest)
823            && Self::voltage_valid(self.v_reset)
824            && Self::voltage_valid(self.v_threshold)
825            && Self::force_valid(self.force)
826            && self.tau_m.is_finite()
827            && self.adapt.is_finite()
828            && self.tau_adapt.is_finite()
829            && self.a_adapt.is_finite()
830            && self.gain.is_finite()
831            && self.twitch_amp.is_finite()
832            && self.tau_twitch.is_finite()
833            && self.force_decay.is_finite()
834            && self.dt.is_finite()
835            && self.tau_m > 0.0
836            && self.tau_adapt > 0.0
837            && self.tau_twitch > 0.0
838            && self.dt > 0.0
839            && self.gain >= 0.0
840            && self.twitch_amp >= 0.0
841            && self.v_reset < self.v_threshold
842    }
843
844    /// Step with descending drive (≥ 0). Returns spike (1/0). Force accessible via `.force`.
845    pub fn step(&mut self, drive: f64) -> i32 {
846        if !drive.is_finite() || !self.valid_state() {
847            return 0;
848        }
849
850        let mut force = self.force * (-self.dt / self.tau_twitch).exp();
851        let input = self.gain * drive.max(0.0) - self.adapt;
852        let v_target = self.v_rest + input;
853        let Some(mut v_candidate) = Self::exact_relax(self.v, v_target, self.tau_m, self.dt) else {
854            return 0;
855        };
856        if !Self::voltage_valid(v_candidate) {
857            return 0;
858        }
859
860        let adapt_target = self.a_adapt * (v_candidate - self.v_rest);
861        let Some(adapt_candidate) =
862            Self::exact_relax(self.adapt, adapt_target, self.tau_adapt, self.dt)
863        else {
864            return 0;
865        };
866        if !adapt_candidate.is_finite() {
867            return 0;
868        }
869
870        let mut spike = 0;
871        if v_candidate >= self.v_threshold {
872            v_candidate = self.v_reset;
873            force = (force + self.twitch_amp).min(1.0);
874            spike = 1;
875        }
876        if !Self::voltage_valid(v_candidate) || !Self::force_valid(force) {
877            return 0;
878        }
879
880        self.v = v_candidate;
881        self.adapt = adapt_candidate;
882        self.force = force;
883        spike
884    }
885
886    pub fn reset(&mut self) {
887        self.v = self.v_rest;
888        self.adapt = 0.0;
889        self.force = 0.0;
890    }
891}
892
893impl Default for MotorUnit {
894    fn default() -> Self {
895        Self::new()
896    }
897}
898
899// ═══════════════════════════════════════════════════════════════════
900// Tests
901// ═══════════════════════════════════════════════════════════════════
902
903#[cfg(test)]
904mod tests {
905    use super::*;
906
907    // ── Alpha Motor Neuron — 6-dimension coverage ──────────────────
908
909    #[test]
910    fn alpha_motor_fires_with_input() {
911        let mut n = AlphaMotorNeuron::new();
912        let spikes: i32 = (0..5000).map(|_| n.step(3.0)).sum();
913        assert!(
914            spikes > 0,
915            "alpha motor must fire with sustained input: got {spikes}"
916        );
917    }
918
919    #[test]
920    fn alpha_motor_no_fire_without_input() {
921        let mut n = AlphaMotorNeuron::new();
922        let spikes: i32 = (0..3000).map(|_| n.step(0.0)).sum();
923        assert_eq!(spikes, 0, "alpha motor should not fire at rest");
924    }
925
926    #[test]
927    fn alpha_motor_negative_current_no_fire() {
928        let mut n = AlphaMotorNeuron::new();
929        let spikes: i32 = (0..2000).map(|_| n.step(-2.0)).sum();
930        assert_eq!(spikes, 0);
931    }
932
933    #[test]
934    fn alpha_motor_ahp_limits_rate() {
935        // AHP from Ca2+-activated K+ should limit firing rate.
936        // Compare: with AHP vs without (g_ahp=0).
937        let mut with_ahp = AlphaMotorNeuron::new();
938        let mut no_ahp = AlphaMotorNeuron::new();
939        no_ahp.g_ahp = 0.0;
940        let s_ahp: i32 = (0..5000).map(|_| with_ahp.step(5.0)).sum();
941        let s_none: i32 = (0..5000).map(|_| no_ahp.step(5.0)).sum();
942        assert!(
943            s_ahp <= s_none + 5,
944            "AHP should limit rate: with={s_ahp}, without={s_none}"
945        );
946    }
947
948    #[test]
949    fn alpha_motor_pic_responds_to_depolarisation() {
950        // PIC (m_pic) should increase from baseline during sustained input.
951        let mut n = AlphaMotorNeuron::new();
952        let baseline = n.m_pic;
953        for _ in 0..2000 {
954            n.step(4.0);
955        }
956        assert!(
957            n.m_pic > baseline + 0.001,
958            "PIC should respond to depolarisation: baseline={baseline}, after={}",
959            n.m_pic
960        );
961    }
962
963    #[test]
964    fn alpha_motor_ca_increases_during_spiking() {
965        let mut n = AlphaMotorNeuron::new();
966        for _ in 0..5000 {
967            n.step(5.0);
968        }
969        assert!(
970            n.ca > 0.0,
971            "Ca2+ should accumulate during spiking: ca={}",
972            n.ca
973        );
974    }
975
976    #[test]
977    fn alpha_motor_reset_roundtrip() {
978        let mut n = AlphaMotorNeuron::new();
979        for _ in 0..2000 {
980            n.step(4.0);
981        }
982        n.reset();
983        let mut fresh = AlphaMotorNeuron::new();
984        let r1: i32 = (0..1000).map(|_| n.step(4.0)).sum();
985        let r2: i32 = (0..1000).map(|_| fresh.step(4.0)).sum();
986        assert_eq!(r1, r2, "reset neuron must match fresh");
987    }
988
989    #[test]
990    fn alpha_motor_voltage_bounded() {
991        let mut n = AlphaMotorNeuron::new();
992        for _ in 0..10000 {
993            n.step(10.0);
994        }
995        assert!(n.v.is_finite(), "voltage must stay finite");
996        assert!(n.ca.is_finite(), "Ca2+ must stay finite");
997        assert!(n.ca >= 0.0, "Ca2+ must be non-negative");
998    }
999
1000    #[test]
1001    fn alpha_motor_nan_recovery() {
1002        let mut n = AlphaMotorNeuron::new();
1003        for _ in 0..100 {
1004            n.step(3.0);
1005        }
1006        for _ in 0..10 {
1007            let _ = n.step(f64::NAN);
1008        }
1009        n.reset();
1010        assert!(n.v.is_finite());
1011        assert!(n.ca >= 0.0);
1012    }
1013
1014    #[test]
1015    fn alpha_motor_extreme_input() {
1016        let mut n = AlphaMotorNeuron::new();
1017        for _ in 0..50 {
1018            n.step(1e6);
1019        }
1020        n.reset();
1021        assert!(n.v.is_finite());
1022        for _ in 0..50 {
1023            n.step(-1e6);
1024        }
1025        n.reset();
1026        assert!(n.v.is_finite());
1027    }
1028
1029    #[test]
1030    fn alpha_motor_performance() {
1031        let mut n = AlphaMotorNeuron::new();
1032        let start = std::time::Instant::now();
1033        for _ in 0..5_000 {
1034            n.step(4.0);
1035        }
1036        assert!(
1037            start.elapsed().as_millis() < 500,
1038            "5k steps took {:?}",
1039            start.elapsed()
1040        );
1041    }
1042
1043    // ── Gamma Motor Neuron — 6-dimension coverage ──────────────────
1044
1045    #[test]
1046    fn gamma_dynamic_fires_with_drive() {
1047        let mut n = GammaMotorNeuron::dynamic();
1048        let spikes: i32 = (0..2000).map(|_| n.step(20.0)).sum();
1049        assert!(spikes > 0, "gamma dynamic must fire: got {spikes}");
1050    }
1051
1052    #[test]
1053    fn gamma_static_fires_with_drive() {
1054        let mut n = GammaMotorNeuron::static_type();
1055        let spikes: i32 = (0..2000).map(|_| n.step(20.0)).sum();
1056        assert!(spikes > 0, "gamma static must fire: got {spikes}");
1057    }
1058
1059    #[test]
1060    fn gamma_no_fire_without_drive() {
1061        let mut n = GammaMotorNeuron::new();
1062        let spikes: i32 = (0..1000).map(|_| n.step(0.0)).sum();
1063        assert_eq!(spikes, 0);
1064    }
1065
1066    #[test]
1067    fn gamma_negative_drive_no_fire() {
1068        let mut n = GammaMotorNeuron::new();
1069        // drive.max(0.0) clamps negatives
1070        let spikes: i32 = (0..1000).map(|_| n.step(-10.0)).sum();
1071        assert_eq!(spikes, 0);
1072    }
1073
1074    #[test]
1075    fn gamma_adaptation_reduces_rate() {
1076        let mut n = GammaMotorNeuron::new();
1077        let first: i32 = (0..1000).map(|_| n.step(20.0)).sum();
1078        let second: i32 = (0..1000).map(|_| n.step(20.0)).sum();
1079        assert!(
1080            second <= first + 3,
1081            "gamma should adapt: first={first}, second={second}"
1082        );
1083    }
1084
1085    #[test]
1086    fn gamma_static_adapts_more_than_dynamic() {
1087        let mut dyn_ = GammaMotorNeuron::dynamic();
1088        let mut stat = GammaMotorNeuron::static_type();
1089        let dyn_spikes: i32 = (0..2000).map(|_| dyn_.step(20.0)).sum();
1090        let stat_spikes: i32 = (0..2000).map(|_| stat.step(20.0)).sum();
1091        // Static uses larger adaptation coupling and lower excitability.
1092        assert!(
1093            stat_spikes <= dyn_spikes + 5,
1094            "static ({stat_spikes}) should fire <= dynamic ({dyn_spikes})"
1095        );
1096    }
1097
1098    #[test]
1099    fn gamma_reset_roundtrip() {
1100        let mut n = GammaMotorNeuron::new();
1101        for _ in 0..1000 {
1102            n.step(20.0);
1103        }
1104        n.reset();
1105        let mut fresh = GammaMotorNeuron::new();
1106        let r1: i32 = (0..500).map(|_| n.step(20.0)).sum();
1107        let r2: i32 = (0..500).map(|_| fresh.step(20.0)).sum();
1108        assert_eq!(r1, r2);
1109    }
1110
1111    #[test]
1112    fn gamma_voltage_bounded() {
1113        let mut n = GammaMotorNeuron::new();
1114        for _ in 0..10000 {
1115            n.step(50.0);
1116        }
1117        assert!(n.v.is_finite());
1118        assert!(n.adapt.is_finite());
1119    }
1120
1121    #[test]
1122    fn gamma_nan_recovery() {
1123        let mut n = GammaMotorNeuron::new();
1124        for _ in 0..50 {
1125            n.step(20.0);
1126        }
1127        let before_v = n.v;
1128        let before_adapt = n.adapt;
1129        for _ in 0..10 {
1130            let _ = n.step(f64::NAN);
1131        }
1132        assert_eq!(n.v, before_v);
1133        assert_eq!(n.adapt, before_adapt);
1134        n.reset();
1135        assert!(n.v.is_finite());
1136        assert_eq!(n.adapt, 0.0);
1137    }
1138
1139    #[test]
1140    fn gamma_extreme_input() {
1141        let mut n = GammaMotorNeuron::new();
1142        for _ in 0..50 {
1143            n.step(1e6);
1144        }
1145        n.reset();
1146        assert!(n.v.is_finite());
1147    }
1148
1149    #[test]
1150    fn gamma_corrupted_state_preserved_on_step() {
1151        let mut n = GammaMotorNeuron::new();
1152        n.tau = 0.0;
1153        let before_v = n.v;
1154        let before_adapt = n.adapt;
1155        assert_eq!(n.step(20.0), 0);
1156        assert_eq!(n.v, before_v);
1157        assert_eq!(n.adapt, before_adapt);
1158    }
1159
1160    #[test]
1161    fn gamma_performance() {
1162        let mut n = GammaMotorNeuron::new();
1163        let start = std::time::Instant::now();
1164        for _ in 0..100_000 {
1165            n.step(20.0);
1166        }
1167        assert!(
1168            start.elapsed().as_millis() < 50,
1169            "100k steps took {:?}",
1170            start.elapsed()
1171        );
1172    }
1173
1174    // ── Upper Motor Neuron — 6-dimension coverage ──────────────────
1175
1176    #[test]
1177    fn upper_motor_fires_with_input() {
1178        let mut n = UpperMotorNeuron::new();
1179        let spikes: i32 = (0..10000).map(|_| n.step(5.0)).sum();
1180        assert!(spikes > 0, "upper motor must fire: got {spikes}");
1181    }
1182
1183    #[test]
1184    fn upper_motor_no_fire_without_input() {
1185        let mut n = UpperMotorNeuron::new();
1186        let spikes: i32 = (0..5000).map(|_| n.step(0.0)).sum();
1187        assert_eq!(spikes, 0);
1188    }
1189
1190    fn upper_motor_reference_step(mut n: UpperMotorNeuron, current: f64) -> UpperMotorNeuron {
1191        fn gate(previous: f64, alpha: f64, beta: f64, dt: f64) -> f64 {
1192            let total = alpha + beta;
1193            let steady = alpha / total;
1194            (steady + (previous - steady) * (-total * dt).exp()).clamp(0.0, 1.0)
1195        }
1196        fn gate_inf(previous: f64, steady: f64, tau: f64, dt: f64) -> f64 {
1197            (steady + (previous - steady) * (-dt / tau).exp()).clamp(0.0, 1.0)
1198        }
1199        let vt = -56.2;
1200        for _ in 0..4 {
1201            let dv = n.v - vt;
1202            let x_m = dv - 13.0;
1203            let alpha_m = if x_m.abs() < 1e-6 {
1204                0.32 * 4.0
1205            } else {
1206                -0.32 * x_m / ((-x_m / 4.0).exp() - 1.0)
1207            };
1208            let x_bm = dv - 40.0;
1209            let beta_m = if x_bm.abs() < 1e-6 {
1210                0.28 * 5.0
1211            } else {
1212                0.28 * x_bm / ((x_bm / 5.0).exp() - 1.0)
1213            };
1214            let alpha_h = 0.128 * (-(dv - 17.0) / 18.0).exp();
1215            let beta_h = 4.0 / (1.0 + (-(dv - 40.0) / 5.0).exp());
1216            let x_n = dv - 15.0;
1217            let alpha_n = if x_n.abs() < 1e-6 {
1218                0.032 * 5.0
1219            } else {
1220                -0.032 * x_n / ((-x_n / 5.0).exp() - 1.0)
1221            };
1222            let beta_n = 0.5 * (-(dv - 10.0) / 40.0).exp();
1223
1224            n.m = gate(n.m, alpha_m, beta_m, n.dt);
1225            n.h = gate(n.h, alpha_h, beta_h, n.dt);
1226            n.n = gate(n.n, alpha_n, beta_n, n.dt);
1227
1228            let p_inf = 1.0 / (1.0 + (-(n.v + 35.0) / 10.0).exp());
1229            let tau_p = 400.0 / (3.3 * ((n.v + 35.0) / 20.0).exp() + (-(n.v + 35.0) / 20.0).exp());
1230            n.p = gate_inf(n.p, p_inf, tau_p, n.dt);
1231
1232            let s_inf = 1.0 / (1.0 + (-(n.v + 20.0) / 5.0).exp());
1233            n.s = gate_inf(n.s, s_inf, 10.0, n.dt);
1234
1235            let g_na = n.g_na * n.m.powi(3) * n.h;
1236            let g_k = n.g_k * n.n.powi(4);
1237            let g_m = n.g_m * n.p;
1238            let g_ca = n.g_ca * n.s.powi(2);
1239            let g_total = g_na + g_k + g_m + g_ca + n.g_l;
1240            let steady_v = (current
1241                + g_na * n.e_na
1242                + g_k * n.e_k
1243                + g_m * n.e_k
1244                + g_ca * n.e_ca
1245                + n.g_l * n.e_l)
1246                / g_total;
1247            n.v = steady_v + (n.v - steady_v) * (-(g_total / n.c_m) * n.dt).exp();
1248        }
1249        n
1250    }
1251
1252    #[test]
1253    fn upper_motor_uses_exact_gate_and_conductance_membrane_step() {
1254        let mut n = UpperMotorNeuron::new();
1255        let expected = upper_motor_reference_step(n.clone(), 5.0);
1256
1257        assert_eq!(n.step(5.0), 0);
1258
1259        assert!((n.v - expected.v).abs() <= 1e-12);
1260        assert!((n.m - expected.m).abs() <= 1e-12);
1261        assert!((n.h - expected.h).abs() <= 1e-12);
1262        assert!((n.n - expected.n).abs() <= 1e-12);
1263        assert!((n.p - expected.p).abs() <= 1e-12);
1264        assert!((n.s - expected.s).abs() <= 1e-12);
1265    }
1266
1267    #[test]
1268    fn upper_motor_corrupted_gate_is_preserved_on_step() {
1269        let mut n = UpperMotorNeuron::new();
1270        n.m = 1.5;
1271        let before = n.clone();
1272
1273        assert_eq!(n.step(5.0), 0);
1274
1275        assert_eq!(n.v, before.v);
1276        assert_eq!(n.m, before.m);
1277        assert_eq!(n.h, before.h);
1278        assert_eq!(n.n, before.n);
1279        assert_eq!(n.p, before.p);
1280        assert_eq!(n.s, before.s);
1281    }
1282
1283    #[test]
1284    fn upper_motor_negative_current_no_fire() {
1285        let mut n = UpperMotorNeuron::new();
1286        let spikes: i32 = (0..2000).map(|_| n.step(-5.0)).sum();
1287        assert_eq!(spikes, 0);
1288    }
1289
1290    #[test]
1291    fn upper_motor_adaptation_via_m_current() {
1292        let mut n = UpperMotorNeuron::new();
1293        let first: i32 = (0..5000).map(|_| n.step(5.0)).sum();
1294        let second: i32 = (0..5000).map(|_| n.step(5.0)).sum();
1295        assert!(
1296            second <= first + 3,
1297            "M-current should cause adaptation: first={first}, second={second}"
1298        );
1299    }
1300
1301    #[test]
1302    fn upper_motor_ca_activates_during_depolarisation() {
1303        let mut n = UpperMotorNeuron::new();
1304        let baseline = n.s;
1305        for _ in 0..5000 {
1306            n.step(5.0);
1307        }
1308        assert!(
1309            n.s > baseline + 0.001,
1310            "Ca2+ gate should activate: s={}",
1311            n.s
1312        );
1313    }
1314
1315    #[test]
1316    fn upper_motor_reset_roundtrip() {
1317        let mut n = UpperMotorNeuron::new();
1318        for _ in 0..3000 {
1319            n.step(5.0);
1320        }
1321        n.reset();
1322        let mut fresh = UpperMotorNeuron::new();
1323        let r1: i32 = (0..2000).map(|_| n.step(5.0)).sum();
1324        let r2: i32 = (0..2000).map(|_| fresh.step(5.0)).sum();
1325        assert_eq!(r1, r2);
1326    }
1327
1328    #[test]
1329    fn upper_motor_voltage_bounded() {
1330        let mut n = UpperMotorNeuron::new();
1331        for _ in 0..20000 {
1332            n.step(10.0);
1333        }
1334        assert!(n.v.is_finite());
1335        assert!(n.p.is_finite());
1336        assert!(n.s.is_finite());
1337    }
1338
1339    #[test]
1340    fn upper_motor_nan_recovery() {
1341        let mut n = UpperMotorNeuron::new();
1342        for _ in 0..100 {
1343            n.step(5.0);
1344        }
1345        for _ in 0..10 {
1346            let _ = n.step(f64::NAN);
1347        }
1348        n.reset();
1349        assert!(n.v.is_finite());
1350    }
1351
1352    #[test]
1353    fn upper_motor_extreme_input() {
1354        let mut n = UpperMotorNeuron::new();
1355        for _ in 0..50 {
1356            n.step(1e6);
1357        }
1358        n.reset();
1359        assert!(n.v.is_finite());
1360    }
1361
1362    #[test]
1363    fn upper_motor_performance() {
1364        let mut n = UpperMotorNeuron::new();
1365        let start = std::time::Instant::now();
1366        for _ in 0..10_000 {
1367            n.step(5.0);
1368        }
1369        assert!(
1370            start.elapsed().as_millis() < 100,
1371            "10k steps took {:?}",
1372            start.elapsed()
1373        );
1374    }
1375
1376    // ── Renshaw Cell — 6-dimension coverage ────────────────────────
1377
1378    #[test]
1379    fn renshaw_fires_with_input() {
1380        let mut n = RenshawCell::new();
1381        let spikes: i32 = (0..5000).map(|_| n.step(3.0)).sum();
1382        assert!(spikes > 0, "Renshaw must fire: got {spikes}");
1383    }
1384
1385    #[test]
1386    fn renshaw_no_fire_without_input() {
1387        let mut n = RenshawCell::new();
1388        let spikes: i32 = (0..3000).map(|_| n.step(0.0)).sum();
1389        assert_eq!(spikes, 0);
1390    }
1391
1392    #[test]
1393    fn renshaw_negative_current_no_fire() {
1394        let mut n = RenshawCell::new();
1395        let spikes: i32 = (0..2000).map(|_| n.step(-2.0)).sum();
1396        assert_eq!(spikes, 0);
1397    }
1398
1399    fn renshaw_test_gate(previous: f64, alpha: f64, beta: f64, phi: f64, dt: f64) -> f64 {
1400        let total = phi * (alpha + beta);
1401        let steady = alpha / (alpha + beta);
1402        (steady + (previous - steady) * (-total * dt).exp()).clamp(0.0, 1.0)
1403    }
1404
1405    fn renshaw_test_adapt(previous: f64, steady: f64, tau: f64, dt: f64) -> f64 {
1406        (steady + (previous - steady) * (-dt / tau).exp()).clamp(0.0, 1.0)
1407    }
1408
1409    fn renshaw_reference_step(mut n: RenshawCell, current: f64) -> RenshawCell {
1410        let n_sub = ((0.5 / n.dt.max(0.001)).max(1.0)) as usize;
1411        for _ in 0..n_sub {
1412            let am = safe_rate(0.1, 35.0, n.v, 10.0, 1.0);
1413            let bm = 4.0 * (-(n.v + 60.0) / 18.0).exp();
1414            let ah = 0.07 * (-(n.v + 58.0) / 20.0).exp();
1415            let bh = 1.0 / (1.0 + (-(n.v + 28.0) / 10.0).exp());
1416            let an = safe_rate(0.01, 34.0, n.v, 10.0, 0.1);
1417            let bn = 0.125 * (-(n.v + 44.0) / 80.0).exp();
1418            let m_inf = am / (am + bm);
1419
1420            n.h = renshaw_test_gate(n.h, ah, bh, n.phi, n.dt);
1421            n.n = renshaw_test_gate(n.n, an, bn, n.phi, n.dt);
1422            let adapt_inf = 1.0 / (1.0 + (-(n.v + 30.0) / 5.0).exp());
1423            n.adapt = renshaw_test_adapt(n.adapt, adapt_inf, n.tau_adapt, n.dt);
1424
1425            let g_na = n.g_na * m_inf.powi(3) * n.h;
1426            let g_k = n.g_k * n.n.powi(4);
1427            let g_adapt = n.g_adapt * n.adapt;
1428            let g_total = g_na + g_k + g_adapt + n.g_l;
1429            let steady_v =
1430                (current + g_na * n.e_na + g_k * n.e_k + g_adapt * n.e_k + n.g_l * n.e_l) / g_total;
1431            n.v = steady_v + (n.v - steady_v) * (-(g_total / n.c_m) * n.dt).exp();
1432        }
1433        n
1434    }
1435
1436    fn renshaw_snapshot(n: &RenshawCell) -> (f64, f64, f64, f64) {
1437        (n.v, n.h, n.n, n.adapt)
1438    }
1439
1440    #[test]
1441    fn renshaw_uses_exact_gate_and_conductance_membrane_step() {
1442        let mut n = RenshawCell::new();
1443        let expected = renshaw_reference_step(RenshawCell::new(), 4.0);
1444
1445        assert_eq!(n.step(4.0), 0);
1446
1447        assert!((n.v - expected.v).abs() <= 1e-12);
1448        assert!((n.h - expected.h).abs() <= 1e-12);
1449        assert!((n.n - expected.n).abs() <= 1e-12);
1450        assert!((n.adapt - expected.adapt).abs() <= 1e-12);
1451    }
1452
1453    #[test]
1454    fn renshaw_rejects_invalid_current_without_state_mutation() {
1455        let mut n = RenshawCell::new();
1456        for _ in 0..20 {
1457            n.step(4.0);
1458        }
1459        let before = renshaw_snapshot(&n);
1460
1461        assert_eq!(n.step(f64::NAN), 0);
1462        assert_eq!(renshaw_snapshot(&n), before);
1463        assert_eq!(n.step(f64::INFINITY), 0);
1464        assert_eq!(renshaw_snapshot(&n), before);
1465    }
1466
1467    #[test]
1468    fn renshaw_rejects_excess_current_without_state_mutation() {
1469        let mut n = RenshawCell::new();
1470        let before = renshaw_snapshot(&n);
1471
1472        assert_eq!(n.step(1.0e8), 0);
1473
1474        assert_eq!(renshaw_snapshot(&n), before);
1475    }
1476
1477    #[test]
1478    fn renshaw_corrupted_gate_is_preserved_on_step() {
1479        let mut n = RenshawCell::new();
1480        n.h = 1.5;
1481        let before = renshaw_snapshot(&n);
1482
1483        assert_eq!(n.step(4.0), 0);
1484
1485        assert_eq!(renshaw_snapshot(&n), before);
1486    }
1487
1488    #[test]
1489    fn renshaw_burst_then_adapt() {
1490        // Renshaw should fire more in the first epoch than the second
1491        let mut n = RenshawCell::new();
1492        let first: i32 = (0..2000).map(|_| n.step(4.0)).sum();
1493        let second: i32 = (0..2000).map(|_| n.step(4.0)).sum();
1494        assert!(
1495            second <= first + 5,
1496            "Renshaw should adapt: first={first}, second={second}"
1497        );
1498    }
1499
1500    #[test]
1501    fn renshaw_adapt_increases_during_firing() {
1502        let mut n = RenshawCell::new();
1503        let baseline = n.adapt;
1504        for _ in 0..3000 {
1505            n.step(4.0);
1506        }
1507        assert!(
1508            n.adapt > baseline + 0.01,
1509            "adaptation variable should increase: adapt={}",
1510            n.adapt
1511        );
1512    }
1513
1514    #[test]
1515    fn renshaw_reset_roundtrip() {
1516        let mut n = RenshawCell::new();
1517        for _ in 0..2000 {
1518            n.step(4.0);
1519        }
1520        n.reset();
1521        let mut fresh = RenshawCell::new();
1522        let r1: i32 = (0..1000).map(|_| n.step(4.0)).sum();
1523        let r2: i32 = (0..1000).map(|_| fresh.step(4.0)).sum();
1524        assert_eq!(r1, r2);
1525    }
1526
1527    #[test]
1528    fn renshaw_voltage_bounded() {
1529        let mut n = RenshawCell::new();
1530        for _ in 0..10000 {
1531            n.step(10.0);
1532        }
1533        assert!(n.v.is_finite());
1534        assert!(n.adapt.is_finite());
1535    }
1536
1537    #[test]
1538    fn renshaw_nan_recovery() {
1539        let mut n = RenshawCell::new();
1540        for _ in 0..100 {
1541            n.step(3.0);
1542        }
1543        for _ in 0..10 {
1544            let _ = n.step(f64::NAN);
1545        }
1546        n.reset();
1547        assert!(n.v.is_finite());
1548        assert_eq!(n.adapt, 0.0);
1549    }
1550
1551    #[test]
1552    fn renshaw_extreme_input() {
1553        let mut n = RenshawCell::new();
1554        for _ in 0..50 {
1555            n.step(1e6);
1556        }
1557        n.reset();
1558        assert!(n.v.is_finite());
1559    }
1560
1561    #[test]
1562    fn renshaw_performance() {
1563        let mut n = RenshawCell::new();
1564        let start = std::time::Instant::now();
1565        for _ in 0..5_000 {
1566            n.step(4.0);
1567        }
1568        assert!(
1569            start.elapsed().as_millis() < 500,
1570            "5k steps took {:?}",
1571            start.elapsed()
1572        );
1573    }
1574
1575    // ── Motor Unit — 6-dimension coverage ──────────────────────────
1576
1577    #[test]
1578    fn motor_unit_fires_with_drive() {
1579        let mut mu = MotorUnit::new();
1580        let spikes: i32 = (0..2000).map(|_| mu.step(20.0)).sum();
1581        assert!(spikes > 0, "motor unit must fire: got {spikes}");
1582    }
1583
1584    #[test]
1585    fn motor_unit_no_fire_without_drive() {
1586        let mut mu = MotorUnit::new();
1587        let spikes: i32 = (0..1000).map(|_| mu.step(0.0)).sum();
1588        assert_eq!(spikes, 0);
1589    }
1590
1591    #[test]
1592    fn motor_unit_negative_drive_no_fire() {
1593        let mut mu = MotorUnit::new();
1594        let spikes: i32 = (0..1000).map(|_| mu.step(-10.0)).sum();
1595        assert_eq!(spikes, 0);
1596    }
1597
1598    #[test]
1599    fn motor_unit_force_increases_with_spikes() {
1600        let mut mu = MotorUnit::new();
1601        assert_eq!(mu.force, 0.0);
1602        for _ in 0..2000 {
1603            mu.step(20.0);
1604        }
1605        assert!(
1606            mu.force > 0.0,
1607            "force should increase during spiking: f={}",
1608            mu.force
1609        );
1610    }
1611
1612    #[test]
1613    fn motor_unit_force_decays_without_input() {
1614        let mut mu = MotorUnit::new();
1615        // Build up force
1616        for _ in 0..1000 {
1617            mu.step(20.0);
1618        }
1619        let peak = mu.force;
1620        assert!(peak > 0.0);
1621        // No input → force decays
1622        for _ in 0..5000 {
1623            mu.step(0.0);
1624        }
1625        assert!(
1626            mu.force < peak,
1627            "force should decay: peak={peak}, now={}",
1628            mu.force
1629        );
1630    }
1631
1632    #[test]
1633    fn motor_unit_fast_produces_more_force() {
1634        let mut slow = MotorUnit::slow();
1635        let mut fast = MotorUnit::fast();
1636        for _ in 0..2000 {
1637            slow.step(20.0);
1638            fast.step(20.0);
1639        }
1640        assert!(
1641            fast.force >= slow.force,
1642            "fast MU ({}) should produce >= force than slow ({})",
1643            fast.force,
1644            slow.force
1645        );
1646    }
1647
1648    #[test]
1649    fn motor_unit_force_capped_at_one() {
1650        let mut mu = MotorUnit::fast();
1651        for _ in 0..10000 {
1652            mu.step(50.0);
1653        }
1654        assert!(mu.force <= 1.0, "force must not exceed 1.0: f={}", mu.force);
1655    }
1656
1657    #[test]
1658    fn motor_unit_reset_roundtrip() {
1659        let mut mu = MotorUnit::new();
1660        for _ in 0..1000 {
1661            mu.step(20.0);
1662        }
1663        mu.reset();
1664        assert_eq!(mu.force, 0.0);
1665        assert_eq!(mu.adapt, 0.0);
1666        let mut fresh = MotorUnit::new();
1667        let r1: i32 = (0..500).map(|_| mu.step(20.0)).sum();
1668        let r2: i32 = (0..500).map(|_| fresh.step(20.0)).sum();
1669        assert_eq!(r1, r2);
1670    }
1671
1672    #[test]
1673    fn motor_unit_voltage_bounded() {
1674        let mut mu = MotorUnit::new();
1675        for _ in 0..10000 {
1676            mu.step(50.0);
1677        }
1678        assert!(mu.v.is_finite());
1679        assert!(mu.force.is_finite());
1680    }
1681
1682    #[test]
1683    fn motor_unit_nan_recovery() {
1684        let mut mu = MotorUnit::new();
1685        for _ in 0..50 {
1686            mu.step(20.0);
1687        }
1688        for _ in 0..10 {
1689            let _ = mu.step(f64::NAN);
1690        }
1691        mu.reset();
1692        assert!(mu.v.is_finite());
1693        assert_eq!(mu.force, 0.0);
1694    }
1695
1696    #[test]
1697    fn motor_unit_performance() {
1698        let mut mu = MotorUnit::new();
1699        let start = std::time::Instant::now();
1700        for _ in 0..100_000 {
1701            mu.step(20.0);
1702        }
1703        let elapsed = start.elapsed();
1704        assert!(elapsed.as_millis() < 100, "100k steps took {:?}", elapsed);
1705    }
1706}