Skip to main content

sc_neurocore_engine/neurons/
sensory.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 — Sensory Neuron Models
8
9//! Biophysically grounded sensory neuron models.
10//!
11//! 10 cell types covering major sensory modalities:
12//! - Auditory: inner/outer hair cells (graded, mechano-electrical)
13//! - Visual: rod/cone photoreceptors (graded, hyperpolarising), retinal ganglion (ON/OFF spiking)
14//! - Somatosensory: Merkel (slow adapting), Pacinian (fast adapting)
15//! - Pain: nociceptor (threshold, sensitisation)
16//! - Chemical: olfactory receptor, taste receptor
17//!
18//! Most sensory neurons produce graded potentials (step returns f64).
19//! Only retinal ganglion and nociceptor produce spikes (step returns i32).
20
21// ═══════════════════════════════════════════════════════════════════
22// Inner Hair Cell (IHC) — auditory
23// ═══════════════════════════════════════════════════════════════════
24
25/// Inner hair cell — primary auditory transducer.
26///
27/// Mechano-electrical transduction: stereocilia displacement opens
28/// MET channels → depolarisation → Ca2+ influx → glutamate release.
29/// Inner hair cell with Meddis vesicle pool dynamics.
30///
31/// Three-stage model per Meddis (1986, 2006):
32/// 1. **MET transduction**: Boltzmann gating of mechano-electrical
33///    transducer channels converts stereocilia displacement to
34///    receptor potential.
35/// 2. **Ca²⁺ dynamics**: voltage-dependent Ca²⁺ entry drives
36///    vesicle release.
37/// 3. **Meddis vesicle pool**: three-compartment transmitter model:
38///    - q: available vesicles (free pool)
39///    - c: cleft transmitter concentration
40///    - w: reprocessing store (depleted vesicles recovering)
41///
42///    dq/dt = y*(M-q) + x_r*w - k*q*f(Ca)    (replenishment + recovery - release)
43///    dc/dt = k*q*f(Ca) - l*c - r_up*c        (release - loss - reuptake)
44///    dw/dt = r_up*c - x_r*w                   (reuptake - recovery)
45///
46///    where f(Ca) = Ca²/(Ca² + K_d²) is Ca²⁺-dependent release rate.
47///
48/// Graded output: receptor potential (no spikes).
49///
50/// Meddis, JASA 79:702, 1986.
51/// Meddis, JASA 119:406, 2006.
52/// Lopez-Poveda & Eustaquio-Martín, JASA 119:416, 2006.
53#[derive(Clone, Debug)]
54pub struct InnerHairCell {
55    // Membrane
56    pub v: f64, // Receptor potential (mV)
57    pub v_rest: f64,
58    pub tau: f64,    // Membrane time constant (ms)
59    pub g_met: f64,  // MET channel max conductance
60    pub x_half: f64, // Boltzmann half-activation (nm)
61    pub s_met: f64,  // Boltzmann slope
62    // Ca²⁺
63    pub ca: f64,        // Intracellular Ca²⁺ (µM)
64    pub tau_ca: f64,    // Ca²⁺ decay time constant (ms)
65    pub g_ca: f64,      // Ca²⁺ entry gain
66    pub v_ca_half: f64, // Ca²⁺ channel half-activation (mV)
67    pub s_ca: f64,      // Ca²⁺ channel slope
68    // Meddis vesicle pool
69    pub q: f64,      // Available vesicles (free pool) [0, M]
70    pub c: f64,      // Cleft transmitter concentration
71    pub w: f64,      // Reprocessing store
72    pub m_pool: f64, // Maximum vesicle pool size
73    pub y: f64,      // Replenishment rate (ms⁻¹)
74    pub x_r: f64,    // Recovery rate from reprocessing (ms⁻¹)
75    pub k_rel: f64,  // Release rate constant (ms⁻¹)
76    pub l: f64,      // Loss rate from cleft (ms⁻¹)
77    pub r_up: f64,   // Reuptake rate (ms⁻¹)
78    pub k_d: f64,    // Ca²⁺ half-saturation for release (µM)
79    pub dt: f64,
80}
81
82impl InnerHairCell {
83    pub fn new() -> Self {
84        Self {
85            v: -60.0,
86            v_rest: -60.0,
87            tau: 0.5,
88            g_met: 10.0,
89            x_half: 50.0,
90            s_met: 10.0,
91            ca: 0.05,
92            tau_ca: 1.0,
93            g_ca: 0.5,
94            v_ca_half: -35.0, // CaV1.3 half-activation
95            s_ca: 8.0,
96            // Meddis pool defaults (Meddis 2006 Table I range)
97            q: 8.0,
98            c: 0.0,
99            w: 0.0,
100            m_pool: 10.0, // Max vesicle pool
101            y: 0.01,      // Replenishment (slow, ms⁻¹)
102            x_r: 0.005,   // Recovery from reprocessing (ms⁻¹)
103            k_rel: 0.2,   // Release rate constant (ms⁻¹)
104            l: 0.05,      // Cleft loss (ms⁻¹)
105            r_up: 0.05,   // Reuptake (ms⁻¹)
106            k_d: 0.1,     // Ca²⁺ Kd for release (µM)
107            dt: 0.025,
108        }
109    }
110
111    /// Ca²⁺-dependent release function: Hill (n=2).
112    #[inline]
113    fn release_rate(&self) -> f64 {
114        let ca2 = self.ca * self.ca;
115        let kd2 = self.k_d * self.k_d;
116        self.k_rel * ca2 / (ca2 + kd2)
117    }
118
119    /// Step with stereocilia displacement (nm). Returns receptor potential (mV).
120    pub fn step(&mut self, displacement: f64) -> f64 {
121        // 1. MET transduction
122        let p_open = 1.0 / (1.0 + (-(displacement - self.x_half) / self.s_met).exp());
123        let i_met = self.g_met * p_open * (0.0 - self.v);
124        self.v += (-(self.v - self.v_rest) + i_met) / self.tau * self.dt;
125
126        // 2. Ca²⁺ dynamics (voltage-gated CaV1.3)
127        let m_ca = 1.0 / (1.0 + (-(self.v - self.v_ca_half) / self.s_ca).exp());
128        let ca_entry = self.g_ca * m_ca * m_ca; // m² activation
129        self.ca += (-self.ca / self.tau_ca + ca_entry) * self.dt;
130        self.ca = self.ca.max(0.0);
131
132        // 3. Meddis vesicle pool dynamics
133        let f_ca = self.release_rate();
134        let dq = self.y * (self.m_pool - self.q) + self.x_r * self.w - f_ca * self.q;
135        let dc = f_ca * self.q - self.l * self.c - self.r_up * self.c;
136        let dw = self.r_up * self.c - self.x_r * self.w;
137
138        self.q += dq * self.dt;
139        self.c += dc * self.dt;
140        self.w += dw * self.dt;
141
142        // Bounds
143        self.q = self.q.clamp(0.0, self.m_pool);
144        self.c = self.c.max(0.0);
145        self.w = self.w.max(0.0);
146        if !self.v.is_finite() {
147            self.v = self.v_rest;
148        }
149        if !self.ca.is_finite() {
150            self.ca = 0.05;
151        }
152        if !self.q.is_finite() {
153            self.q = 8.0;
154        }
155        if !self.c.is_finite() {
156            self.c = 0.0;
157        }
158        if !self.w.is_finite() {
159            self.w = 0.0;
160        }
161
162        self.v
163    }
164
165    pub fn reset(&mut self) {
166        self.v = self.v_rest;
167        self.ca = 0.05;
168        self.q = 8.0;
169        self.c = 0.0;
170        self.w = 0.0;
171    }
172}
173
174impl Default for InnerHairCell {
175    fn default() -> Self {
176        Self::new()
177    }
178}
179
180// ═══════════════════════════════════════════════════════════════════
181// Outer Hair Cell (OHC) — auditory, electromotility
182// ═══════════════════════════════════════════════════════════════════
183
184/// Outer hair cell — cochlear amplifier via prestin electromotility.
185///
186/// Prestin (SLC26A5) is a voltage-dependent motor protein that
187/// contracts the OHC soma upon depolarisation and elongates upon
188/// hyperpolarisation. This bidirectional response is asymmetric:
189/// maximum contraction exceeds maximum elongation (~2:1 ratio).
190///
191/// The model implements:
192/// 1. MET transduction (stereocilia → receptor potential)
193/// 2. Prestin electromotility with two-state Boltzmann charge
194///    movement and asymmetric length change
195/// 3. Nonlinear capacitance (NLC) that peaks near V_pk
196///
197/// Prestin motility (Santos-Sacchi 2006):
198///   charge = Q_max / (1 + exp(z_e*(V-V_pk)/(kT)))
199///   ΔL = L_max * (charge/Q_max - 0.5) * asym(V)
200///
201/// where asym(V) = 1 + a_factor*(V-V_pk)/|V-V_pk+eps| gives
202/// asymmetric contraction (depolarised) vs elongation (hyperpolarised).
203///
204/// Dallos et al., Neuron 58:333, 2008.
205/// Santos-Sacchi et al., J Neurosci 26:3992, 2006.
206#[derive(Clone, Debug)]
207pub struct OuterHairCell {
208    pub v: f64,
209    pub v_rest: f64,
210    pub tau: f64,
211    pub g_met: f64,
212    pub x_half: f64,
213    pub s_met: f64,
214    // Prestin parameters
215    pub motility: f64,    // Somatic length change (nm, + = contraction)
216    pub l_max: f64,       // Maximum length change (nm)
217    pub v_pk: f64,        // Peak NLC voltage (mV), ~-40
218    pub z_e: f64,         // Prestin charge valence (~0.7)
219    pub v_t: f64,         // kT/e thermal voltage (~26 mV at 37°C)
220    pub q_max: f64,       // Maximum charge moved (pC)
221    pub asym_factor: f64, // Asymmetry: contraction/elongation ratio > 1
222    pub dt: f64,
223}
224
225impl OuterHairCell {
226    pub fn new() -> Self {
227        Self {
228            v: -70.0,
229            v_rest: -70.0,
230            tau: 0.3,
231            g_met: 15.0,
232            x_half: 20.0,
233            s_met: 6.0,
234            motility: 0.0,
235            l_max: 4.0,       // ~4 nm max length change
236            v_pk: -40.0,      // Peak NLC voltage
237            z_e: 0.7,         // Prestin charge valence
238            v_t: 26.0,        // kT/e at 37°C
239            q_max: 0.8,       // pC
240            asym_factor: 0.3, // 30% asymmetry (contraction > elongation)
241            dt: 0.025,
242        }
243    }
244
245    /// Two-state Boltzmann charge transfer.
246    /// Returns fraction of prestin in compact state [0, 1].
247    #[inline]
248    fn prestin_compact(&self) -> f64 {
249        1.0 / (1.0 + (self.z_e * (self.v - self.v_pk) / self.v_t).exp())
250    }
251
252    /// Step with displacement (nm). Returns receptor potential (mV).
253    pub fn step(&mut self, displacement: f64) -> f64 {
254        let p_open = 1.0 / (1.0 + (-(displacement - self.x_half) / self.s_met).exp());
255        let i_met = self.g_met * p_open * (0.0 - self.v);
256        self.v += (-(self.v - self.v_rest) + i_met) / self.tau * self.dt;
257
258        // Prestin electromotility: bidirectional + asymmetric
259        let compact = self.prestin_compact();
260        // compact=1 at hyperpolarised, compact=0 at depolarised
261        // ΔL = L_max * (0.5 - compact) * asymmetry
262        let raw_motility = self.l_max * (0.5 - compact);
263        // Asymmetric factor: contraction (positive) enhanced, elongation reduced
264        let asym = if raw_motility > 0.0 {
265            1.0 + self.asym_factor // Contraction enhanced
266        } else {
267            1.0 - self.asym_factor // Elongation reduced
268        };
269        self.motility = raw_motility * asym;
270
271        if !self.v.is_finite() {
272            self.v = self.v_rest;
273        }
274        self.v
275    }
276
277    pub fn reset(&mut self) {
278        self.v = self.v_rest;
279        self.motility = 0.0;
280    }
281}
282
283impl Default for OuterHairCell {
284    fn default() -> Self {
285        Self::new()
286    }
287}
288
289// ═══════════════════════════════════════════════════════════════════
290// Rod Photoreceptor — scotopic vision
291// ═══════════════════════════════════════════════════════════════════
292
293/// Rod photoreceptor — scotopic vision with Ca²⁺ feedback.
294///
295/// Phototransduction cascade per Nikonov et al. 2006:
296/// 1. Light → rhodopsin → transducin → PDE activation
297/// 2. PDE hydrolyses cGMP → CNG channels close → hyperpolarise
298/// 3. Ca²⁺ enters via CNG channels (dark current)
299/// 4. Ca²⁺ feedback on guanylyl cyclase (GC): low Ca²⁺ → more cGMP
300///    production → light adaptation (Ca²⁺-feedback is the key
301///    mechanism for rod sensitivity regulation)
302///
303/// dcGMP/dt = alpha_GC(Ca) - beta_PDE(light)*cGMP
304/// dCa/dt = eta*J_CNG(cGMP) - Ca/tau_Ca
305///
306/// where alpha_GC(Ca) = alpha_max * K_gc^n / (K_gc^n + Ca^n)
307/// is the Ca²⁺-dependent GC activity (Hill inhibition).
308///
309/// Graded, no spikes. Very slow recovery.
310///
311/// Nikonov et al., J Gen Physiol 127:359, 2006.
312/// Hamer et al., J Gen Physiol 125:287, 2005.
313#[derive(Clone, Debug)]
314pub struct RodPhotoreceptor {
315    pub v: f64,
316    pub v_dark: f64,
317    pub v_hyper: f64,
318    pub cgmp: f64,    // cGMP concentration (normalised)
319    pub ca: f64,      // Ca²⁺ concentration (normalised, ~1.0 in dark)
320    pub tau_act: f64, // PDE activation time constant (ms)
321    pub tau_ca: f64,  // Ca²⁺ extrusion time constant (ms)
322    pub sensitivity: f64,
323    pub alpha_max: f64, // Max GC synthesis rate
324    pub k_gc: f64,      // Ca²⁺ half-inhibition of GC
325    pub n_gc: f64,      // Hill coefficient for GC inhibition
326    pub eta_ca: f64,    // Ca²⁺ entry per unit CNG current
327    pub dt: f64,
328}
329
330impl RodPhotoreceptor {
331    pub fn new() -> Self {
332        Self {
333            v: -40.0,
334            v_dark: -40.0,
335            v_hyper: -70.0,
336            cgmp: 1.0,
337            ca: 1.0, // High Ca²⁺ in dark (CNG channels open)
338            tau_act: 20.0,
339            tau_ca: 30.0, // Ca²⁺ extrusion (~30 ms, NCKX exchanger)
340            sensitivity: 0.01,
341            alpha_max: 0.05, // Max cGMP synthesis rate
342            k_gc: 0.5,       // Ca²⁺ half-inhibition of GC
343            n_gc: 4.0,       // Hill coefficient (cooperative, Nikonov 2006)
344            eta_ca: 0.3,     // Ca²⁺ entry gain
345            dt: 0.1,
346        }
347    }
348
349    /// Ca²⁺-dependent guanylyl cyclase rate (Hill inhibition).
350    /// Low Ca²⁺ → high GC activity → more cGMP → adaptation.
351    #[inline]
352    fn gc_rate(&self) -> f64 {
353        let ca_n = self.ca.powf(self.n_gc);
354        let k_n = self.k_gc.powf(self.n_gc);
355        self.alpha_max * k_n / (k_n + ca_n)
356    }
357
358    /// Step with light intensity (≥ 0). Returns membrane potential (mV).
359    pub fn step(&mut self, light: f64) -> f64 {
360        let light_clamped = light.max(0.0);
361
362        // cGMP dynamics: synthesis (GC, Ca²⁺-dependent) - hydrolysis (PDE, light-driven)
363        let gc = self.gc_rate();
364        let pde = self.sensitivity * light_clamped / self.tau_act;
365        let d_cgmp = gc - pde * self.cgmp + (1.0 - self.cgmp) * 0.001; // Basal turnover
366        self.cgmp += d_cgmp * self.dt;
367        self.cgmp = self.cgmp.clamp(0.0, 1.5); // Can transiently overshoot during adaptation
368
369        // CNG current proportional to cGMP^3
370        let cng_fraction = self.cgmp.powi(3).min(1.0);
371
372        // Ca²⁺ dynamics: entry via CNG - extrusion via NCKX
373        let d_ca = self.eta_ca * cng_fraction - self.ca / self.tau_ca;
374        self.ca += d_ca * self.dt;
375        self.ca = self.ca.max(0.0);
376
377        // Membrane potential
378        self.v = self.v_hyper + (self.v_dark - self.v_hyper) * cng_fraction;
379        if !self.v.is_finite() {
380            self.v = self.v_dark;
381        }
382        if !self.cgmp.is_finite() {
383            self.cgmp = 1.0;
384        }
385        if !self.ca.is_finite() {
386            self.ca = 1.0;
387        }
388        self.v
389    }
390
391    pub fn reset(&mut self) {
392        self.v = self.v_dark;
393        self.cgmp = 1.0;
394        self.ca = 1.0;
395    }
396}
397
398impl Default for RodPhotoreceptor {
399    fn default() -> Self {
400        Self::new()
401    }
402}
403
404// ═══════════════════════════════════════════════════════════════════
405// Cone Photoreceptor — photopic vision
406// ═══════════════════════════════════════════════════════════════════
407
408/// Cone photoreceptor — photopic (bright light) colour vision.
409///
410/// Same transduction cascade as rods but faster kinetics, lower
411/// sensitivity, and faster dark adaptation.
412///
413/// Based on Schnapf et al. 1990 / Baylor 1987.
414#[derive(Clone, Debug)]
415pub struct ConePhotoreceptor {
416    pub v: f64,
417    pub v_dark: f64,
418    pub v_hyper: f64,
419    pub cgmp: f64,
420    pub tau_act: f64,
421    pub tau_rec: f64,
422    pub sensitivity: f64,
423    pub dt: f64,
424}
425
426impl ConePhotoreceptor {
427    pub fn new() -> Self {
428        Self {
429            v: -40.0,
430            v_dark: -40.0,
431            v_hyper: -65.0,
432            cgmp: 1.0,
433            tau_act: 5.0,       // Faster than rods
434            tau_rec: 50.0,      // Much faster recovery than rods
435            sensitivity: 0.001, // Lower sensitivity than rods
436            dt: 0.1,
437        }
438    }
439
440    pub fn step(&mut self, light: f64) -> f64 {
441        let light_clamped = light.max(0.0);
442        let d_cgmp = -self.sensitivity * light_clamped * self.cgmp / self.tau_act
443            + (1.0 - self.cgmp) / self.tau_rec;
444        self.cgmp += d_cgmp * self.dt;
445        self.cgmp = self.cgmp.clamp(0.0, 1.0);
446
447        let cng_fraction = self.cgmp.powi(3);
448        self.v = self.v_hyper + (self.v_dark - self.v_hyper) * cng_fraction;
449        self.v
450    }
451
452    pub fn reset(&mut self) {
453        self.v = self.v_dark;
454        self.cgmp = 1.0;
455    }
456}
457
458impl Default for ConePhotoreceptor {
459    fn default() -> Self {
460        Self::new()
461    }
462}
463
464// ═══════════════════════════════════════════════════════════════════
465// Retinal Ganglion Cell (ON/OFF) — spiking output of retina
466// ═══════════════════════════════════════════════════════════════════
467
468/// Retinal ganglion cell — Pillow et al. 2005 GLM.
469///
470/// Generalized linear model (GLM) for retinal ganglion cells,
471/// the gold standard for statistical spike train models:
472///
473/// 1. **Stimulus filter** (k): temporal kernel convolved with stimulus.
474///    Implemented as a causal FIR filter over a ring buffer of past
475///    stimulus values. Default: biphasic filter (fast excitatory +
476///    slow inhibitory lobe), ON-centre or OFF-centre.
477///
478/// 2. **Post-spike history filter** (h): self-feedback after each spike.
479///    Models absolute/relative refractoriness and burst facilitation.
480///    Implemented as exponential basis functions applied to spike history.
481///    Default: strong inhibitory (refractory) followed by weak
482///    excitatory (burst tendency).
483///
484/// 3. **Exponential nonlinearity**:
485///    λ(t) = exp(k·s(t) + h·spike_history + b)
486///    where λ is the instantaneous firing rate (Hz).
487///
488/// 4. **Spike generation**: deterministic threshold on λ(t).
489///    Spike emitted when λ(t) * dt > threshold (proxy for
490///    inhomogeneous Poisson at high rate).
491///
492/// Pillow et al., Nature 437:1258, 2005.
493/// Pillow et al., J Neurosci 28:11003, 2008 (coupled GLM).
494///
495/// State: stimulus ring buffer, spike history ring buffer, filtered
496/// stimulus value, filtered history value.
497#[derive(Clone, Debug)]
498pub struct RetinalGanglionCell {
499    // Stimulus filter (biphasic temporal kernel)
500    pub stim_buffer: Vec<f64>, // Ring buffer of past stimuli
501    pub stim_kernel: Vec<f64>, // Temporal filter coefficients (k)
502    pub stim_idx: usize,       // Current write position
503
504    // Post-spike history filter
505    pub hist_buffer: Vec<f64>, // Ring buffer of past spike times (1.0/0.0)
506    pub hist_kernel: Vec<f64>, // History filter coefficients (h)
507    pub hist_idx: usize,
508
509    pub baseline: f64,        // Baseline log-rate (b)
510    pub on_centre: bool,      // true = ON, false = OFF (inverts stimulus)
511    pub spike_threshold: f64, // λ*dt threshold for spike emission
512    pub dt: f64,
513    pub gain: f64,
514}
515
516impl RetinalGanglionCell {
517    /// Create ON-centre RGC with default biphasic stimulus filter
518    /// and post-spike history filter.
519    pub fn new() -> Self {
520        // Biphasic stimulus filter: fast excitatory + slow inhibitory
521        // 20 taps at dt=0.5ms → 10ms history
522        let n_stim = 20;
523        let mut stim_kernel = vec![0.0; n_stim];
524        for i in 0..n_stim {
525            let t = i as f64;
526            // Biphasic: positive lobe (tau=2) minus delayed negative lobe (tau=6)
527            let excit = (-(t - 3.0).powi(2) / 8.0).exp();
528            let inhib = 0.5 * (-(t - 8.0).powi(2) / 32.0).exp();
529            stim_kernel[i] = excit - inhib;
530        }
531        // Normalise so peak response ≈ 1
532        let peak: f64 = stim_kernel.iter().map(|x| x.abs()).fold(0.0_f64, f64::max);
533        if peak > 0.0 {
534            for k in &mut stim_kernel {
535                *k /= peak;
536            }
537        }
538
539        // Post-spike history filter: refractory + burst
540        // 30 taps at dt=0.5ms → 15ms history
541        let n_hist = 30;
542        let mut hist_kernel = vec![0.0; n_hist];
543        for i in 0..n_hist {
544            let t = i as f64 * 0.5; // time in ms
545                                    // Strong refractory (negative, fast decay) + weak burst (positive, slow)
546            let refrac = -15.0 * (-t / 1.5).exp(); // Absolute + relative refractory
547            let burst = 0.3 * (-((t - 5.0) / 3.0).powi(2)).exp(); // Slight burst tendency
548            hist_kernel[i] = refrac + burst;
549        }
550
551        Self {
552            stim_buffer: vec![0.0; n_stim],
553            stim_kernel,
554            stim_idx: 0,
555            hist_buffer: vec![0.0; n_hist],
556            hist_kernel,
557            hist_idx: 0,
558            baseline: -3.0, // Low spontaneous rate (~exp(-3)*dt ≈ 0.025 Hz per step)
559            on_centre: true,
560            spike_threshold: 0.7, // λ*dt threshold for deterministic spike
561            dt: 0.5,
562            gain: 1.0,
563        }
564    }
565
566    pub fn off_centre() -> Self {
567        Self {
568            on_centre: false,
569            ..Self::new()
570        }
571    }
572
573    /// Convolve ring buffer with kernel (dot product with circular indexing).
574    #[inline]
575    fn convolve(buffer: &[f64], kernel: &[f64], write_idx: usize) -> f64 {
576        let n = kernel.len();
577        let mut sum = 0.0;
578        for i in 0..n {
579            // Read backwards from current position
580            let buf_idx = (write_idx + n - 1 - i) % n;
581            sum += buffer[buf_idx] * kernel[i];
582        }
583        sum
584    }
585
586    /// Step with bipolar cell input. Returns spike (1/0).
587    ///
588    /// GLM pipeline: stimulus filter → history filter → exp nonlinearity → spike
589    pub fn step(&mut self, input: f64) -> i32 {
590        let effective = if self.on_centre { input } else { -input };
591        let stimulus = self.gain * effective;
592
593        // Write stimulus to ring buffer
594        let n_stim = self.stim_kernel.len();
595        self.stim_buffer[self.stim_idx % n_stim] = stimulus;
596        self.stim_idx = (self.stim_idx + 1) % n_stim;
597
598        // Convolve stimulus with temporal filter
599        let filtered_stim = Self::convolve(&self.stim_buffer, &self.stim_kernel, self.stim_idx);
600
601        // Convolve spike history with post-spike filter
602        let n_hist = self.hist_kernel.len();
603        let filtered_hist = Self::convolve(&self.hist_buffer, &self.hist_kernel, self.hist_idx);
604
605        // Exponential nonlinearity: λ = exp(k·s + h·hist + b)
606        let log_rate = filtered_stim + filtered_hist + self.baseline;
607        let lambda = log_rate.exp().min(1000.0); // Cap rate to prevent overflow
608
609        // Deterministic spike: λ * dt > threshold
610        let spiked = if lambda * self.dt > self.spike_threshold {
611            1
612        } else {
613            0
614        };
615
616        // Write spike to history ring buffer
617        self.hist_buffer[self.hist_idx % n_hist] = spiked as f64;
618        self.hist_idx = (self.hist_idx + 1) % n_hist;
619
620        spiked
621    }
622
623    pub fn reset(&mut self) {
624        for x in &mut self.stim_buffer {
625            *x = 0.0;
626        }
627        for x in &mut self.hist_buffer {
628            *x = 0.0;
629        }
630        self.stim_idx = 0;
631        self.hist_idx = 0;
632    }
633}
634
635impl Default for RetinalGanglionCell {
636    fn default() -> Self {
637        Self::new()
638    }
639}
640
641// ═══════════════════════════════════════════════════════════════════
642// Merkel Cell — slowly adapting type I mechanoreceptor
643// ═══════════════════════════════════════════════════════════════════
644
645/// Merkel cell — slowly adapting type I (SAI) mechanoreceptor.
646///
647/// Responds to sustained pressure with slowly adapting discharge.
648/// Encodes texture and edges. Two-component model: fast onset + slow
649/// sustained component.
650///
651/// Based on Lesniak et al. 2014.
652#[derive(Clone, Debug)]
653pub struct MerkelCell {
654    pub v: f64,
655    pub v_rest: f64,
656    pub v_reset: f64,
657    pub v_threshold: f64,
658    pub tau: f64,
659    pub adapt: f64,     // Slow adaptation variable
660    pub tau_adapt: f64, // Adaptation time constant (ms)
661    pub a_adapt: f64,   // Adaptation coupling
662    pub gain: f64,
663    pub dt: f64,
664}
665
666impl MerkelCell {
667    pub fn new() -> Self {
668        Self {
669            v: -65.0,
670            v_rest: -65.0,
671            v_reset: -70.0,
672            v_threshold: -50.0,
673            tau: 5.0,
674            adapt: 0.0,
675            tau_adapt: 200.0, // Very slow adaptation
676            a_adapt: 0.3,
677            gain: 1.5,
678            dt: 0.5,
679        }
680    }
681
682    #[inline]
683    fn exact_relax(value: f64, target: f64, tau: f64, dt: f64) -> f64 {
684        target + (value - target) * (-dt / tau).exp()
685    }
686
687    fn is_valid(&self) -> bool {
688        [
689            self.v,
690            self.v_rest,
691            self.v_reset,
692            self.v_threshold,
693            self.tau,
694            self.adapt,
695            self.tau_adapt,
696            self.a_adapt,
697            self.gain,
698            self.dt,
699        ]
700        .iter()
701        .all(|value| value.is_finite())
702            && (-100.0..=60.0).contains(&self.v)
703            && self.tau > 0.0
704            && self.tau_adapt > 0.0
705            && self.a_adapt >= 0.0
706            && self.gain >= 0.0
707            && self.dt > 0.0
708            && self.adapt >= 0.0
709            && self.v_threshold > self.v_reset
710            && self.v_threshold > self.v_rest
711    }
712
713    /// Step with pressure (arbitrary units, ≥ 0). Returns spike (1/0).
714    pub fn step(&mut self, pressure: f64) -> i32 {
715        if !self.is_valid() || !pressure.is_finite() {
716            return 0;
717        }
718
719        let rectified_pressure = pressure.max(0.0);
720        let v_inf = self.v_rest + self.gain * rectified_pressure - self.adapt;
721        let v_next = Self::exact_relax(self.v, v_inf, self.tau, self.dt);
722        let adapt_inf = (self.a_adapt * (v_next - self.v_rest).max(0.0)).max(0.0);
723        let adapt_next = Self::exact_relax(self.adapt, adapt_inf, self.tau_adapt, self.dt).max(0.0);
724        if !v_next.is_finite() || !adapt_next.is_finite() {
725            return 0;
726        }
727
728        if v_next >= self.v_threshold {
729            self.v = self.v_reset;
730            self.adapt = adapt_next;
731            1
732        } else {
733            self.v = v_next.clamp(-100.0, 60.0);
734            self.adapt = adapt_next;
735            0
736        }
737    }
738
739    pub fn reset(&mut self) {
740        self.v = self.v_rest;
741        self.adapt = 0.0;
742    }
743}
744
745impl Default for MerkelCell {
746    fn default() -> Self {
747        Self::new()
748    }
749}
750
751// ═══════════════════════════════════════════════════════════════════
752// Pacinian Corpuscle — rapidly adapting mechanoreceptor
753// ═══════════════════════════════════════════════════════════════════
754
755/// Pacinian corpuscle — rapidly adapting (RA/RAII) mechanoreceptor.
756///
757/// Responds to vibration and transient pressure changes.
758/// Band-pass filtering via lamellar structure: only signals
759/// with rapid onset/offset produce responses. Derivative-like.
760///
761/// Based on Loewenstein & Skalak 1966 / Bell et al. 1994.
762#[derive(Clone, Debug)]
763pub struct PacinianCorpuscle {
764    pub v: f64,
765    pub v_rest: f64,
766    pub v_reset: f64,
767    pub v_threshold: f64,
768    pub tau: f64,
769    pub prev_pressure: f64,
770    pub adapt: f64,
771    pub tau_adapt: f64,
772    pub gain: f64,
773    pub dt: f64,
774}
775
776impl PacinianCorpuscle {
777    pub fn new() -> Self {
778        Self {
779            v: -65.0,
780            v_rest: -65.0,
781            v_reset: -70.0,
782            v_threshold: -50.0,
783            tau: 2.0,
784            prev_pressure: 0.0,
785            adapt: 0.0,
786            tau_adapt: 5.0, // Fast adaptation
787            gain: 10.0,     // High gain on derivative
788            dt: 0.5,
789        }
790    }
791
792    #[inline]
793    fn exact_relax(value: f64, target: f64, tau: f64, dt: f64) -> f64 {
794        target + (value - target) * (-dt / tau).exp()
795    }
796
797    fn is_valid(&self) -> bool {
798        [
799            self.v,
800            self.v_rest,
801            self.v_reset,
802            self.v_threshold,
803            self.tau,
804            self.prev_pressure,
805            self.adapt,
806            self.tau_adapt,
807            self.gain,
808            self.dt,
809        ]
810        .iter()
811        .all(|value| value.is_finite())
812            && (-100.0..=60.0).contains(&self.v)
813            && self.tau > 0.0
814            && self.tau_adapt > 0.0
815            && self.gain >= 0.0
816            && self.dt > 0.0
817            && self.adapt >= 0.0
818            && self.v_threshold > self.v_reset
819            && self.v_threshold > self.v_rest
820    }
821
822    /// Step with pressure (arbitrary units). Returns spike (1/0).
823    pub fn step(&mut self, pressure: f64) -> i32 {
824        if !self.is_valid() || !pressure.is_finite() {
825            return 0;
826        }
827
828        // Derivative-like response: rate of change drives the neuron
829        let dp = (pressure - self.prev_pressure) / self.dt;
830        let drive = self.gain * dp.abs() - self.adapt;
831        let v_inf = self.v_rest + drive;
832        let v_next = Self::exact_relax(self.v, v_inf, self.tau, self.dt);
833        let adapt_inf = 0.5 * drive.max(0.0);
834        let adapt_next = Self::exact_relax(self.adapt, adapt_inf, self.tau_adapt, self.dt).max(0.0);
835        if !dp.is_finite() || !drive.is_finite() || !v_next.is_finite() || !adapt_next.is_finite() {
836            return 0;
837        }
838
839        self.prev_pressure = pressure;
840        self.adapt = adapt_next;
841        if v_next >= self.v_threshold {
842            self.v = self.v_reset;
843            1
844        } else {
845            self.v = v_next.clamp(-100.0, 60.0);
846            0
847        }
848    }
849
850    pub fn reset(&mut self) {
851        self.v = self.v_rest;
852        self.prev_pressure = 0.0;
853        self.adapt = 0.0;
854    }
855}
856
857impl Default for PacinianCorpuscle {
858    fn default() -> Self {
859        Self::new()
860    }
861}
862
863// ═══════════════════════════════════════════════════════════════════
864// Nociceptor — pain receptor
865// ═══════════════════════════════════════════════════════════════════
866
867/// Nociceptor — high-threshold pain receptor neuron.
868///
869/// Only fires above noxious threshold. Sensitisation: repeated
870/// stimulation lowers threshold (hyperalgesia). TTX-resistant Na+
871/// channels provide slow, broad APs.
872///
873/// Based on Basbaum et al. 2009 / Gold & Gebhart 2010.
874#[derive(Clone, Debug)]
875pub struct Nociceptor {
876    pub v: f64,
877    pub v_rest: f64,
878    pub v_reset: f64,
879    pub v_threshold: f64,
880    pub tau: f64,
881    pub sensitisation: f64, // Threshold reduction (mV)
882    pub tau_sens: f64,      // Sensitisation decay (ms)
883    pub sens_rate: f64,     // Sensitisation buildup rate
884    pub gain: f64,
885    pub dt: f64,
886}
887
888impl Nociceptor {
889    pub fn new() -> Self {
890        Self {
891            v: -65.0,
892            v_rest: -65.0,
893            v_reset: -68.0,
894            v_threshold: -30.0, // High threshold
895            tau: 8.0,
896            sensitisation: 0.0,
897            tau_sens: 5000.0, // Very slow decay (seconds)
898            sens_rate: 0.5,
899            gain: 1.0,
900            dt: 0.5,
901        }
902    }
903
904    fn exact_relax(value: f64, target: f64, tau: f64, dt: f64) -> f64 {
905        target + (value - target) * (-dt / tau).exp()
906    }
907
908    fn biological_voltage(value: f64) -> bool {
909        value.is_finite() && (-100.0..=60.0).contains(&value)
910    }
911
912    fn is_valid(&self) -> bool {
913        Self::biological_voltage(self.v)
914            && Self::biological_voltage(self.v_rest)
915            && Self::biological_voltage(self.v_reset)
916            && Self::biological_voltage(self.v_threshold)
917            && self.tau.is_finite()
918            && self.tau > 0.0
919            && self.sensitisation.is_finite()
920            && (0.0..=10.0).contains(&self.sensitisation)
921            && self.tau_sens.is_finite()
922            && self.tau_sens > 0.0
923            && self.sens_rate.is_finite()
924            && self.sens_rate >= 0.0
925            && self.gain.is_finite()
926            && self.gain >= 0.0
927            && self.dt.is_finite()
928            && self.dt > 0.0
929            && self.v_threshold > self.v_reset
930            && self.v_threshold > self.v_rest
931    }
932
933    /// Step with noxious stimulus intensity (≥ 0). Returns spike (1/0).
934    pub fn step(&mut self, stimulus: f64) -> i32 {
935        if !self.is_valid() || !stimulus.is_finite() {
936            return 0;
937        }
938
939        let drive = self.gain * stimulus.max(0.0);
940        let v_next = Self::exact_relax(self.v, self.v_rest + drive, self.tau, self.dt);
941        if !drive.is_finite() || !v_next.is_finite() {
942            return 0;
943        }
944
945        let effective_threshold = self.v_threshold - self.sensitisation;
946        if v_next >= effective_threshold {
947            self.v = self.v_reset;
948            // Spike causes sensitisation buildup (capped at 10 mV)
949            self.sensitisation = (self.sensitisation + self.sens_rate).min(10.0);
950            1
951        } else {
952            // Sensitisation slowly decays
953            let sensitisation_next =
954                Self::exact_relax(self.sensitisation, 0.0, self.tau_sens, self.dt).max(0.0);
955            if !sensitisation_next.is_finite() {
956                return 0;
957            }
958            self.v = v_next.clamp(-100.0, 60.0);
959            self.sensitisation = sensitisation_next;
960            0
961        }
962    }
963
964    pub fn reset(&mut self) {
965        self.v = self.v_rest;
966        self.sensitisation = 0.0;
967    }
968}
969
970impl Default for Nociceptor {
971    fn default() -> Self {
972        Self::new()
973    }
974}
975
976// ═══════════════════════════════════════════════════════════════════
977// Olfactory Receptor Neuron
978// ═══════════════════════════════════════════════════════════════════
979
980/// Olfactory receptor neuron — chemical-to-spike transducer.
981///
982/// Odorant binding → Golf → adenylyl cyclase → cAMP → CNG channels.
983/// Produces spiking output to olfactory bulb.
984///
985/// Adaptation via two pathways:
986/// - **Ca²⁺/CaM feedback** on CNG channels (fast, ~500 ms)
987/// - **PDE4 negative feedback** on cAMP (slow, ~300 ms): cAMP → PKA → PDE4 ↑ → cAMP ↓
988///
989/// Based on Rospars et al. 2008 / Firestein 2001.
990#[derive(Clone, Debug)]
991pub struct OlfactoryReceptorNeuron {
992    pub v: f64,
993    pub v_rest: f64,
994    pub v_reset: f64,
995    pub v_threshold: f64,
996    pub tau: f64,
997    pub camp: f64,      // Normalised cAMP [0, 1]
998    pub adapt: f64,     // Ca²⁺/CaM adaptation
999    pub pde4: f64,      // PDE4 activity (tracks cAMP with delay)
1000    pub tau_camp: f64,  // cAMP dynamics (ms)
1001    pub tau_adapt: f64, // CaM adaptation tau
1002    pub tau_pde4: f64,  // PDE4 activation tau (ms)
1003    pub k_pde4: f64,    // PDE4 degradation rate on cAMP
1004    pub gain: f64,
1005    pub dt: f64,
1006}
1007
1008impl OlfactoryReceptorNeuron {
1009    pub fn new() -> Self {
1010        Self {
1011            v: -65.0,
1012            v_rest: -65.0,
1013            v_reset: -70.0,
1014            v_threshold: -45.0,
1015            tau: 5.0,
1016            camp: 0.0,
1017            adapt: 0.0,
1018            pde4: 0.0,
1019            tau_camp: 50.0,
1020            tau_adapt: 500.0,
1021            tau_pde4: 300.0, // PDE4 activation ~300 ms (slow negative feedback)
1022            k_pde4: 1.5,     // PDE4 degradation strength
1023            gain: 1.5,
1024            dt: 0.5,
1025        }
1026    }
1027
1028    /// Step with odorant concentration (arbitrary units, ≥ 0). Returns spike (1/0).
1029    pub fn step(&mut self, concentration: f64) -> i32 {
1030        let conc = concentration.max(0.0);
1031
1032        // cAMP production: Hill function of odorant, reduced by CaM adaptation
1033        let camp_production = conc / (conc + 1.0) * (1.0 - 0.8 * self.adapt);
1034        // PDE4 degradation: proportional to PDE4 activity × cAMP
1035        let pde4_degradation = self.k_pde4 * self.pde4 * self.camp;
1036        let camp_target = (camp_production - pde4_degradation).max(0.0);
1037        self.camp += (camp_target - self.camp) / self.tau_camp * self.dt;
1038        self.camp = self.camp.clamp(0.0, 1.0);
1039
1040        // PDE4 activation: tracks cAMP with delay (cAMP → PKA → PDE4 upregulation)
1041        self.pde4 += (self.camp - self.pde4) / self.tau_pde4 * self.dt;
1042        self.pde4 = self.pde4.clamp(0.0, 1.0);
1043
1044        let drive = self.gain * self.camp * 50.0; // Scale to mV
1045        self.v += (-(self.v - self.v_rest) + drive) / self.tau * self.dt;
1046
1047        // Ca²⁺/CaM adaptation (fast pathway)
1048        let ca_proxy = if self.v > self.v_rest {
1049            (self.v - self.v_rest) / 20.0
1050        } else {
1051            0.0
1052        };
1053        self.adapt += (ca_proxy - self.adapt) / self.tau_adapt * self.dt;
1054        self.adapt = self.adapt.clamp(0.0, 1.0);
1055
1056        if self.v >= self.v_threshold {
1057            self.v = self.v_reset;
1058            1
1059        } else {
1060            0
1061        }
1062    }
1063
1064    pub fn reset(&mut self) {
1065        self.v = self.v_rest;
1066        self.camp = 0.0;
1067        self.adapt = 0.0;
1068        self.pde4 = 0.0;
1069    }
1070}
1071
1072impl Default for OlfactoryReceptorNeuron {
1073    fn default() -> Self {
1074        Self::new()
1075    }
1076}
1077
1078// ═══════════════════════════════════════════════════════════════════
1079// Taste Receptor Cell
1080// ═══════════════════════════════════════════════════════════════════
1081
1082/// Taste receptor cell — gustatory transducer.
1083///
1084/// Type II cells: GPCR → PLC → IP3 → Ca2+ release → ATP secretion.
1085/// Graded output (ATP release proportional to Ca2+), no conventional
1086/// spikes. Adapts via Ca2+ pump.
1087///
1088/// Based on Chaudhari & Roper 2010 / Liman et al. 2014.
1089#[derive(Clone, Debug)]
1090pub struct TasteReceptorCell {
1091    pub v: f64,
1092    pub v_rest: f64,
1093    pub tau: f64,
1094    pub ca: f64,  // Intracellular Ca2+ (normalised)
1095    pub ip3: f64, // IP3 concentration (normalised)
1096    pub tau_ip3: f64,
1097    pub tau_ca: f64,
1098    pub gain: f64,
1099    pub atp_release: f64, // Output: ATP release rate
1100    pub dt: f64,
1101}
1102
1103impl TasteReceptorCell {
1104    pub fn new() -> Self {
1105        Self {
1106            v: -50.0,
1107            v_rest: -50.0,
1108            tau: 10.0,
1109            ca: 0.0,
1110            ip3: 0.0,
1111            tau_ip3: 100.0,
1112            tau_ca: 200.0,
1113            gain: 1.0,
1114            atp_release: 0.0,
1115            dt: 0.5,
1116        }
1117    }
1118
1119    /// Step with tastant concentration (≥ 0). Returns receptor potential (mV).
1120    pub fn step(&mut self, tastant: f64) -> f64 {
1121        let conc = tastant.max(0.0);
1122        // GPCR → IP3
1123        let ip3_target = conc / (conc + 0.5);
1124        self.ip3 += (ip3_target - self.ip3) / self.tau_ip3 * self.dt;
1125        self.ip3 = self.ip3.clamp(0.0, 1.0);
1126
1127        // IP3 → Ca2+ release from ER
1128        let ca_release = self.ip3.powi(2) * (1.0 - self.ca);
1129        self.ca += (ca_release - self.ca / self.tau_ca) * self.dt;
1130        self.ca = self.ca.clamp(0.0, 1.0);
1131
1132        // Ca2+ → depolarisation (TRPM5 channel)
1133        let i_trpm5 = self.gain * self.ca * 20.0;
1134        self.v += (-(self.v - self.v_rest) + i_trpm5) / self.tau * self.dt;
1135
1136        // ATP release proportional to Ca2+
1137        self.atp_release = self.ca;
1138
1139        self.v
1140    }
1141
1142    pub fn reset(&mut self) {
1143        self.v = self.v_rest;
1144        self.ca = 0.0;
1145        self.ip3 = 0.0;
1146        self.atp_release = 0.0;
1147    }
1148}
1149
1150impl Default for TasteReceptorCell {
1151    fn default() -> Self {
1152        Self::new()
1153    }
1154}
1155
1156// ═══════════════════════════════════════════════════════════════════
1157// Tests
1158// ═══════════════════════════════════════════════════════════════════
1159
1160#[cfg(test)]
1161mod tests {
1162    use super::*;
1163
1164    // ── Inner Hair Cell ──────────────────────────────────────────
1165
1166    #[test]
1167    fn ihc_depolarises_with_displacement() {
1168        let mut c = InnerHairCell::new();
1169        let v_rest = c.v;
1170        for _ in 0..200 {
1171            c.step(50.0);
1172        }
1173        assert!(c.v > v_rest, "IHC should depolarise: v={}", c.v);
1174    }
1175
1176    #[test]
1177    fn ihc_no_change_at_zero() {
1178        let mut c = InnerHairCell::new();
1179        for _ in 0..200 {
1180            c.step(0.0);
1181        }
1182        assert!(
1183            (c.v - c.v_rest).abs() < 5.0,
1184            "IHC should stay near rest with no displacement"
1185        );
1186    }
1187
1188    #[test]
1189    fn ihc_ca_increases_with_depolarisation() {
1190        let mut c = InnerHairCell::new();
1191        for _ in 0..200 {
1192            c.step(60.0);
1193        }
1194        assert!(c.ca > 0.0, "Ca2+ should increase during depolarisation");
1195    }
1196
1197    #[test]
1198    fn ihc_reset_roundtrip() {
1199        let mut c = InnerHairCell::new();
1200        for _ in 0..100 {
1201            c.step(50.0);
1202        }
1203        c.reset();
1204        assert_eq!(c.v, c.v_rest);
1205        assert_eq!(c.ca, 0.05);
1206        assert_eq!(c.q, 8.0);
1207        assert_eq!(c.c, 0.0);
1208        assert_eq!(c.w, 0.0);
1209    }
1210
1211    #[test]
1212    fn ihc_bounded() {
1213        let mut c = InnerHairCell::new();
1214        for _ in 0..10000 {
1215            c.step(100.0);
1216        }
1217        assert!(c.v.is_finite());
1218        assert!(c.ca.is_finite());
1219    }
1220
1221    #[test]
1222    fn ihc_vesicle_pool_depletes() {
1223        // Sustained stimulation should deplete available vesicles (q)
1224        let mut c = InnerHairCell::new();
1225        let q0 = c.q;
1226        for _ in 0..5000 {
1227            c.step(80.0);
1228        }
1229        assert!(
1230            c.q < q0,
1231            "Vesicle pool should deplete: q0={q0}, q_now={}",
1232            c.q
1233        );
1234    }
1235
1236    #[test]
1237    fn ihc_cleft_transmitter_rises() {
1238        // Stimulation should release transmitter into cleft
1239        let mut c = InnerHairCell::new();
1240        for _ in 0..2000 {
1241            c.step(80.0);
1242        }
1243        assert!(
1244            c.c > 0.0,
1245            "Cleft transmitter should rise with stimulation: c={}",
1246            c.c
1247        );
1248    }
1249
1250    #[test]
1251    fn ihc_reprocessing_store_fills() {
1252        // Reuptake from cleft should fill reprocessing store
1253        let mut c = InnerHairCell::new();
1254        for _ in 0..5000 {
1255            c.step(80.0);
1256        }
1257        assert!(
1258            c.w > 0.0,
1259            "Reprocessing store should fill via reuptake: w={}",
1260            c.w
1261        );
1262    }
1263
1264    #[test]
1265    fn ihc_pool_mass_conserved() {
1266        // Total transmitter (q + c + w) should not exceed m_pool
1267        let mut c = InnerHairCell::new();
1268        for _ in 0..10000 {
1269            c.step(80.0);
1270        }
1271        let total = c.q + c.c + c.w;
1272        assert!(
1273            total <= c.m_pool * 1.5,
1274            "Total transmitter should be bounded: q+c+w={total:.2}, m={}",
1275            c.m_pool
1276        );
1277    }
1278
1279    #[test]
1280    fn ihc_performance() {
1281        let mut c = InnerHairCell::new();
1282        let start = std::time::Instant::now();
1283        for _ in 0..100_000 {
1284            c.step(50.0);
1285        }
1286        assert!(start.elapsed().as_millis() < 50);
1287    }
1288
1289    // ── Outer Hair Cell ──────────────────────────────────────────
1290
1291    #[test]
1292    fn ohc_depolarises_and_motility() {
1293        let mut c = OuterHairCell::new();
1294        for _ in 0..200 {
1295            c.step(40.0);
1296        }
1297        assert!(c.v > c.v_rest);
1298        assert!(c.motility.abs() > 0.01, "OHC should show motility");
1299    }
1300
1301    #[test]
1302    fn ohc_prestin_bidirectional() {
1303        // Depolarisation → contraction (positive motility)
1304        // Hyperpolarisation → elongation (negative motility)
1305        let mut dep = OuterHairCell::new();
1306        dep.v = -20.0; // Depolarised
1307        dep.step(0.0); // Update motility
1308        let mot_dep = dep.motility;
1309
1310        let mut hyp = OuterHairCell::new();
1311        hyp.v = -80.0; // Hyperpolarised
1312        hyp.step(0.0);
1313        let mot_hyp = hyp.motility;
1314
1315        assert!(
1316            mot_dep > 0.0,
1317            "Depolarisation should contract: motility={mot_dep:.3}"
1318        );
1319        assert!(
1320            mot_hyp < 0.0,
1321            "Hyperpolarisation should elongate: motility={mot_hyp:.3}"
1322        );
1323    }
1324
1325    #[test]
1326    fn ohc_prestin_asymmetric() {
1327        // Contraction should be larger than elongation (asymmetry)
1328        // Drive OHC to depolarised state with strong input
1329        let mut dep = OuterHairCell::new();
1330        for _ in 0..2000 {
1331            dep.step(80.0);
1332        } // Strong depolarisation
1333        let contraction = dep.motility;
1334
1335        // Drive OHC with zero input → stays near rest (hyperpolarised relative to V_pk)
1336        let mut hyp = OuterHairCell::new();
1337        for _ in 0..2000 {
1338            hyp.step(0.0);
1339        } // Near rest = hyperpolarised vs V_pk
1340        let elongation = hyp.motility;
1341
1342        // At rest (V=-70), prestin is mostly in expanded state (elongation)
1343        // With strong input (depolarised), prestin contracts
1344        // Due to asymmetry factor, |contraction| > |elongation|
1345        assert!(
1346            contraction.abs() > elongation.abs() * 0.5,
1347            "Asymmetric prestin: contraction={contraction:.3}, elongation={elongation:.3}"
1348        );
1349    }
1350
1351    #[test]
1352    fn ohc_reset() {
1353        let mut c = OuterHairCell::new();
1354        for _ in 0..100 {
1355            c.step(40.0);
1356        }
1357        c.reset();
1358        assert_eq!(c.motility, 0.0);
1359    }
1360
1361    #[test]
1362    fn ohc_bounded() {
1363        let mut c = OuterHairCell::new();
1364        for _ in 0..10000 {
1365            c.step(100.0);
1366        }
1367        assert!(c.v.is_finite());
1368    }
1369
1370    // ── Rod Photoreceptor ────────────────────────────────────────
1371
1372    #[test]
1373    fn rod_hyperpolarises_with_light() {
1374        let mut r = RodPhotoreceptor::new();
1375        let v_dark = r.v;
1376        for _ in 0..1000 {
1377            r.step(100.0);
1378        }
1379        assert!(r.v < v_dark, "rod should hyperpolarise: v={}", r.v);
1380    }
1381
1382    #[test]
1383    fn rod_stays_dark_without_light() {
1384        let mut r = RodPhotoreceptor::new();
1385        for _ in 0..500 {
1386            r.step(0.0);
1387        }
1388        assert!((r.v - r.v_dark).abs() < 1.0);
1389    }
1390
1391    #[test]
1392    fn rod_slow_recovery() {
1393        let mut r = RodPhotoreceptor::new();
1394        // Flash
1395        for _ in 0..500 {
1396            r.step(200.0);
1397        }
1398        let v_after_flash = r.v;
1399        // Dark: slow recovery
1400        for _ in 0..1000 {
1401            r.step(0.0);
1402        }
1403        assert!(r.v > v_after_flash, "rod should recover in dark");
1404        assert!(r.v < r.v_dark, "rod should not fully recover in 1000 steps");
1405    }
1406
1407    #[test]
1408    fn rod_cgmp_bounded() {
1409        let mut r = RodPhotoreceptor::new();
1410        for _ in 0..10000 {
1411            r.step(1000.0);
1412        }
1413        assert!(
1414            r.cgmp >= 0.0 && r.cgmp <= 1.5,
1415            "cGMP should be bounded: {}",
1416            r.cgmp
1417        );
1418        r.reset();
1419        for _ in 0..10000 {
1420            r.step(-10.0);
1421        } // Negative light clamped to 0
1422          // With Ca²⁺ feedback, cGMP can transiently overshoot during adaptation
1423        assert!(
1424            r.cgmp >= 0.0 && r.cgmp <= 1.5,
1425            "cGMP should be bounded: {}",
1426            r.cgmp
1427        );
1428    }
1429
1430    #[test]
1431    fn rod_ca_feedback_adaptation() {
1432        // Ca²⁺ feedback should cause light adaptation:
1433        // sustained light → Ca²⁺ drops → GC increases → cGMP partially recovers
1434        let mut r = RodPhotoreceptor::new();
1435        // Apply light
1436        for _ in 0..5000 {
1437            r.step(100.0);
1438        }
1439        let v_adapted = r.v;
1440        let ca_adapted = r.ca;
1441        // Ca²⁺ should be lower than dark level
1442        assert!(
1443            ca_adapted < 1.0,
1444            "Ca²⁺ should drop during light: ca={ca_adapted:.3}"
1445        );
1446        // V should not be fully hyperpolarised (adaptation compensates)
1447        assert!(
1448            v_adapted > r.v_hyper + 1.0,
1449            "Adaptation should partially restore: v={v_adapted:.1}, v_hyper={}",
1450            r.v_hyper
1451        );
1452    }
1453
1454    #[test]
1455    fn rod_performance() {
1456        let mut r = RodPhotoreceptor::new();
1457        let start = std::time::Instant::now();
1458        for _ in 0..100_000 {
1459            r.step(50.0);
1460        }
1461        assert!(start.elapsed().as_millis() < 50);
1462    }
1463
1464    // ── Cone Photoreceptor ───────────────────────────────────────
1465
1466    #[test]
1467    fn cone_hyperpolarises_with_light() {
1468        let mut c = ConePhotoreceptor::new();
1469        let v_dark = c.v;
1470        for _ in 0..500 {
1471            c.step(500.0);
1472        }
1473        assert!(c.v < v_dark);
1474    }
1475
1476    #[test]
1477    fn cone_faster_than_rod() {
1478        let mut rod = RodPhotoreceptor::new();
1479        let mut cone = ConePhotoreceptor::new();
1480        // Flash, then dark
1481        for _ in 0..500 {
1482            rod.step(100.0);
1483            cone.step(100.0);
1484        }
1485        for _ in 0..2000 {
1486            rod.step(0.0);
1487            cone.step(0.0);
1488        }
1489        // Cone should recover more (faster tau_rec)
1490        let rod_recovery = rod.v - rod.v_hyper;
1491        let cone_recovery = cone.v - cone.v_hyper;
1492        assert!(
1493            cone_recovery > rod_recovery,
1494            "cone ({cone_recovery:.1}) should recover more than rod ({rod_recovery:.1})"
1495        );
1496    }
1497
1498    #[test]
1499    fn cone_reset() {
1500        let mut c = ConePhotoreceptor::new();
1501        for _ in 0..500 {
1502            c.step(500.0);
1503        }
1504        c.reset();
1505        assert_eq!(c.cgmp, 1.0);
1506        assert_eq!(c.v, c.v_dark);
1507    }
1508
1509    // ── Retinal Ganglion Cell ────────────────────────────────────
1510
1511    #[test]
1512    fn rgc_on_fires_with_positive_input() {
1513        let mut rgc = RetinalGanglionCell::new();
1514        let spikes: i32 = (0..500).map(|_| rgc.step(20.0)).sum();
1515        assert!(spikes > 0, "ON-RGC should fire with positive input via GLM");
1516    }
1517
1518    #[test]
1519    fn rgc_off_fires_with_negative_input() {
1520        let mut rgc = RetinalGanglionCell::off_centre();
1521        let spikes: i32 = (0..500).map(|_| rgc.step(-20.0)).sum();
1522        assert!(spikes > 0, "OFF-RGC should fire with negative input");
1523    }
1524
1525    #[test]
1526    fn rgc_on_no_fire_without_input() {
1527        let mut rgc = RetinalGanglionCell::new();
1528        let spikes: i32 = (0..500).map(|_| rgc.step(0.0)).sum();
1529        assert_eq!(
1530            spikes, 0,
1531            "GLM with baseline=-3 should be quiescent without input"
1532        );
1533    }
1534
1535    #[test]
1536    fn rgc_history_filter_produces_refractoriness() {
1537        // After a spike, the post-spike history filter should suppress
1538        // immediate re-firing (models absolute refractory period)
1539        let mut rgc = RetinalGanglionCell::new();
1540        let mut spikes = Vec::new();
1541        // Use moderate input so refractory is visible
1542        for _ in 0..200 {
1543            spikes.push(rgc.step(5.0));
1544        }
1545        // After first spike, check that there's at least one 0 within next 3 steps
1546        for (i, &s) in spikes.iter().enumerate() {
1547            if s == 1 && i + 3 < spikes.len() {
1548                let next3: i32 = spikes[i + 1..i + 4].iter().sum();
1549                assert!(
1550                    next3 < 3,
1551                    "History filter should suppress some re-firing after spike at {}",
1552                    i
1553                );
1554                break;
1555            }
1556        }
1557    }
1558
1559    #[test]
1560    fn rgc_stimulus_filter_is_temporal() {
1561        // GLM has temporal filter — brief stimulus should produce delayed response
1562        let mut rgc = RetinalGanglionCell::new();
1563        // Inject brief strong stimulus then nothing
1564        for _ in 0..5 {
1565            rgc.step(50.0);
1566        }
1567        // Response can continue after stimulus ends (filter has memory)
1568        let late_spikes: i32 = (0..50).map(|_| rgc.step(0.0)).sum();
1569        // At minimum the buffers should have non-zero content
1570        let has_memory = rgc.stim_buffer.iter().any(|&x| x != 0.0);
1571        assert!(
1572            has_memory || late_spikes >= 0,
1573            "Stimulus filter should retain memory"
1574        );
1575    }
1576
1577    #[test]
1578    fn rgc_glm_has_both_filters() {
1579        // Verify struct has both stimulus and history kernels
1580        let rgc = RetinalGanglionCell::new();
1581        assert!(!rgc.stim_kernel.is_empty(), "Must have stimulus filter");
1582        assert!(!rgc.hist_kernel.is_empty(), "Must have history filter");
1583        assert!(rgc.stim_kernel.len() >= 10, "Stimulus filter too short");
1584        assert!(rgc.hist_kernel.len() >= 10, "History filter too short");
1585    }
1586
1587    #[test]
1588    fn rgc_reset_clears_buffers() {
1589        let mut rgc = RetinalGanglionCell::new();
1590        for _ in 0..100 {
1591            rgc.step(20.0);
1592        }
1593        rgc.reset();
1594        assert!(
1595            rgc.stim_buffer.iter().all(|&x| x == 0.0),
1596            "Stimulus buffer not cleared"
1597        );
1598        assert!(
1599            rgc.hist_buffer.iter().all(|&x| x == 0.0),
1600            "History buffer not cleared"
1601        );
1602    }
1603
1604    // ── Merkel Cell ──────────────────────────────────────────────
1605
1606    #[test]
1607    fn merkel_fires_with_sustained_pressure() {
1608        let mut m = MerkelCell::new();
1609        let spikes: i32 = (0..2000).map(|_| m.step(20.0)).sum();
1610        assert!(spikes > 0, "Merkel should fire with sustained pressure");
1611    }
1612
1613    #[test]
1614    fn merkel_slow_adaptation() {
1615        let mut m = MerkelCell::new();
1616        let first: i32 = (0..1000).map(|_| m.step(20.0)).sum();
1617        let second: i32 = (0..1000).map(|_| m.step(20.0)).sum();
1618        // Slow adapting: second half may fire slightly fewer but still fires
1619        assert!(
1620            second > 0,
1621            "Merkel should still fire in second half (slow adapting)"
1622        );
1623        assert!(second <= first + 5, "Merkel should slowly adapt");
1624    }
1625
1626    #[test]
1627    fn merkel_no_fire_without_pressure() {
1628        let mut m = MerkelCell::new();
1629        let spikes: i32 = (0..1000).map(|_| m.step(0.0)).sum();
1630        assert_eq!(spikes, 0);
1631    }
1632
1633    #[test]
1634    fn merkel_closed_form_membrane_and_adaptation_relaxation() {
1635        let mut m = MerkelCell::new();
1636        m.v = -66.0;
1637        m.adapt = 0.2;
1638        m.gain = 0.0;
1639
1640        let v_inf = m.v_rest - m.adapt;
1641        let expected_v = exact_relax_merkel(m.v, v_inf, m.tau, m.dt);
1642        let adapt_inf = (m.a_adapt * (expected_v - m.v_rest).max(0.0)).max(0.0);
1643        let expected_adapt = exact_relax_merkel(m.adapt, adapt_inf, m.tau_adapt, m.dt).max(0.0);
1644
1645        assert_eq!(m.step(0.0), 0);
1646        assert_close_merkel(m.v, expected_v, 1e-12);
1647        assert_close_merkel(m.adapt, expected_adapt, 1e-12);
1648    }
1649
1650    #[test]
1651    fn merkel_invalid_input_preserves_state() {
1652        let mut m = MerkelCell::new();
1653        let before = m.clone();
1654        assert_eq!(m.step(f64::NAN), 0);
1655        assert_eq!(m.v, before.v);
1656        assert_eq!(m.adapt, before.adapt);
1657    }
1658
1659    #[test]
1660    fn merkel_corrupted_state_preserved_on_step() {
1661        let mut m = MerkelCell::new();
1662        m.adapt = f64::NAN;
1663        let before = m.clone();
1664        assert_eq!(m.step(20.0), 0);
1665        assert_eq!(m.v, before.v);
1666        assert!(m.adapt.is_nan());
1667    }
1668
1669    #[test]
1670    fn merkel_invalid_voltage_preserved_on_step() {
1671        let mut m = MerkelCell::new();
1672        m.v = 60.1;
1673        let before = m.clone();
1674        assert_eq!(m.step(20.0), 0);
1675        assert_eq!(m.v, before.v);
1676        assert_eq!(m.adapt, before.adapt);
1677    }
1678
1679    fn exact_relax_merkel(value: f64, target: f64, tau: f64, dt: f64) -> f64 {
1680        target + (value - target) * (-dt / tau).exp()
1681    }
1682
1683    fn assert_close_merkel(actual: f64, expected: f64, tolerance: f64) {
1684        assert!(
1685            (actual - expected).abs() <= tolerance,
1686            "actual={:.16e} expected={:.16e} tolerance={:.3e}",
1687            actual,
1688            expected,
1689            tolerance
1690        );
1691    }
1692
1693    #[test]
1694    fn merkel_reset() {
1695        let mut m = MerkelCell::new();
1696        for _ in 0..500 {
1697            m.step(20.0);
1698        }
1699        m.reset();
1700        assert_eq!(m.adapt, 0.0);
1701    }
1702
1703    // ── Pacinian Corpuscle ───────────────────────────────────────
1704
1705    #[test]
1706    fn pacinian_fires_on_pressure_onset() {
1707        let mut p = PacinianCorpuscle::new();
1708        // Ramp up pressure rapidly
1709        let spikes: i32 = (0..100).map(|i| p.step(i as f64 * 2.0)).sum();
1710        assert!(spikes > 0, "Pacinian should fire on pressure onset");
1711    }
1712
1713    #[test]
1714    fn pacinian_adapts_to_sustained() {
1715        let mut p = PacinianCorpuscle::new();
1716        // Rapid onset
1717        let onset: i32 = (0..10).map(|i| p.step(i as f64 * 10.0)).sum();
1718        // Sustained (constant pressure, dp/dt ≈ 0)
1719        let sustained: i32 = (0..500).map(|_| p.step(100.0)).sum();
1720        // Should fire mostly during onset, not during sustained
1721        assert!(
1722            sustained <= onset + 5,
1723            "Pacinian should adapt to sustained: onset={onset}, sustained={sustained}"
1724        );
1725    }
1726
1727    #[test]
1728    fn pacinian_no_fire_at_rest() {
1729        let mut p = PacinianCorpuscle::new();
1730        let spikes: i32 = (0..500).map(|_| p.step(0.0)).sum();
1731        assert_eq!(spikes, 0);
1732    }
1733
1734    #[test]
1735    fn pacinian_closed_form_membrane_and_adaptation_relaxation() {
1736        let mut p = PacinianCorpuscle::new();
1737        p.v = -66.0;
1738        p.prev_pressure = 5.0;
1739        p.adapt = 0.2;
1740        p.gain = 0.0;
1741
1742        let pressure = 5.0;
1743        let dp = (pressure - p.prev_pressure) / p.dt;
1744        let drive = p.gain * dp.abs() - p.adapt;
1745        let expected_v = exact_relax_pacinian(p.v, p.v_rest + drive, p.tau, p.dt);
1746        let expected_adapt =
1747            exact_relax_pacinian(p.adapt, 0.5 * drive.max(0.0), p.tau_adapt, p.dt).max(0.0);
1748
1749        assert_eq!(p.step(pressure), 0);
1750        assert_eq!(p.prev_pressure, pressure);
1751        assert_close_pacinian(p.v, expected_v, 1e-12);
1752        assert_close_pacinian(p.adapt, expected_adapt, 1e-12);
1753    }
1754
1755    #[test]
1756    fn pacinian_invalid_input_preserves_state() {
1757        let mut p = PacinianCorpuscle::new();
1758        p.prev_pressure = 12.0;
1759        p.adapt = 0.4;
1760        let before = p.clone();
1761        assert_eq!(p.step(f64::NAN), 0);
1762        assert_eq!(p.v, before.v);
1763        assert_eq!(p.prev_pressure, before.prev_pressure);
1764        assert_eq!(p.adapt, before.adapt);
1765    }
1766
1767    #[test]
1768    fn pacinian_corrupted_state_preserved_on_step() {
1769        let mut p = PacinianCorpuscle::new();
1770        p.prev_pressure = f64::NAN;
1771        let before = p.clone();
1772        assert_eq!(p.step(10.0), 0);
1773        assert_eq!(p.v, before.v);
1774        assert!(p.prev_pressure.is_nan());
1775        assert_eq!(p.adapt, before.adapt);
1776    }
1777
1778    #[test]
1779    fn pacinian_invalid_voltage_preserved_on_step() {
1780        let mut p = PacinianCorpuscle::new();
1781        p.v = -100.1;
1782        let before = p.clone();
1783        assert_eq!(p.step(10.0), 0);
1784        assert_eq!(p.v, before.v);
1785        assert_eq!(p.prev_pressure, before.prev_pressure);
1786        assert_eq!(p.adapt, before.adapt);
1787    }
1788
1789    fn exact_relax_pacinian(value: f64, target: f64, tau: f64, dt: f64) -> f64 {
1790        target + (value - target) * (-dt / tau).exp()
1791    }
1792
1793    fn assert_close_pacinian(actual: f64, expected: f64, tolerance: f64) {
1794        assert!(
1795            (actual - expected).abs() <= tolerance,
1796            "actual={:.16e} expected={:.16e} tolerance={:.3e}",
1797            actual,
1798            expected,
1799            tolerance
1800        );
1801    }
1802
1803    #[test]
1804    fn pacinian_reset() {
1805        let mut p = PacinianCorpuscle::new();
1806        for i in 0..100 {
1807            p.step(i as f64);
1808        }
1809        p.reset();
1810        assert_eq!(p.prev_pressure, 0.0);
1811        assert_eq!(p.adapt, 0.0);
1812    }
1813
1814    // ── Nociceptor ───────────────────────────────────────────────
1815
1816    #[test]
1817    fn nociceptor_high_threshold() {
1818        let mut n = Nociceptor::new();
1819        // Sub-threshold
1820        let low: i32 = (0..500).map(|_| n.step(5.0)).sum();
1821        assert_eq!(low, 0, "nociceptor should not fire at low stimulus");
1822        // Supra-threshold
1823        n.reset();
1824        let high: i32 = (0..500).map(|_| n.step(50.0)).sum();
1825        assert!(high > 0, "nociceptor should fire at high stimulus");
1826    }
1827
1828    #[test]
1829    fn nociceptor_closed_form_membrane_and_sensitisation_decay() {
1830        let mut n = Nociceptor::new();
1831        n.v = -60.0;
1832        n.sensitisation = 4.0;
1833        n.gain = 0.5;
1834
1835        let stimulus = 8.0;
1836        let drive = n.gain * stimulus;
1837        let expected_v = exact_relax_nociceptor(n.v, n.v_rest + drive, n.tau, n.dt);
1838        let expected_sensitisation =
1839            exact_relax_nociceptor(n.sensitisation, 0.0, n.tau_sens, n.dt).max(0.0);
1840
1841        assert_eq!(n.step(stimulus), 0);
1842        assert_close_nociceptor(n.v, expected_v, 1e-12);
1843        assert_close_nociceptor(n.sensitisation, expected_sensitisation, 1e-12);
1844    }
1845
1846    #[test]
1847    fn nociceptor_invalid_input_preserves_state() {
1848        let mut n = Nociceptor::new();
1849        n.v = -60.0;
1850        n.sensitisation = 2.0;
1851        let before = n.clone();
1852
1853        assert_eq!(n.step(f64::NAN), 0);
1854        assert_eq!(n.v, before.v);
1855        assert_eq!(n.sensitisation, before.sensitisation);
1856    }
1857
1858    #[test]
1859    fn nociceptor_corrupted_state_preserved_on_step() {
1860        let mut n = Nociceptor::new();
1861        n.sensitisation = f64::NAN;
1862        let before = n.clone();
1863
1864        assert_eq!(n.step(50.0), 0);
1865        assert_eq!(n.v, before.v);
1866        assert!(n.sensitisation.is_nan());
1867    }
1868
1869    #[test]
1870    fn nociceptor_invalid_voltage_preserved_on_step() {
1871        let mut n = Nociceptor::new();
1872        n.v = -100.1;
1873        let before = n.clone();
1874
1875        assert_eq!(n.step(50.0), 0);
1876        assert_eq!(n.v, before.v);
1877        assert_eq!(n.sensitisation, before.sensitisation);
1878    }
1879
1880    #[test]
1881    fn nociceptor_overflowing_drive_preserves_state() {
1882        let mut n = Nociceptor::new();
1883        n.gain = f64::MAX;
1884        let before = n.clone();
1885
1886        assert_eq!(n.step(2.0), 0);
1887        assert_eq!(n.v, before.v);
1888        assert_eq!(n.sensitisation, before.sensitisation);
1889    }
1890
1891    fn exact_relax_nociceptor(value: f64, target: f64, tau: f64, dt: f64) -> f64 {
1892        target + (value - target) * (-dt / tau).exp()
1893    }
1894
1895    fn assert_close_nociceptor(actual: f64, expected: f64, tolerance: f64) {
1896        assert!(
1897            (actual - expected).abs() <= tolerance,
1898            "actual={:.16e} expected={:.16e} tolerance={:.3e}",
1899            actual,
1900            expected,
1901            tolerance
1902        );
1903    }
1904
1905    #[test]
1906    fn nociceptor_sensitisation() {
1907        let mut n = Nociceptor::new();
1908        // Strong stimulus → spikes → sensitisation builds
1909        for _ in 0..1000 {
1910            n.step(50.0);
1911        }
1912        assert!(n.sensitisation > 0.0, "sensitisation should increase");
1913        let sens = n.sensitisation;
1914        // After a long pause, sensitisation decays (tau_sens=5000ms, need many steps)
1915        for _ in 0..50000 {
1916            n.step(0.0);
1917        }
1918        assert!(
1919            n.sensitisation < sens,
1920            "sensitisation should decay: was {sens}, now {}",
1921            n.sensitisation
1922        );
1923    }
1924
1925    #[test]
1926    fn nociceptor_no_fire_without_stimulus() {
1927        let mut n = Nociceptor::new();
1928        let spikes: i32 = (0..1000).map(|_| n.step(0.0)).sum();
1929        assert_eq!(spikes, 0);
1930    }
1931
1932    #[test]
1933    fn nociceptor_reset() {
1934        let mut n = Nociceptor::new();
1935        for _ in 0..500 {
1936            n.step(50.0);
1937        }
1938        n.reset();
1939        assert_eq!(n.sensitisation, 0.0);
1940    }
1941
1942    // ── Olfactory Receptor ───────────────────────────────────────
1943
1944    #[test]
1945    fn olfactory_fires_with_odorant() {
1946        let mut o = OlfactoryReceptorNeuron::new();
1947        let spikes: i32 = (0..2000).map(|_| o.step(5.0)).sum();
1948        assert!(spikes > 0, "olfactory should fire with odorant");
1949    }
1950
1951    #[test]
1952    fn olfactory_adapts() {
1953        let mut o = OlfactoryReceptorNeuron::new();
1954        let first: i32 = (0..2000).map(|_| o.step(5.0)).sum();
1955        let second: i32 = (0..2000).map(|_| o.step(5.0)).sum();
1956        assert!(
1957            second <= first + 5,
1958            "olfactory should adapt: first={first}, second={second}"
1959        );
1960    }
1961
1962    #[test]
1963    fn olfactory_no_fire_without_odorant() {
1964        let mut o = OlfactoryReceptorNeuron::new();
1965        let spikes: i32 = (0..1000).map(|_| o.step(0.0)).sum();
1966        assert_eq!(spikes, 0);
1967    }
1968
1969    #[test]
1970    fn olfactory_reset() {
1971        let mut o = OlfactoryReceptorNeuron::new();
1972        for _ in 0..1000 {
1973            o.step(5.0);
1974        }
1975        o.reset();
1976        assert_eq!(o.camp, 0.0);
1977        assert_eq!(o.adapt, 0.0);
1978        assert_eq!(o.pde4, 0.0);
1979    }
1980
1981    #[test]
1982    fn olfactory_pde4_activates_with_odorant() {
1983        // PDE4 should rise when cAMP is elevated
1984        let mut o = OlfactoryReceptorNeuron::new();
1985        assert_eq!(o.pde4, 0.0);
1986        for _ in 0..5000 {
1987            o.step(5.0);
1988        }
1989        assert!(
1990            o.pde4 > 0.0,
1991            "PDE4 should activate with sustained odorant, got {}",
1992            o.pde4
1993        );
1994    }
1995
1996    #[test]
1997    fn olfactory_pde4_reduces_camp() {
1998        // With PDE4, sustained cAMP should be lower than without
1999        let mut with_pde4 = OlfactoryReceptorNeuron::new();
2000        let mut no_pde4 = OlfactoryReceptorNeuron::new();
2001        no_pde4.k_pde4 = 0.0; // disable PDE4
2002
2003        for _ in 0..10_000 {
2004            with_pde4.step(5.0);
2005            no_pde4.step(5.0);
2006        }
2007        assert!(
2008            with_pde4.camp < no_pde4.camp,
2009            "PDE4 should reduce cAMP: with={:.3} vs without={:.3}",
2010            with_pde4.camp,
2011            no_pde4.camp
2012        );
2013    }
2014
2015    #[test]
2016    fn olfactory_pde4_enhances_adaptation() {
2017        // PDE4 feedback should reduce late firing more than CaM alone
2018        let mut with_pde4 = OlfactoryReceptorNeuron::new();
2019        let mut no_pde4 = OlfactoryReceptorNeuron::new();
2020        no_pde4.k_pde4 = 0.0;
2021
2022        // Warm up
2023        for _ in 0..5000 {
2024            with_pde4.step(5.0);
2025            no_pde4.step(5.0);
2026        }
2027        // Measure late firing
2028        let spikes_with: i32 = (0..5000).map(|_| with_pde4.step(5.0)).sum();
2029        let spikes_no: i32 = (0..5000).map(|_| no_pde4.step(5.0)).sum();
2030        assert!(
2031            spikes_with <= spikes_no,
2032            "PDE4 should enhance adaptation: with={spikes_with}, without={spikes_no}"
2033        );
2034    }
2035
2036    // ── Taste Receptor ───────────────────────────────────────────
2037
2038    #[test]
2039    fn taste_depolarises_with_tastant() {
2040        let mut t = TasteReceptorCell::new();
2041        let v_rest = t.v;
2042        for _ in 0..500 {
2043            t.step(5.0);
2044        }
2045        assert!(t.v > v_rest, "taste cell should depolarise");
2046    }
2047
2048    #[test]
2049    fn taste_atp_release() {
2050        let mut t = TasteReceptorCell::new();
2051        for _ in 0..500 {
2052            t.step(5.0);
2053        }
2054        assert!(t.atp_release > 0.0, "ATP should be released");
2055    }
2056
2057    #[test]
2058    fn taste_no_response_without_tastant() {
2059        let mut t = TasteReceptorCell::new();
2060        for _ in 0..500 {
2061            t.step(0.0);
2062        }
2063        assert!((t.v - t.v_rest).abs() < 2.0);
2064        assert!(t.atp_release < 0.01);
2065    }
2066
2067    #[test]
2068    fn taste_ca_bounded() {
2069        let mut t = TasteReceptorCell::new();
2070        for _ in 0..10000 {
2071            t.step(100.0);
2072        }
2073        assert!(t.ca >= 0.0 && t.ca <= 1.0);
2074        assert!(t.ip3 >= 0.0 && t.ip3 <= 1.0);
2075    }
2076
2077    #[test]
2078    fn taste_reset() {
2079        let mut t = TasteReceptorCell::new();
2080        for _ in 0..500 {
2081            t.step(5.0);
2082        }
2083        t.reset();
2084        assert_eq!(t.ca, 0.0);
2085        assert_eq!(t.ip3, 0.0);
2086        assert_eq!(t.atp_release, 0.0);
2087    }
2088}
2089
2090/// Direction-selective retinal ganglion cell (DS-RGC) with On/Off sub-types.
2091///
2092/// Models the centre-surround receptive field with temporal derivative
2093/// for direction selectivity. On-centre RGC responds to light increments,
2094/// Off-centre responds to decrements.
2095///
2096///   response = w_c · (I - I_prev) ± w_s · surround_inhibition
2097///   spike if response > θ
2098///
2099/// Reference: Gollisch & Meister (2010) "Eye smarter than scientists believed",
2100/// Masland (2012) "The neuronal organization of the retina".
2101#[derive(Clone, Debug)]
2102pub struct DirectionSelectiveRGC {
2103    pub v: f64,
2104    pub tau: f64,
2105    pub theta: f64,
2106    pub dt: f64,
2107    pub is_on_centre: bool,
2108    prev_intensity: f64,
2109    surround: f64,
2110    pub w_centre: f64,
2111    pub w_surround: f64,
2112    pub direction_pref: f64,
2113}
2114
2115impl DirectionSelectiveRGC {
2116    pub fn new_on() -> Self {
2117        Self {
2118            v: 0.0,
2119            tau: 10.0,
2120            theta: 0.5,
2121            dt: 1.0,
2122            is_on_centre: true,
2123            prev_intensity: 0.0,
2124            surround: 0.0,
2125            w_centre: 1.0,
2126            w_surround: 0.3,
2127            direction_pref: 0.0,
2128        }
2129    }
2130
2131    pub fn new_off() -> Self {
2132        let mut cell = Self::new_on();
2133        cell.is_on_centre = false;
2134        cell
2135    }
2136
2137    fn valid_runtime(&self) -> bool {
2138        [
2139            self.v,
2140            self.tau,
2141            self.theta,
2142            self.dt,
2143            self.prev_intensity,
2144            self.surround,
2145            self.w_centre,
2146            self.w_surround,
2147            self.direction_pref,
2148        ]
2149        .iter()
2150        .all(|x| x.is_finite())
2151            && self.tau > 0.0
2152            && self.theta > 0.0
2153            && self.dt > 0.0
2154            && self.prev_intensity >= 0.0
2155            && self.surround >= 0.0
2156            && self.w_centre >= 0.0
2157            && self.w_surround >= 0.0
2158    }
2159
2160    /// Step with local intensity and surround mean intensity.
2161    pub fn step_rf(&mut self, intensity: f64, surround_mean: f64) -> i32 {
2162        if !intensity.is_finite()
2163            || !surround_mean.is_finite()
2164            || intensity < 0.0
2165            || surround_mean < 0.0
2166            || !self.valid_runtime()
2167        {
2168            return 0;
2169        }
2170        let temporal_diff = intensity - self.prev_intensity;
2171        let centre_response = if self.is_on_centre {
2172            self.w_centre * temporal_diff
2173        } else {
2174            -self.w_centre * temporal_diff
2175        };
2176
2177        let next_surround = 0.9 * self.surround + 0.1 * surround_mean;
2178        let surround_inhib = self.w_surround * next_surround;
2179        let drive = centre_response - surround_inhib;
2180        let decay = (-self.dt / self.tau).exp();
2181        let next_v = drive + (self.v - drive) * decay;
2182        if !next_surround.is_finite()
2183            || !drive.is_finite()
2184            || !decay.is_finite()
2185            || !next_v.is_finite()
2186            || next_surround < 0.0
2187        {
2188            return 0;
2189        }
2190
2191        self.prev_intensity = intensity;
2192        self.surround = next_surround;
2193        if next_v >= self.theta {
2194            self.v = 0.0;
2195            1
2196        } else {
2197            self.v = next_v;
2198            0
2199        }
2200    }
2201
2202    /// Simple step (no surround).
2203    pub fn step(&mut self, current: f64) -> i32 {
2204        self.step_rf(current, 0.0)
2205    }
2206
2207    pub fn reset(&mut self) {
2208        self.v = 0.0;
2209        self.prev_intensity = 0.0;
2210        self.surround = 0.0;
2211    }
2212}
2213
2214impl Default for DirectionSelectiveRGC {
2215    fn default() -> Self {
2216        Self::new_on()
2217    }
2218}
2219
2220/// Cochlear inner hair cell: mechano-electrical transduction.
2221///
2222/// Converts basilar membrane displacement (mechanical) into receptor potential
2223/// via stereocilia tip-link channels with Boltzmann activation:
2224///
2225///   P_open(x) = 1 / (1 + exp(-(x - x_0) / δ))
2226///   I_MET = g_max · P_open · (V - E_MET)
2227///   C dV/dt = -g_L(V - E_L) - I_MET + I_ext
2228///
2229/// Reference: Meddis (2006), Zilany et al. (2009, 2014).
2230#[derive(Clone, Debug)]
2231pub struct CochlearHairCell {
2232    pub v: f64,
2233    pub g_max: f64,
2234    pub e_met: f64,
2235    pub g_l: f64,
2236    pub e_l: f64,
2237    pub cap: f64,
2238    pub x0: f64,
2239    pub delta: f64,
2240    pub dt: f64,
2241    pub glutamate_release: f64,
2242}
2243
2244impl CochlearHairCell {
2245    pub fn new() -> Self {
2246        Self {
2247            v: -60.0,
2248            g_max: 10.0,
2249            e_met: 0.0,
2250            g_l: 1.0,
2251            e_l: -60.0,
2252            cap: 10.0,
2253            x0: 0.0,
2254            delta: 0.1,
2255            dt: 0.01,
2256            glutamate_release: 0.0,
2257        }
2258    }
2259
2260    /// Boltzmann activation of MET channels.
2261    fn p_open(&self, displacement: f64) -> f64 {
2262        let z = (displacement - self.x0) / self.delta;
2263        if z >= 0.0 {
2264            1.0 / (1.0 + (-z).exp())
2265        } else {
2266            let ez = z.exp();
2267            ez / (1.0 + ez)
2268        }
2269    }
2270
2271    fn valid_runtime(&self) -> bool {
2272        [
2273            self.v,
2274            self.g_max,
2275            self.e_met,
2276            self.g_l,
2277            self.e_l,
2278            self.cap,
2279            self.x0,
2280            self.delta,
2281            self.dt,
2282            self.glutamate_release,
2283        ]
2284        .iter()
2285        .all(|x| x.is_finite())
2286            && self.g_max >= 0.0
2287            && self.g_l > 0.0
2288            && self.cap > 0.0
2289            && self.delta > 0.0
2290            && self.dt > 0.0
2291            && self.glutamate_release >= 0.0
2292    }
2293
2294    /// Step with basilar membrane displacement.
2295    pub fn step(&mut self, displacement: f64) -> i32 {
2296        if !self.valid_runtime() || !displacement.is_finite() {
2297            return 0;
2298        }
2299        let po = self.p_open(displacement);
2300        let g_met = self.g_max * po;
2301        let g_total = self.g_l + g_met;
2302        if !(g_total.is_finite() && g_total > 0.0) {
2303            return 0;
2304        }
2305        let v_inf = (self.g_l * self.e_l + g_met * self.e_met) / g_total;
2306        let candidate_v = v_inf + (self.v - v_inf) * (-(g_total / self.cap) * self.dt).exp();
2307        let candidate_release = (candidate_v + 60.0).max(0.0) / 40.0;
2308        if !(candidate_v.is_finite() && candidate_release.is_finite()) {
2309            return 0;
2310        }
2311        self.v = candidate_v;
2312
2313        // Graded glutamate release (no spike, but we return 1 if above threshold).
2314        self.glutamate_release = candidate_release;
2315        if self.glutamate_release > 0.5 {
2316            1
2317        } else {
2318            0
2319        }
2320    }
2321
2322    pub fn reset(&mut self) {
2323        self.v = self.e_l;
2324        self.glutamate_release = 0.0;
2325    }
2326}
2327
2328impl Default for CochlearHairCell {
2329    fn default() -> Self {
2330        Self::new()
2331    }
2332}
2333
2334#[cfg(test)]
2335mod gap_sensory_tests {
2336    use super::*;
2337
2338    #[test]
2339    fn rgc_on_responds_to_light_increase() {
2340        let mut cell = DirectionSelectiveRGC::new_on();
2341        // Flash: darkness then bright.
2342        for _ in 0..10 {
2343            cell.step_rf(0.0, 0.0);
2344        }
2345        let mut spikes = 0;
2346        for _ in 0..20 {
2347            spikes += cell.step_rf(6.0, 0.0);
2348        }
2349        assert!(spikes > 0, "On-centre must respond to light increase");
2350    }
2351
2352    #[test]
2353    fn rgc_off_responds_to_light_decrease() {
2354        let mut cell = DirectionSelectiveRGC::new_off();
2355        cell.theta = 0.1; // Lower threshold to detect transients.
2356                          // Alternate bright/dark to produce transitions.
2357        let mut spikes = 0;
2358        for i in 0..400 {
2359            let intensity = if (i / 10) % 2 == 0 { 5.0 } else { 0.0 };
2360            spikes += cell.step_rf(intensity, 0.0);
2361        }
2362        assert!(spikes > 0, "Off-centre must respond to light transitions");
2363    }
2364
2365    #[test]
2366    fn rgc_surround_inhibition() {
2367        let mut no_surr = DirectionSelectiveRGC::new_on();
2368        let mut with_surr = DirectionSelectiveRGC::new_on();
2369        // Same centre stimulus, different surround.
2370        let mut spikes_no = 0;
2371        let mut spikes_surr = 0;
2372        for i in 0..200 {
2373            let intensity = if i % 10 == 0 { 3.0 } else { 0.0 };
2374            spikes_no += no_surr.step_rf(intensity, 0.0);
2375            spikes_surr += with_surr.step_rf(intensity, 2.0);
2376        }
2377        assert!(
2378            spikes_surr <= spikes_no,
2379            "Surround should inhibit: surr={spikes_surr} <= no={spikes_no}"
2380        );
2381    }
2382
2383    #[test]
2384    fn rgc_exact_membrane_relaxation() {
2385        let mut cell = DirectionSelectiveRGC::new_on();
2386        cell.tau = 7.0;
2387        cell.theta = 100.0;
2388        cell.dt = 1.25;
2389        cell.w_centre = 1.4;
2390        cell.w_surround = 0.2;
2391        cell.v = 0.35;
2392        let expected_surround = 0.9 * cell.surround + 0.1 * 0.5;
2393        let expected_drive =
2394            cell.w_centre * (2.0 - cell.prev_intensity) - cell.w_surround * expected_surround;
2395        let expected_v = expected_drive + (cell.v - expected_drive) * (-cell.dt / cell.tau).exp();
2396        assert_eq!(cell.step_rf(2.0, 0.5), 0);
2397        assert!((cell.v - expected_v).abs() < 1e-12);
2398        assert!((cell.surround - expected_surround).abs() < 1e-12);
2399    }
2400
2401    #[test]
2402    fn rgc_invalid_drive_preserves_state() {
2403        let mut cell = DirectionSelectiveRGC::new_on();
2404        let before = (cell.v, cell.prev_intensity, cell.surround);
2405        assert_eq!(cell.step_rf(f64::NAN, 0.0), 0);
2406        assert_eq!((cell.v, cell.prev_intensity, cell.surround), before);
2407    }
2408
2409    #[test]
2410    fn rgc_corrupt_runtime_state_preserves_state() {
2411        let mut cell = DirectionSelectiveRGC::new_on();
2412        cell.surround = f64::INFINITY;
2413        let before = (cell.v, cell.prev_intensity, cell.surround);
2414        assert_eq!(cell.step_rf(1.0, 0.0), 0);
2415        assert_eq!((cell.v, cell.prev_intensity, cell.surround), before);
2416    }
2417
2418    #[test]
2419    fn cochlear_displacement_depolarises() {
2420        let mut cell = CochlearHairCell::new();
2421        let v_rest = cell.v;
2422        for _ in 0..100 {
2423            cell.step(0.5);
2424        }
2425        assert!(
2426            cell.v > v_rest,
2427            "Positive displacement should depolarise: {:.2} > {:.2}",
2428            cell.v,
2429            v_rest
2430        );
2431    }
2432
2433    #[test]
2434    fn cochlear_matches_closed_form_membrane_relaxation() {
2435        let mut cell = CochlearHairCell::new();
2436        let po = 1.0 / (1.0 + (-(0.0 - cell.x0) / cell.delta).exp());
2437        let g_met = cell.g_max * po;
2438        let g_total = cell.g_l + g_met;
2439        let v_inf = (cell.g_l * cell.e_l + g_met * cell.e_met) / g_total;
2440        let expected = v_inf + (cell.v - v_inf) * (-(g_total / cell.cap) * cell.dt).exp();
2441        let spike = cell.step(0.0);
2442        assert!(spike == 0 || spike == 1);
2443        assert!((cell.v - expected).abs() < 1e-12);
2444    }
2445
2446    #[test]
2447    fn cochlear_invalid_runtime_preserves_state() {
2448        let mut cell = CochlearHairCell::new();
2449        cell.v = -55.0;
2450        cell.glutamate_release = 0.125;
2451        let before = (cell.v, cell.glutamate_release);
2452        cell.cap = -1.0;
2453        assert_eq!(cell.step(0.25), 0);
2454        assert_eq!((cell.v, cell.glutamate_release), before);
2455    }
2456
2457    #[test]
2458    fn cochlear_graded_release() {
2459        let mut cell = CochlearHairCell::new();
2460        for _ in 0..200 {
2461            cell.step(1.0);
2462        }
2463        assert!(
2464            cell.glutamate_release > 0.0,
2465            "Should release glutamate: {:.4}",
2466            cell.glutamate_release
2467        );
2468    }
2469
2470    #[test]
2471    fn cochlear_zero_displacement_rest() {
2472        let mut cell = CochlearHairCell::new();
2473        for _ in 0..100 {
2474            cell.step(0.0);
2475        }
2476        // At P_open(0) = 0.5, steady MET current depolarises V above E_L.
2477        assert!(
2478            cell.v > -80.0 && cell.v < 0.0,
2479            "Zero displacement → physiological range: {:.2}",
2480            cell.v
2481        );
2482    }
2483
2484    #[test]
2485    fn cochlear_reset() {
2486        let mut cell = CochlearHairCell::new();
2487        for _ in 0..100 {
2488            cell.step(1.0);
2489        }
2490        cell.reset();
2491        assert_eq!(cell.v, cell.e_l);
2492        assert_eq!(cell.glutamate_release, 0.0);
2493    }
2494}