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, // Stronger adaptation (lower steady-state rate)
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        let input = self.gain * drive.max(0.0) - self.adapt;
237        self.v += (-(self.v - self.v_rest) + input) / self.tau * self.dt;
238        self.adapt +=
239            (self.a_adapt * (self.v - self.v_rest) - self.adapt) / self.tau_adapt * self.dt;
240
241        if self.v >= self.v_threshold {
242            self.v = self.v_reset;
243            1
244        } else {
245            0
246        }
247    }
248
249    pub fn reset(&mut self) {
250        self.v = self.v_rest;
251        self.adapt = 0.0;
252    }
253}
254
255impl Default for GammaMotorNeuron {
256    fn default() -> Self {
257        Self::new()
258    }
259}
260
261// ═══════════════════════════════════════════════════════════════════
262// Upper Motor Neuron (Corticospinal L5 Pyramidal)
263// ═══════════════════════════════════════════════════════════════════
264
265/// Upper motor neuron — layer 5 pyramidal cell, corticospinal projection.
266///
267/// Biophysics: Pospischil 2008 RS parameterisation (Na+, K+, M-current)
268/// with added high-threshold Ca2+ current for dendritic Ca2+ spikes.
269/// Regular-spiking with adaptation. Drives alpha/gamma motor neurons
270/// via corticospinal tract.
271///
272/// Pospischil et al., Biol. Cybern. 99(4-5), 2008 (RS variant).
273/// Larkum, Trends Neurosci. 36(3), 2013 (dendritic Ca2+ spikes).
274#[derive(Clone, Debug)]
275pub struct UpperMotorNeuron {
276    pub v: f64,
277    pub m: f64,
278    pub h: f64,
279    pub n: f64,
280    pub p: f64, // M-current (Kv7) activation
281    pub s: f64, // High-threshold Ca2+ activation
282    // Conductances
283    pub g_na: f64,
284    pub g_k: f64,
285    pub g_m: f64,
286    pub g_ca: f64,
287    pub g_l: f64,
288    // Reversal potentials
289    pub e_na: f64,
290    pub e_k: f64,
291    pub e_ca: f64,
292    pub e_l: f64,
293    pub c_m: f64,
294    pub dt: f64,
295    pub v_threshold: f64,
296}
297
298impl UpperMotorNeuron {
299    pub fn new() -> Self {
300        Self {
301            v: -70.0,
302            m: 0.05,
303            h: 0.6,
304            n: 0.3,
305            p: 0.0,
306            s: 0.0,
307            g_na: 50.0,
308            g_k: 5.0,
309            g_m: 0.07, // M-current for adaptation (Pospischil RS)
310            g_ca: 0.3, // High-threshold dendritic Ca2+
311            g_l: 0.1,
312            e_na: 50.0,
313            e_k: -90.0,
314            e_ca: 120.0,
315            e_l: -70.0,
316            c_m: 1.0,
317            dt: 0.025,
318            v_threshold: -20.0,
319        }
320    }
321
322    pub fn step(&mut self, current: f64) -> i32 {
323        let v_prev = self.v;
324        let vt = -56.2;
325        for _ in 0..4 {
326            // Pospischil Na+ gating
327            let dv = self.v - vt;
328            let x_m = dv - 13.0;
329            let alpha_m = if x_m.abs() < 1e-6 {
330                0.32 * 4.0
331            } else {
332                -0.32 * x_m / ((-x_m / 4.0).exp() - 1.0)
333            };
334            let x_h = dv - 17.0;
335            let beta_m = if x_h.abs() < 1e-6 {
336                0.28 * 5.0
337            } else {
338                0.28 * x_h / ((x_h / 5.0).exp() - 1.0)
339            };
340            let alpha_h = 0.128 * (-(dv - 17.0) / 18.0).exp();
341            let beta_h = 4.0 / (1.0 + (-(dv - 40.0) / 5.0).exp());
342            // K+ gating
343            let x_n = dv - 15.0;
344            let alpha_n = if x_n.abs() < 1e-6 {
345                0.032 * 5.0
346            } else {
347                -0.032 * x_n / ((-x_n / 5.0).exp() - 1.0)
348            };
349            let beta_n = 0.5 * (-(dv - 10.0) / 40.0).exp();
350
351            self.m += (alpha_m * (1.0 - self.m) - beta_m * self.m) * self.dt;
352            self.h += (alpha_h * (1.0 - self.h) - beta_h * self.h) * self.dt;
353            self.n += (alpha_n * (1.0 - self.n) - beta_n * self.n) * self.dt;
354
355            // M-current (slow K+, adaptation)
356            let p_inf = 1.0 / (1.0 + (-(self.v + 35.0) / 10.0).exp());
357            let tau_p =
358                400.0 / (3.3 * ((self.v + 35.0) / 20.0).exp() + (-(self.v + 35.0) / 20.0).exp());
359            self.p += (p_inf - self.p) / tau_p * self.dt;
360
361            // High-threshold Ca2+ (dendritic spike)
362            let s_inf = 1.0 / (1.0 + (-(self.v + 20.0) / 5.0).exp());
363            self.s += (s_inf - self.s) / 10.0 * self.dt;
364
365            let i_na = self.g_na * self.m.powi(3) * self.h * (self.v - self.e_na);
366            let i_k = self.g_k * self.n.powi(4) * (self.v - self.e_k);
367            let i_m = self.g_m * self.p * (self.v - self.e_k);
368            let i_ca = self.g_ca * self.s.powi(2) * (self.v - self.e_ca);
369            let i_l = self.g_l * (self.v - self.e_l);
370
371            self.v += (-i_na - i_k - i_m - i_ca - i_l + current) / self.c_m * self.dt;
372        }
373        if self.v >= self.v_threshold && v_prev < self.v_threshold {
374            1
375        } else {
376            0
377        }
378    }
379
380    pub fn reset(&mut self) {
381        self.v = -70.0;
382        self.m = 0.05;
383        self.h = 0.6;
384        self.n = 0.3;
385        self.p = 0.0;
386        self.s = 0.0;
387    }
388}
389
390impl Default for UpperMotorNeuron {
391    fn default() -> Self {
392        Self::new()
393    }
394}
395
396// ═══════════════════════════════════════════════════════════════════
397// Renshaw Cell (Spinal Inhibitory Interneuron)
398// ═══════════════════════════════════════════════════════════════════
399
400/// Renshaw cell — spinal inhibitory interneuron for recurrent inhibition.
401///
402/// Receives collaterals from alpha motor neuron axons, provides
403/// glycinergic recurrent inhibition back to the motor pool. Characteristic
404/// high-frequency initial burst (cholinergic nicotinic drive from motor
405/// axon collaterals) followed by rapid adaptation.
406///
407/// WB gating core with strong adaptation (M-current analogue) to produce
408/// the burst-then-decay response pattern.
409///
410/// Renshaw 1941 (discovery); Windhorst, Prog. Neurobiol. 46(5), 1996.
411#[derive(Clone, Debug)]
412pub struct RenshawCell {
413    pub v: f64,
414    pub h: f64,
415    pub n: f64,
416    pub adapt: f64,
417    pub g_na: f64,
418    pub g_k: f64,
419    pub g_adapt: f64,
420    pub g_l: f64,
421    pub e_na: f64,
422    pub e_k: f64,
423    pub e_l: f64,
424    pub c_m: f64,
425    pub phi: f64,
426    pub tau_adapt: f64,
427    pub dt: f64,
428    pub v_threshold: f64,
429}
430
431impl RenshawCell {
432    pub fn new() -> Self {
433        Self {
434            v: -65.0,
435            h: 0.8,
436            n: 0.1,
437            adapt: 0.0,
438            g_na: 35.0,
439            g_k: 9.0,
440            g_adapt: 5.0,
441            g_l: 0.12,
442            e_na: 55.0,
443            e_k: -90.0,
444            e_l: -65.0,
445            c_m: 1.0,
446            phi: 5.0,
447            tau_adapt: 50.0,
448            dt: 0.01,
449            v_threshold: -20.0,
450        }
451    }
452
453    pub fn step(&mut self, current: f64) -> i32 {
454        let v_prev = self.v;
455        let n_sub = (0.5 / self.dt.max(0.001)) as usize;
456        for _ in 0..n_sub {
457            let am = safe_rate(0.1, 35.0, self.v, 10.0, 1.0);
458            let bm = 4.0 * (-(self.v + 60.0) / 18.0).exp();
459            let m_inf = am / (am + bm);
460            let ah = 0.07 * (-(self.v + 58.0) / 20.0).exp();
461            let bh = 1.0 / (1.0 + (-(self.v + 28.0) / 10.0).exp());
462            let an = safe_rate(0.01, 34.0, self.v, 10.0, 0.1);
463            let bn = 0.125 * (-(self.v + 44.0) / 80.0).exp();
464
465            self.h += self.phi * (ah * (1.0 - self.h) - bh * self.h) * self.dt;
466            self.n += self.phi * (an * (1.0 - self.n) - bn * self.n) * self.dt;
467
468            let adapt_inf = 1.0 / (1.0 + (-(self.v + 30.0) / 5.0).exp());
469            self.adapt += (adapt_inf - self.adapt) / self.tau_adapt * self.dt;
470
471            let i_na = self.g_na * m_inf.powi(3) * self.h * (self.v - self.e_na);
472            let i_k = self.g_k * self.n.powi(4) * (self.v - self.e_k);
473            let i_adapt = self.g_adapt * self.adapt * (self.v - self.e_k);
474            let i_l = self.g_l * (self.v - self.e_l);
475
476            self.v += (-i_na - i_k - i_adapt - i_l + current) / self.c_m * self.dt;
477        }
478        if self.v >= self.v_threshold && v_prev < self.v_threshold {
479            1
480        } else {
481            0
482        }
483    }
484
485    pub fn reset(&mut self) {
486        self.v = -65.0;
487        self.h = 0.8;
488        self.n = 0.1;
489        self.adapt = 0.0;
490    }
491}
492
493impl Default for RenshawCell {
494    fn default() -> Self {
495        Self::new()
496    }
497}
498
499// ═══════════════════════════════════════════════════════════════════
500// Motor Unit (Alpha Motor Neuron + Muscle Fibre)
501// ═══════════════════════════════════════════════════════════════════
502
503/// Motor unit — functional unit of motor control: alpha motor neuron + muscle fibre.
504///
505/// Each spike from the embedded LIF motor neuron triggers a muscle twitch.
506/// Force output is the summation of overlapping twitches (rate coding).
507/// Higher firing rates → more twitch overlap → higher force (tetanus).
508///
509/// Muscle twitch modelled as a critically-damped second-order system:
510/// f(t) = A * (t/τ) * exp(1 - t/τ), giving a smooth rise-then-decay.
511///
512/// Fuglevand et al., J. Neurophysiol. 70(6), 1993.
513/// Heckman & Enoka, Compr. Physiol. 2(4), 2012.
514#[derive(Clone, Debug)]
515pub struct MotorUnit {
516    pub v: f64,
517    pub v_rest: f64,
518    pub v_reset: f64,
519    pub v_threshold: f64,
520    pub tau_m: f64, // Membrane time constant (ms)
521    pub adapt: f64,
522    pub tau_adapt: f64,
523    pub a_adapt: f64,
524    pub gain: f64,
525    // Muscle fibre
526    pub force: f64,       // Current force output (normalised)
527    pub twitch_amp: f64,  // Peak twitch amplitude
528    pub tau_twitch: f64,  // Twitch contraction time (ms)
529    pub force_decay: f64, // Force decay per step
530    pub dt: f64,
531}
532
533impl MotorUnit {
534    pub fn new() -> Self {
535        Self::slow()
536    }
537
538    /// Slow motor unit (type S): small, fatigue-resistant, low force.
539    pub fn slow() -> Self {
540        Self {
541            v: -65.0,
542            v_rest: -65.0,
543            v_reset: -70.0,
544            v_threshold: -50.0,
545            tau_m: 10.0,
546            adapt: 0.0,
547            tau_adapt: 100.0,
548            a_adapt: 0.2,
549            gain: 1.0,
550            force: 0.0,
551            twitch_amp: 0.05,
552            tau_twitch: 90.0,
553            force_decay: 0.0,
554            dt: 0.5,
555        }
556    }
557
558    /// Fast motor unit (type FF): large, fatigable, high force.
559    pub fn fast() -> Self {
560        Self {
561            tau_m: 6.0,
562            tau_adapt: 50.0,
563            a_adapt: 0.1,
564            twitch_amp: 0.3,
565            tau_twitch: 30.0,
566            ..Self::slow()
567        }
568    }
569
570    /// Step with descending drive (≥ 0). Returns spike (1/0). Force accessible via `.force`.
571    pub fn step(&mut self, drive: f64) -> i32 {
572        let input = self.gain * drive.max(0.0) - self.adapt;
573        self.v += (-(self.v - self.v_rest) + input) / self.tau_m * self.dt;
574        self.adapt +=
575            (self.a_adapt * (self.v - self.v_rest) - self.adapt) / self.tau_adapt * self.dt;
576
577        // Force decay: exponential relaxation
578        self.force *= (-self.dt / self.tau_twitch).exp();
579
580        if self.v >= self.v_threshold {
581            self.v = self.v_reset;
582            // Spike → muscle twitch (add to force)
583            self.force += self.twitch_amp;
584            if self.force > 1.0 {
585                self.force = 1.0;
586            }
587            1
588        } else {
589            0
590        }
591    }
592
593    pub fn reset(&mut self) {
594        self.v = self.v_rest;
595        self.adapt = 0.0;
596        self.force = 0.0;
597    }
598}
599
600impl Default for MotorUnit {
601    fn default() -> Self {
602        Self::new()
603    }
604}
605
606// ═══════════════════════════════════════════════════════════════════
607// Tests
608// ═══════════════════════════════════════════════════════════════════
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613
614    // ── Alpha Motor Neuron — 6-dimension coverage ──────────────────
615
616    #[test]
617    fn alpha_motor_fires_with_input() {
618        let mut n = AlphaMotorNeuron::new();
619        let spikes: i32 = (0..5000).map(|_| n.step(3.0)).sum();
620        assert!(
621            spikes > 0,
622            "alpha motor must fire with sustained input: got {spikes}"
623        );
624    }
625
626    #[test]
627    fn alpha_motor_no_fire_without_input() {
628        let mut n = AlphaMotorNeuron::new();
629        let spikes: i32 = (0..3000).map(|_| n.step(0.0)).sum();
630        assert_eq!(spikes, 0, "alpha motor should not fire at rest");
631    }
632
633    #[test]
634    fn alpha_motor_negative_current_no_fire() {
635        let mut n = AlphaMotorNeuron::new();
636        let spikes: i32 = (0..2000).map(|_| n.step(-2.0)).sum();
637        assert_eq!(spikes, 0);
638    }
639
640    #[test]
641    fn alpha_motor_ahp_limits_rate() {
642        // AHP from Ca2+-activated K+ should limit firing rate.
643        // Compare: with AHP vs without (g_ahp=0).
644        let mut with_ahp = AlphaMotorNeuron::new();
645        let mut no_ahp = AlphaMotorNeuron::new();
646        no_ahp.g_ahp = 0.0;
647        let s_ahp: i32 = (0..5000).map(|_| with_ahp.step(5.0)).sum();
648        let s_none: i32 = (0..5000).map(|_| no_ahp.step(5.0)).sum();
649        assert!(
650            s_ahp <= s_none + 5,
651            "AHP should limit rate: with={s_ahp}, without={s_none}"
652        );
653    }
654
655    #[test]
656    fn alpha_motor_pic_responds_to_depolarisation() {
657        // PIC (m_pic) should increase from baseline during sustained input.
658        let mut n = AlphaMotorNeuron::new();
659        let baseline = n.m_pic;
660        for _ in 0..2000 {
661            n.step(4.0);
662        }
663        assert!(
664            n.m_pic > baseline + 0.001,
665            "PIC should respond to depolarisation: baseline={baseline}, after={}",
666            n.m_pic
667        );
668    }
669
670    #[test]
671    fn alpha_motor_ca_increases_during_spiking() {
672        let mut n = AlphaMotorNeuron::new();
673        for _ in 0..5000 {
674            n.step(5.0);
675        }
676        assert!(
677            n.ca > 0.0,
678            "Ca2+ should accumulate during spiking: ca={}",
679            n.ca
680        );
681    }
682
683    #[test]
684    fn alpha_motor_reset_roundtrip() {
685        let mut n = AlphaMotorNeuron::new();
686        for _ in 0..2000 {
687            n.step(4.0);
688        }
689        n.reset();
690        let mut fresh = AlphaMotorNeuron::new();
691        let r1: i32 = (0..1000).map(|_| n.step(4.0)).sum();
692        let r2: i32 = (0..1000).map(|_| fresh.step(4.0)).sum();
693        assert_eq!(r1, r2, "reset neuron must match fresh");
694    }
695
696    #[test]
697    fn alpha_motor_voltage_bounded() {
698        let mut n = AlphaMotorNeuron::new();
699        for _ in 0..10000 {
700            n.step(10.0);
701        }
702        assert!(n.v.is_finite(), "voltage must stay finite");
703        assert!(n.ca.is_finite(), "Ca2+ must stay finite");
704        assert!(n.ca >= 0.0, "Ca2+ must be non-negative");
705    }
706
707    #[test]
708    fn alpha_motor_nan_recovery() {
709        let mut n = AlphaMotorNeuron::new();
710        for _ in 0..100 {
711            n.step(3.0);
712        }
713        for _ in 0..10 {
714            let _ = n.step(f64::NAN);
715        }
716        n.reset();
717        assert!(n.v.is_finite());
718        assert!(n.ca >= 0.0);
719    }
720
721    #[test]
722    fn alpha_motor_extreme_input() {
723        let mut n = AlphaMotorNeuron::new();
724        for _ in 0..50 {
725            n.step(1e6);
726        }
727        n.reset();
728        assert!(n.v.is_finite());
729        for _ in 0..50 {
730            n.step(-1e6);
731        }
732        n.reset();
733        assert!(n.v.is_finite());
734    }
735
736    #[test]
737    fn alpha_motor_performance() {
738        let mut n = AlphaMotorNeuron::new();
739        let start = std::time::Instant::now();
740        for _ in 0..5_000 {
741            n.step(4.0);
742        }
743        assert!(
744            start.elapsed().as_millis() < 500,
745            "5k steps took {:?}",
746            start.elapsed()
747        );
748    }
749
750    // ── Gamma Motor Neuron — 6-dimension coverage ──────────────────
751
752    #[test]
753    fn gamma_dynamic_fires_with_drive() {
754        let mut n = GammaMotorNeuron::dynamic();
755        let spikes: i32 = (0..2000).map(|_| n.step(20.0)).sum();
756        assert!(spikes > 0, "gamma dynamic must fire: got {spikes}");
757    }
758
759    #[test]
760    fn gamma_static_fires_with_drive() {
761        let mut n = GammaMotorNeuron::static_type();
762        let spikes: i32 = (0..2000).map(|_| n.step(20.0)).sum();
763        assert!(spikes > 0, "gamma static must fire: got {spikes}");
764    }
765
766    #[test]
767    fn gamma_no_fire_without_drive() {
768        let mut n = GammaMotorNeuron::new();
769        let spikes: i32 = (0..1000).map(|_| n.step(0.0)).sum();
770        assert_eq!(spikes, 0);
771    }
772
773    #[test]
774    fn gamma_negative_drive_no_fire() {
775        let mut n = GammaMotorNeuron::new();
776        // drive.max(0.0) clamps negatives
777        let spikes: i32 = (0..1000).map(|_| n.step(-10.0)).sum();
778        assert_eq!(spikes, 0);
779    }
780
781    #[test]
782    fn gamma_adaptation_reduces_rate() {
783        let mut n = GammaMotorNeuron::new();
784        let first: i32 = (0..1000).map(|_| n.step(20.0)).sum();
785        let second: i32 = (0..1000).map(|_| n.step(20.0)).sum();
786        assert!(
787            second <= first + 3,
788            "gamma should adapt: first={first}, second={second}"
789        );
790    }
791
792    #[test]
793    fn gamma_static_adapts_more_than_dynamic() {
794        let mut dyn_ = GammaMotorNeuron::dynamic();
795        let mut stat = GammaMotorNeuron::static_type();
796        let dyn_spikes: i32 = (0..2000).map(|_| dyn_.step(20.0)).sum();
797        let stat_spikes: i32 = (0..2000).map(|_| stat.step(20.0)).sum();
798        // Static has stronger adaptation → fewer spikes
799        assert!(
800            stat_spikes <= dyn_spikes + 5,
801            "static ({stat_spikes}) should fire <= dynamic ({dyn_spikes})"
802        );
803    }
804
805    #[test]
806    fn gamma_reset_roundtrip() {
807        let mut n = GammaMotorNeuron::new();
808        for _ in 0..1000 {
809            n.step(20.0);
810        }
811        n.reset();
812        let mut fresh = GammaMotorNeuron::new();
813        let r1: i32 = (0..500).map(|_| n.step(20.0)).sum();
814        let r2: i32 = (0..500).map(|_| fresh.step(20.0)).sum();
815        assert_eq!(r1, r2);
816    }
817
818    #[test]
819    fn gamma_voltage_bounded() {
820        let mut n = GammaMotorNeuron::new();
821        for _ in 0..10000 {
822            n.step(50.0);
823        }
824        assert!(n.v.is_finite());
825        assert!(n.adapt.is_finite());
826    }
827
828    #[test]
829    fn gamma_nan_recovery() {
830        let mut n = GammaMotorNeuron::new();
831        for _ in 0..50 {
832            n.step(20.0);
833        }
834        for _ in 0..10 {
835            let _ = n.step(f64::NAN);
836        }
837        n.reset();
838        assert!(n.v.is_finite());
839        assert_eq!(n.adapt, 0.0);
840    }
841
842    #[test]
843    fn gamma_extreme_input() {
844        let mut n = GammaMotorNeuron::new();
845        for _ in 0..50 {
846            n.step(1e6);
847        }
848        n.reset();
849        assert!(n.v.is_finite());
850    }
851
852    #[test]
853    fn gamma_performance() {
854        let mut n = GammaMotorNeuron::new();
855        let start = std::time::Instant::now();
856        for _ in 0..100_000 {
857            n.step(20.0);
858        }
859        assert!(
860            start.elapsed().as_millis() < 50,
861            "100k steps took {:?}",
862            start.elapsed()
863        );
864    }
865
866    // ── Upper Motor Neuron — 6-dimension coverage ──────────────────
867
868    #[test]
869    fn upper_motor_fires_with_input() {
870        let mut n = UpperMotorNeuron::new();
871        let spikes: i32 = (0..10000).map(|_| n.step(5.0)).sum();
872        assert!(spikes > 0, "upper motor must fire: got {spikes}");
873    }
874
875    #[test]
876    fn upper_motor_no_fire_without_input() {
877        let mut n = UpperMotorNeuron::new();
878        let spikes: i32 = (0..5000).map(|_| n.step(0.0)).sum();
879        assert_eq!(spikes, 0);
880    }
881
882    #[test]
883    fn upper_motor_negative_current_no_fire() {
884        let mut n = UpperMotorNeuron::new();
885        let spikes: i32 = (0..2000).map(|_| n.step(-5.0)).sum();
886        assert_eq!(spikes, 0);
887    }
888
889    #[test]
890    fn upper_motor_adaptation_via_m_current() {
891        let mut n = UpperMotorNeuron::new();
892        let first: i32 = (0..5000).map(|_| n.step(5.0)).sum();
893        let second: i32 = (0..5000).map(|_| n.step(5.0)).sum();
894        assert!(
895            second <= first + 3,
896            "M-current should cause adaptation: first={first}, second={second}"
897        );
898    }
899
900    #[test]
901    fn upper_motor_ca_activates_during_depolarisation() {
902        let mut n = UpperMotorNeuron::new();
903        let baseline = n.s;
904        for _ in 0..5000 {
905            n.step(5.0);
906        }
907        assert!(
908            n.s > baseline + 0.001,
909            "Ca2+ gate should activate: s={}",
910            n.s
911        );
912    }
913
914    #[test]
915    fn upper_motor_reset_roundtrip() {
916        let mut n = UpperMotorNeuron::new();
917        for _ in 0..3000 {
918            n.step(5.0);
919        }
920        n.reset();
921        let mut fresh = UpperMotorNeuron::new();
922        let r1: i32 = (0..2000).map(|_| n.step(5.0)).sum();
923        let r2: i32 = (0..2000).map(|_| fresh.step(5.0)).sum();
924        assert_eq!(r1, r2);
925    }
926
927    #[test]
928    fn upper_motor_voltage_bounded() {
929        let mut n = UpperMotorNeuron::new();
930        for _ in 0..20000 {
931            n.step(10.0);
932        }
933        assert!(n.v.is_finite());
934        assert!(n.p.is_finite());
935        assert!(n.s.is_finite());
936    }
937
938    #[test]
939    fn upper_motor_nan_recovery() {
940        let mut n = UpperMotorNeuron::new();
941        for _ in 0..100 {
942            n.step(5.0);
943        }
944        for _ in 0..10 {
945            let _ = n.step(f64::NAN);
946        }
947        n.reset();
948        assert!(n.v.is_finite());
949    }
950
951    #[test]
952    fn upper_motor_extreme_input() {
953        let mut n = UpperMotorNeuron::new();
954        for _ in 0..50 {
955            n.step(1e6);
956        }
957        n.reset();
958        assert!(n.v.is_finite());
959    }
960
961    #[test]
962    fn upper_motor_performance() {
963        let mut n = UpperMotorNeuron::new();
964        let start = std::time::Instant::now();
965        for _ in 0..10_000 {
966            n.step(5.0);
967        }
968        assert!(
969            start.elapsed().as_millis() < 100,
970            "10k steps took {:?}",
971            start.elapsed()
972        );
973    }
974
975    // ── Renshaw Cell — 6-dimension coverage ────────────────────────
976
977    #[test]
978    fn renshaw_fires_with_input() {
979        let mut n = RenshawCell::new();
980        let spikes: i32 = (0..5000).map(|_| n.step(3.0)).sum();
981        assert!(spikes > 0, "Renshaw must fire: got {spikes}");
982    }
983
984    #[test]
985    fn renshaw_no_fire_without_input() {
986        let mut n = RenshawCell::new();
987        let spikes: i32 = (0..3000).map(|_| n.step(0.0)).sum();
988        assert_eq!(spikes, 0);
989    }
990
991    #[test]
992    fn renshaw_negative_current_no_fire() {
993        let mut n = RenshawCell::new();
994        let spikes: i32 = (0..2000).map(|_| n.step(-2.0)).sum();
995        assert_eq!(spikes, 0);
996    }
997
998    #[test]
999    fn renshaw_burst_then_adapt() {
1000        // Renshaw should fire more in the first epoch than the second
1001        let mut n = RenshawCell::new();
1002        let first: i32 = (0..2000).map(|_| n.step(4.0)).sum();
1003        let second: i32 = (0..2000).map(|_| n.step(4.0)).sum();
1004        assert!(
1005            second <= first + 5,
1006            "Renshaw should adapt: first={first}, second={second}"
1007        );
1008    }
1009
1010    #[test]
1011    fn renshaw_adapt_increases_during_firing() {
1012        let mut n = RenshawCell::new();
1013        let baseline = n.adapt;
1014        for _ in 0..3000 {
1015            n.step(4.0);
1016        }
1017        assert!(
1018            n.adapt > baseline + 0.01,
1019            "adaptation variable should increase: adapt={}",
1020            n.adapt
1021        );
1022    }
1023
1024    #[test]
1025    fn renshaw_reset_roundtrip() {
1026        let mut n = RenshawCell::new();
1027        for _ in 0..2000 {
1028            n.step(4.0);
1029        }
1030        n.reset();
1031        let mut fresh = RenshawCell::new();
1032        let r1: i32 = (0..1000).map(|_| n.step(4.0)).sum();
1033        let r2: i32 = (0..1000).map(|_| fresh.step(4.0)).sum();
1034        assert_eq!(r1, r2);
1035    }
1036
1037    #[test]
1038    fn renshaw_voltage_bounded() {
1039        let mut n = RenshawCell::new();
1040        for _ in 0..10000 {
1041            n.step(10.0);
1042        }
1043        assert!(n.v.is_finite());
1044        assert!(n.adapt.is_finite());
1045    }
1046
1047    #[test]
1048    fn renshaw_nan_recovery() {
1049        let mut n = RenshawCell::new();
1050        for _ in 0..100 {
1051            n.step(3.0);
1052        }
1053        for _ in 0..10 {
1054            let _ = n.step(f64::NAN);
1055        }
1056        n.reset();
1057        assert!(n.v.is_finite());
1058        assert_eq!(n.adapt, 0.0);
1059    }
1060
1061    #[test]
1062    fn renshaw_extreme_input() {
1063        let mut n = RenshawCell::new();
1064        for _ in 0..50 {
1065            n.step(1e6);
1066        }
1067        n.reset();
1068        assert!(n.v.is_finite());
1069    }
1070
1071    #[test]
1072    fn renshaw_performance() {
1073        let mut n = RenshawCell::new();
1074        let start = std::time::Instant::now();
1075        for _ in 0..5_000 {
1076            n.step(4.0);
1077        }
1078        assert!(
1079            start.elapsed().as_millis() < 500,
1080            "5k steps took {:?}",
1081            start.elapsed()
1082        );
1083    }
1084
1085    // ── Motor Unit — 6-dimension coverage ──────────────────────────
1086
1087    #[test]
1088    fn motor_unit_fires_with_drive() {
1089        let mut mu = MotorUnit::new();
1090        let spikes: i32 = (0..2000).map(|_| mu.step(20.0)).sum();
1091        assert!(spikes > 0, "motor unit must fire: got {spikes}");
1092    }
1093
1094    #[test]
1095    fn motor_unit_no_fire_without_drive() {
1096        let mut mu = MotorUnit::new();
1097        let spikes: i32 = (0..1000).map(|_| mu.step(0.0)).sum();
1098        assert_eq!(spikes, 0);
1099    }
1100
1101    #[test]
1102    fn motor_unit_negative_drive_no_fire() {
1103        let mut mu = MotorUnit::new();
1104        let spikes: i32 = (0..1000).map(|_| mu.step(-10.0)).sum();
1105        assert_eq!(spikes, 0);
1106    }
1107
1108    #[test]
1109    fn motor_unit_force_increases_with_spikes() {
1110        let mut mu = MotorUnit::new();
1111        assert_eq!(mu.force, 0.0);
1112        for _ in 0..2000 {
1113            mu.step(20.0);
1114        }
1115        assert!(
1116            mu.force > 0.0,
1117            "force should increase during spiking: f={}",
1118            mu.force
1119        );
1120    }
1121
1122    #[test]
1123    fn motor_unit_force_decays_without_input() {
1124        let mut mu = MotorUnit::new();
1125        // Build up force
1126        for _ in 0..1000 {
1127            mu.step(20.0);
1128        }
1129        let peak = mu.force;
1130        assert!(peak > 0.0);
1131        // No input → force decays
1132        for _ in 0..5000 {
1133            mu.step(0.0);
1134        }
1135        assert!(
1136            mu.force < peak,
1137            "force should decay: peak={peak}, now={}",
1138            mu.force
1139        );
1140    }
1141
1142    #[test]
1143    fn motor_unit_fast_produces_more_force() {
1144        let mut slow = MotorUnit::slow();
1145        let mut fast = MotorUnit::fast();
1146        for _ in 0..2000 {
1147            slow.step(20.0);
1148            fast.step(20.0);
1149        }
1150        assert!(
1151            fast.force >= slow.force,
1152            "fast MU ({}) should produce >= force than slow ({})",
1153            fast.force,
1154            slow.force
1155        );
1156    }
1157
1158    #[test]
1159    fn motor_unit_force_capped_at_one() {
1160        let mut mu = MotorUnit::fast();
1161        for _ in 0..10000 {
1162            mu.step(50.0);
1163        }
1164        assert!(mu.force <= 1.0, "force must not exceed 1.0: f={}", mu.force);
1165    }
1166
1167    #[test]
1168    fn motor_unit_reset_roundtrip() {
1169        let mut mu = MotorUnit::new();
1170        for _ in 0..1000 {
1171            mu.step(20.0);
1172        }
1173        mu.reset();
1174        assert_eq!(mu.force, 0.0);
1175        assert_eq!(mu.adapt, 0.0);
1176        let mut fresh = MotorUnit::new();
1177        let r1: i32 = (0..500).map(|_| mu.step(20.0)).sum();
1178        let r2: i32 = (0..500).map(|_| fresh.step(20.0)).sum();
1179        assert_eq!(r1, r2);
1180    }
1181
1182    #[test]
1183    fn motor_unit_voltage_bounded() {
1184        let mut mu = MotorUnit::new();
1185        for _ in 0..10000 {
1186            mu.step(50.0);
1187        }
1188        assert!(mu.v.is_finite());
1189        assert!(mu.force.is_finite());
1190    }
1191
1192    #[test]
1193    fn motor_unit_nan_recovery() {
1194        let mut mu = MotorUnit::new();
1195        for _ in 0..50 {
1196            mu.step(20.0);
1197        }
1198        for _ in 0..10 {
1199            let _ = mu.step(f64::NAN);
1200        }
1201        mu.reset();
1202        assert!(mu.v.is_finite());
1203        assert_eq!(mu.force, 0.0);
1204    }
1205
1206    #[test]
1207    fn motor_unit_performance() {
1208        let mut mu = MotorUnit::new();
1209        let start = std::time::Instant::now();
1210        for _ in 0..100_000 {
1211            mu.step(20.0);
1212        }
1213        assert!(
1214            start.elapsed().as_millis() < 50,
1215            "100k steps took {:?}",
1216            start.elapsed()
1217        );
1218    }
1219}