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    /// Step with pressure (arbitrary units, ≥ 0). Returns spike (1/0).
683    pub fn step(&mut self, pressure: f64) -> i32 {
684        let drive = self.gain * pressure.max(0.0) - self.adapt;
685        self.v += (-(self.v - self.v_rest) + drive) / self.tau * self.dt;
686        self.adapt +=
687            (self.a_adapt * (self.v - self.v_rest) - self.adapt) / self.tau_adapt * self.dt;
688
689        if self.v >= self.v_threshold {
690            self.v = self.v_reset;
691            1
692        } else {
693            0
694        }
695    }
696
697    pub fn reset(&mut self) {
698        self.v = self.v_rest;
699        self.adapt = 0.0;
700    }
701}
702
703impl Default for MerkelCell {
704    fn default() -> Self {
705        Self::new()
706    }
707}
708
709// ═══════════════════════════════════════════════════════════════════
710// Pacinian Corpuscle — rapidly adapting mechanoreceptor
711// ═══════════════════════════════════════════════════════════════════
712
713/// Pacinian corpuscle — rapidly adapting (RA/RAII) mechanoreceptor.
714///
715/// Responds to vibration and transient pressure changes.
716/// Band-pass filtering via lamellar structure: only signals
717/// with rapid onset/offset produce responses. Derivative-like.
718///
719/// Based on Loewenstein & Skalak 1966 / Bell et al. 1994.
720#[derive(Clone, Debug)]
721pub struct PacinianCorpuscle {
722    pub v: f64,
723    pub v_rest: f64,
724    pub v_reset: f64,
725    pub v_threshold: f64,
726    pub tau: f64,
727    pub prev_pressure: f64,
728    pub adapt: f64,
729    pub tau_adapt: f64,
730    pub gain: f64,
731    pub dt: f64,
732}
733
734impl PacinianCorpuscle {
735    pub fn new() -> Self {
736        Self {
737            v: -65.0,
738            v_rest: -65.0,
739            v_reset: -70.0,
740            v_threshold: -50.0,
741            tau: 2.0,
742            prev_pressure: 0.0,
743            adapt: 0.0,
744            tau_adapt: 5.0, // Fast adaptation
745            gain: 10.0,     // High gain on derivative
746            dt: 0.5,
747        }
748    }
749
750    /// Step with pressure (arbitrary units). Returns spike (1/0).
751    pub fn step(&mut self, pressure: f64) -> i32 {
752        // Derivative-like response: rate of change drives the neuron
753        let dp = (pressure - self.prev_pressure) / self.dt;
754        self.prev_pressure = pressure;
755
756        let drive = self.gain * dp.abs() - self.adapt;
757        self.v += (-(self.v - self.v_rest) + drive) / self.tau * self.dt;
758        self.adapt += (0.5 * drive.max(0.0) - self.adapt) / self.tau_adapt * self.dt;
759
760        if self.v >= self.v_threshold {
761            self.v = self.v_reset;
762            1
763        } else {
764            0
765        }
766    }
767
768    pub fn reset(&mut self) {
769        self.v = self.v_rest;
770        self.prev_pressure = 0.0;
771        self.adapt = 0.0;
772    }
773}
774
775impl Default for PacinianCorpuscle {
776    fn default() -> Self {
777        Self::new()
778    }
779}
780
781// ═══════════════════════════════════════════════════════════════════
782// Nociceptor — pain receptor
783// ═══════════════════════════════════════════════════════════════════
784
785/// Nociceptor — high-threshold pain receptor neuron.
786///
787/// Only fires above noxious threshold. Sensitisation: repeated
788/// stimulation lowers threshold (hyperalgesia). TTX-resistant Na+
789/// channels provide slow, broad APs.
790///
791/// Based on Basbaum et al. 2009 / Gold & Gebhart 2010.
792#[derive(Clone, Debug)]
793pub struct Nociceptor {
794    pub v: f64,
795    pub v_rest: f64,
796    pub v_reset: f64,
797    pub v_threshold: f64,
798    pub tau: f64,
799    pub sensitisation: f64, // Threshold reduction (mV)
800    pub tau_sens: f64,      // Sensitisation decay (ms)
801    pub sens_rate: f64,     // Sensitisation buildup rate
802    pub gain: f64,
803    pub dt: f64,
804}
805
806impl Nociceptor {
807    pub fn new() -> Self {
808        Self {
809            v: -65.0,
810            v_rest: -65.0,
811            v_reset: -68.0,
812            v_threshold: -30.0, // High threshold
813            tau: 8.0,
814            sensitisation: 0.0,
815            tau_sens: 5000.0, // Very slow decay (seconds)
816            sens_rate: 0.5,
817            gain: 1.0,
818            dt: 0.5,
819        }
820    }
821
822    /// Step with noxious stimulus intensity (≥ 0). Returns spike (1/0).
823    pub fn step(&mut self, stimulus: f64) -> i32 {
824        let drive = self.gain * stimulus.max(0.0);
825        self.v += (-(self.v - self.v_rest) + drive) / self.tau * self.dt;
826
827        let effective_threshold = self.v_threshold - self.sensitisation;
828        if self.v >= effective_threshold {
829            self.v = self.v_reset;
830            // Spike causes sensitisation buildup (capped at 10 mV)
831            self.sensitisation = (self.sensitisation + self.sens_rate).min(10.0);
832            1
833        } else {
834            // Sensitisation slowly decays
835            self.sensitisation += -self.sensitisation / self.tau_sens * self.dt;
836            if self.sensitisation < 0.0 {
837                self.sensitisation = 0.0;
838            }
839            0
840        }
841    }
842
843    pub fn reset(&mut self) {
844        self.v = self.v_rest;
845        self.sensitisation = 0.0;
846    }
847}
848
849impl Default for Nociceptor {
850    fn default() -> Self {
851        Self::new()
852    }
853}
854
855// ═══════════════════════════════════════════════════════════════════
856// Olfactory Receptor Neuron
857// ═══════════════════════════════════════════════════════════════════
858
859/// Olfactory receptor neuron — chemical-to-spike transducer.
860///
861/// Odorant binding → Golf → adenylyl cyclase → cAMP → CNG channels.
862/// Produces spiking output to olfactory bulb.
863///
864/// Adaptation via two pathways:
865/// - **Ca²⁺/CaM feedback** on CNG channels (fast, ~500 ms)
866/// - **PDE4 negative feedback** on cAMP (slow, ~300 ms): cAMP → PKA → PDE4 ↑ → cAMP ↓
867///
868/// Based on Rospars et al. 2008 / Firestein 2001.
869#[derive(Clone, Debug)]
870pub struct OlfactoryReceptorNeuron {
871    pub v: f64,
872    pub v_rest: f64,
873    pub v_reset: f64,
874    pub v_threshold: f64,
875    pub tau: f64,
876    pub camp: f64,      // Normalised cAMP [0, 1]
877    pub adapt: f64,     // Ca²⁺/CaM adaptation
878    pub pde4: f64,      // PDE4 activity (tracks cAMP with delay)
879    pub tau_camp: f64,  // cAMP dynamics (ms)
880    pub tau_adapt: f64, // CaM adaptation tau
881    pub tau_pde4: f64,  // PDE4 activation tau (ms)
882    pub k_pde4: f64,    // PDE4 degradation rate on cAMP
883    pub gain: f64,
884    pub dt: f64,
885}
886
887impl OlfactoryReceptorNeuron {
888    pub fn new() -> Self {
889        Self {
890            v: -65.0,
891            v_rest: -65.0,
892            v_reset: -70.0,
893            v_threshold: -45.0,
894            tau: 5.0,
895            camp: 0.0,
896            adapt: 0.0,
897            pde4: 0.0,
898            tau_camp: 50.0,
899            tau_adapt: 500.0,
900            tau_pde4: 300.0, // PDE4 activation ~300 ms (slow negative feedback)
901            k_pde4: 1.5,     // PDE4 degradation strength
902            gain: 1.5,
903            dt: 0.5,
904        }
905    }
906
907    /// Step with odorant concentration (arbitrary units, ≥ 0). Returns spike (1/0).
908    pub fn step(&mut self, concentration: f64) -> i32 {
909        let conc = concentration.max(0.0);
910
911        // cAMP production: Hill function of odorant, reduced by CaM adaptation
912        let camp_production = conc / (conc + 1.0) * (1.0 - 0.8 * self.adapt);
913        // PDE4 degradation: proportional to PDE4 activity × cAMP
914        let pde4_degradation = self.k_pde4 * self.pde4 * self.camp;
915        let camp_target = (camp_production - pde4_degradation).max(0.0);
916        self.camp += (camp_target - self.camp) / self.tau_camp * self.dt;
917        self.camp = self.camp.clamp(0.0, 1.0);
918
919        // PDE4 activation: tracks cAMP with delay (cAMP → PKA → PDE4 upregulation)
920        self.pde4 += (self.camp - self.pde4) / self.tau_pde4 * self.dt;
921        self.pde4 = self.pde4.clamp(0.0, 1.0);
922
923        let drive = self.gain * self.camp * 50.0; // Scale to mV
924        self.v += (-(self.v - self.v_rest) + drive) / self.tau * self.dt;
925
926        // Ca²⁺/CaM adaptation (fast pathway)
927        let ca_proxy = if self.v > self.v_rest {
928            (self.v - self.v_rest) / 20.0
929        } else {
930            0.0
931        };
932        self.adapt += (ca_proxy - self.adapt) / self.tau_adapt * self.dt;
933        self.adapt = self.adapt.clamp(0.0, 1.0);
934
935        if self.v >= self.v_threshold {
936            self.v = self.v_reset;
937            1
938        } else {
939            0
940        }
941    }
942
943    pub fn reset(&mut self) {
944        self.v = self.v_rest;
945        self.camp = 0.0;
946        self.adapt = 0.0;
947        self.pde4 = 0.0;
948    }
949}
950
951impl Default for OlfactoryReceptorNeuron {
952    fn default() -> Self {
953        Self::new()
954    }
955}
956
957// ═══════════════════════════════════════════════════════════════════
958// Taste Receptor Cell
959// ═══════════════════════════════════════════════════════════════════
960
961/// Taste receptor cell — gustatory transducer.
962///
963/// Type II cells: GPCR → PLC → IP3 → Ca2+ release → ATP secretion.
964/// Graded output (ATP release proportional to Ca2+), no conventional
965/// spikes. Adapts via Ca2+ pump.
966///
967/// Based on Chaudhari & Roper 2010 / Liman et al. 2014.
968#[derive(Clone, Debug)]
969pub struct TasteReceptorCell {
970    pub v: f64,
971    pub v_rest: f64,
972    pub tau: f64,
973    pub ca: f64,  // Intracellular Ca2+ (normalised)
974    pub ip3: f64, // IP3 concentration (normalised)
975    pub tau_ip3: f64,
976    pub tau_ca: f64,
977    pub gain: f64,
978    pub atp_release: f64, // Output: ATP release rate
979    pub dt: f64,
980}
981
982impl TasteReceptorCell {
983    pub fn new() -> Self {
984        Self {
985            v: -50.0,
986            v_rest: -50.0,
987            tau: 10.0,
988            ca: 0.0,
989            ip3: 0.0,
990            tau_ip3: 100.0,
991            tau_ca: 200.0,
992            gain: 1.0,
993            atp_release: 0.0,
994            dt: 0.5,
995        }
996    }
997
998    /// Step with tastant concentration (≥ 0). Returns receptor potential (mV).
999    pub fn step(&mut self, tastant: f64) -> f64 {
1000        let conc = tastant.max(0.0);
1001        // GPCR → IP3
1002        let ip3_target = conc / (conc + 0.5);
1003        self.ip3 += (ip3_target - self.ip3) / self.tau_ip3 * self.dt;
1004        self.ip3 = self.ip3.clamp(0.0, 1.0);
1005
1006        // IP3 → Ca2+ release from ER
1007        let ca_release = self.ip3.powi(2) * (1.0 - self.ca);
1008        self.ca += (ca_release - self.ca / self.tau_ca) * self.dt;
1009        self.ca = self.ca.clamp(0.0, 1.0);
1010
1011        // Ca2+ → depolarisation (TRPM5 channel)
1012        let i_trpm5 = self.gain * self.ca * 20.0;
1013        self.v += (-(self.v - self.v_rest) + i_trpm5) / self.tau * self.dt;
1014
1015        // ATP release proportional to Ca2+
1016        self.atp_release = self.ca;
1017
1018        self.v
1019    }
1020
1021    pub fn reset(&mut self) {
1022        self.v = self.v_rest;
1023        self.ca = 0.0;
1024        self.ip3 = 0.0;
1025        self.atp_release = 0.0;
1026    }
1027}
1028
1029impl Default for TasteReceptorCell {
1030    fn default() -> Self {
1031        Self::new()
1032    }
1033}
1034
1035// ═══════════════════════════════════════════════════════════════════
1036// Tests
1037// ═══════════════════════════════════════════════════════════════════
1038
1039#[cfg(test)]
1040mod tests {
1041    use super::*;
1042
1043    // ── Inner Hair Cell ──────────────────────────────────────────
1044
1045    #[test]
1046    fn ihc_depolarises_with_displacement() {
1047        let mut c = InnerHairCell::new();
1048        let v_rest = c.v;
1049        for _ in 0..200 {
1050            c.step(50.0);
1051        }
1052        assert!(c.v > v_rest, "IHC should depolarise: v={}", c.v);
1053    }
1054
1055    #[test]
1056    fn ihc_no_change_at_zero() {
1057        let mut c = InnerHairCell::new();
1058        for _ in 0..200 {
1059            c.step(0.0);
1060        }
1061        assert!(
1062            (c.v - c.v_rest).abs() < 5.0,
1063            "IHC should stay near rest with no displacement"
1064        );
1065    }
1066
1067    #[test]
1068    fn ihc_ca_increases_with_depolarisation() {
1069        let mut c = InnerHairCell::new();
1070        for _ in 0..200 {
1071            c.step(60.0);
1072        }
1073        assert!(c.ca > 0.0, "Ca2+ should increase during depolarisation");
1074    }
1075
1076    #[test]
1077    fn ihc_reset_roundtrip() {
1078        let mut c = InnerHairCell::new();
1079        for _ in 0..100 {
1080            c.step(50.0);
1081        }
1082        c.reset();
1083        assert_eq!(c.v, c.v_rest);
1084        assert_eq!(c.ca, 0.05);
1085        assert_eq!(c.q, 8.0);
1086        assert_eq!(c.c, 0.0);
1087        assert_eq!(c.w, 0.0);
1088    }
1089
1090    #[test]
1091    fn ihc_bounded() {
1092        let mut c = InnerHairCell::new();
1093        for _ in 0..10000 {
1094            c.step(100.0);
1095        }
1096        assert!(c.v.is_finite());
1097        assert!(c.ca.is_finite());
1098    }
1099
1100    #[test]
1101    fn ihc_vesicle_pool_depletes() {
1102        // Sustained stimulation should deplete available vesicles (q)
1103        let mut c = InnerHairCell::new();
1104        let q0 = c.q;
1105        for _ in 0..5000 {
1106            c.step(80.0);
1107        }
1108        assert!(
1109            c.q < q0,
1110            "Vesicle pool should deplete: q0={q0}, q_now={}",
1111            c.q
1112        );
1113    }
1114
1115    #[test]
1116    fn ihc_cleft_transmitter_rises() {
1117        // Stimulation should release transmitter into cleft
1118        let mut c = InnerHairCell::new();
1119        for _ in 0..2000 {
1120            c.step(80.0);
1121        }
1122        assert!(
1123            c.c > 0.0,
1124            "Cleft transmitter should rise with stimulation: c={}",
1125            c.c
1126        );
1127    }
1128
1129    #[test]
1130    fn ihc_reprocessing_store_fills() {
1131        // Reuptake from cleft should fill reprocessing store
1132        let mut c = InnerHairCell::new();
1133        for _ in 0..5000 {
1134            c.step(80.0);
1135        }
1136        assert!(
1137            c.w > 0.0,
1138            "Reprocessing store should fill via reuptake: w={}",
1139            c.w
1140        );
1141    }
1142
1143    #[test]
1144    fn ihc_pool_mass_conserved() {
1145        // Total transmitter (q + c + w) should not exceed m_pool
1146        let mut c = InnerHairCell::new();
1147        for _ in 0..10000 {
1148            c.step(80.0);
1149        }
1150        let total = c.q + c.c + c.w;
1151        assert!(
1152            total <= c.m_pool * 1.5,
1153            "Total transmitter should be bounded: q+c+w={total:.2}, m={}",
1154            c.m_pool
1155        );
1156    }
1157
1158    #[test]
1159    fn ihc_performance() {
1160        let mut c = InnerHairCell::new();
1161        let start = std::time::Instant::now();
1162        for _ in 0..100_000 {
1163            c.step(50.0);
1164        }
1165        assert!(start.elapsed().as_millis() < 50);
1166    }
1167
1168    // ── Outer Hair Cell ──────────────────────────────────────────
1169
1170    #[test]
1171    fn ohc_depolarises_and_motility() {
1172        let mut c = OuterHairCell::new();
1173        for _ in 0..200 {
1174            c.step(40.0);
1175        }
1176        assert!(c.v > c.v_rest);
1177        assert!(c.motility.abs() > 0.01, "OHC should show motility");
1178    }
1179
1180    #[test]
1181    fn ohc_prestin_bidirectional() {
1182        // Depolarisation → contraction (positive motility)
1183        // Hyperpolarisation → elongation (negative motility)
1184        let mut dep = OuterHairCell::new();
1185        dep.v = -20.0; // Depolarised
1186        dep.step(0.0); // Update motility
1187        let mot_dep = dep.motility;
1188
1189        let mut hyp = OuterHairCell::new();
1190        hyp.v = -80.0; // Hyperpolarised
1191        hyp.step(0.0);
1192        let mot_hyp = hyp.motility;
1193
1194        assert!(
1195            mot_dep > 0.0,
1196            "Depolarisation should contract: motility={mot_dep:.3}"
1197        );
1198        assert!(
1199            mot_hyp < 0.0,
1200            "Hyperpolarisation should elongate: motility={mot_hyp:.3}"
1201        );
1202    }
1203
1204    #[test]
1205    fn ohc_prestin_asymmetric() {
1206        // Contraction should be larger than elongation (asymmetry)
1207        // Drive OHC to depolarised state with strong input
1208        let mut dep = OuterHairCell::new();
1209        for _ in 0..2000 {
1210            dep.step(80.0);
1211        } // Strong depolarisation
1212        let contraction = dep.motility;
1213
1214        // Drive OHC with zero input → stays near rest (hyperpolarised relative to V_pk)
1215        let mut hyp = OuterHairCell::new();
1216        for _ in 0..2000 {
1217            hyp.step(0.0);
1218        } // Near rest = hyperpolarised vs V_pk
1219        let elongation = hyp.motility;
1220
1221        // At rest (V=-70), prestin is mostly in expanded state (elongation)
1222        // With strong input (depolarised), prestin contracts
1223        // Due to asymmetry factor, |contraction| > |elongation|
1224        assert!(
1225            contraction.abs() > elongation.abs() * 0.5,
1226            "Asymmetric prestin: contraction={contraction:.3}, elongation={elongation:.3}"
1227        );
1228    }
1229
1230    #[test]
1231    fn ohc_reset() {
1232        let mut c = OuterHairCell::new();
1233        for _ in 0..100 {
1234            c.step(40.0);
1235        }
1236        c.reset();
1237        assert_eq!(c.motility, 0.0);
1238    }
1239
1240    #[test]
1241    fn ohc_bounded() {
1242        let mut c = OuterHairCell::new();
1243        for _ in 0..10000 {
1244            c.step(100.0);
1245        }
1246        assert!(c.v.is_finite());
1247    }
1248
1249    // ── Rod Photoreceptor ────────────────────────────────────────
1250
1251    #[test]
1252    fn rod_hyperpolarises_with_light() {
1253        let mut r = RodPhotoreceptor::new();
1254        let v_dark = r.v;
1255        for _ in 0..1000 {
1256            r.step(100.0);
1257        }
1258        assert!(r.v < v_dark, "rod should hyperpolarise: v={}", r.v);
1259    }
1260
1261    #[test]
1262    fn rod_stays_dark_without_light() {
1263        let mut r = RodPhotoreceptor::new();
1264        for _ in 0..500 {
1265            r.step(0.0);
1266        }
1267        assert!((r.v - r.v_dark).abs() < 1.0);
1268    }
1269
1270    #[test]
1271    fn rod_slow_recovery() {
1272        let mut r = RodPhotoreceptor::new();
1273        // Flash
1274        for _ in 0..500 {
1275            r.step(200.0);
1276        }
1277        let v_after_flash = r.v;
1278        // Dark: slow recovery
1279        for _ in 0..1000 {
1280            r.step(0.0);
1281        }
1282        assert!(r.v > v_after_flash, "rod should recover in dark");
1283        assert!(r.v < r.v_dark, "rod should not fully recover in 1000 steps");
1284    }
1285
1286    #[test]
1287    fn rod_cgmp_bounded() {
1288        let mut r = RodPhotoreceptor::new();
1289        for _ in 0..10000 {
1290            r.step(1000.0);
1291        }
1292        assert!(
1293            r.cgmp >= 0.0 && r.cgmp <= 1.5,
1294            "cGMP should be bounded: {}",
1295            r.cgmp
1296        );
1297        r.reset();
1298        for _ in 0..10000 {
1299            r.step(-10.0);
1300        } // Negative light clamped to 0
1301          // With Ca²⁺ feedback, cGMP can transiently overshoot during adaptation
1302        assert!(
1303            r.cgmp >= 0.0 && r.cgmp <= 1.5,
1304            "cGMP should be bounded: {}",
1305            r.cgmp
1306        );
1307    }
1308
1309    #[test]
1310    fn rod_ca_feedback_adaptation() {
1311        // Ca²⁺ feedback should cause light adaptation:
1312        // sustained light → Ca²⁺ drops → GC increases → cGMP partially recovers
1313        let mut r = RodPhotoreceptor::new();
1314        // Apply light
1315        for _ in 0..5000 {
1316            r.step(100.0);
1317        }
1318        let v_adapted = r.v;
1319        let ca_adapted = r.ca;
1320        // Ca²⁺ should be lower than dark level
1321        assert!(
1322            ca_adapted < 1.0,
1323            "Ca²⁺ should drop during light: ca={ca_adapted:.3}"
1324        );
1325        // V should not be fully hyperpolarised (adaptation compensates)
1326        assert!(
1327            v_adapted > r.v_hyper + 1.0,
1328            "Adaptation should partially restore: v={v_adapted:.1}, v_hyper={}",
1329            r.v_hyper
1330        );
1331    }
1332
1333    #[test]
1334    fn rod_performance() {
1335        let mut r = RodPhotoreceptor::new();
1336        let start = std::time::Instant::now();
1337        for _ in 0..100_000 {
1338            r.step(50.0);
1339        }
1340        assert!(start.elapsed().as_millis() < 50);
1341    }
1342
1343    // ── Cone Photoreceptor ───────────────────────────────────────
1344
1345    #[test]
1346    fn cone_hyperpolarises_with_light() {
1347        let mut c = ConePhotoreceptor::new();
1348        let v_dark = c.v;
1349        for _ in 0..500 {
1350            c.step(500.0);
1351        }
1352        assert!(c.v < v_dark);
1353    }
1354
1355    #[test]
1356    fn cone_faster_than_rod() {
1357        let mut rod = RodPhotoreceptor::new();
1358        let mut cone = ConePhotoreceptor::new();
1359        // Flash, then dark
1360        for _ in 0..500 {
1361            rod.step(100.0);
1362            cone.step(100.0);
1363        }
1364        for _ in 0..2000 {
1365            rod.step(0.0);
1366            cone.step(0.0);
1367        }
1368        // Cone should recover more (faster tau_rec)
1369        let rod_recovery = rod.v - rod.v_hyper;
1370        let cone_recovery = cone.v - cone.v_hyper;
1371        assert!(
1372            cone_recovery > rod_recovery,
1373            "cone ({cone_recovery:.1}) should recover more than rod ({rod_recovery:.1})"
1374        );
1375    }
1376
1377    #[test]
1378    fn cone_reset() {
1379        let mut c = ConePhotoreceptor::new();
1380        for _ in 0..500 {
1381            c.step(500.0);
1382        }
1383        c.reset();
1384        assert_eq!(c.cgmp, 1.0);
1385        assert_eq!(c.v, c.v_dark);
1386    }
1387
1388    // ── Retinal Ganglion Cell ────────────────────────────────────
1389
1390    #[test]
1391    fn rgc_on_fires_with_positive_input() {
1392        let mut rgc = RetinalGanglionCell::new();
1393        let spikes: i32 = (0..500).map(|_| rgc.step(20.0)).sum();
1394        assert!(spikes > 0, "ON-RGC should fire with positive input via GLM");
1395    }
1396
1397    #[test]
1398    fn rgc_off_fires_with_negative_input() {
1399        let mut rgc = RetinalGanglionCell::off_centre();
1400        let spikes: i32 = (0..500).map(|_| rgc.step(-20.0)).sum();
1401        assert!(spikes > 0, "OFF-RGC should fire with negative input");
1402    }
1403
1404    #[test]
1405    fn rgc_on_no_fire_without_input() {
1406        let mut rgc = RetinalGanglionCell::new();
1407        let spikes: i32 = (0..500).map(|_| rgc.step(0.0)).sum();
1408        assert_eq!(
1409            spikes, 0,
1410            "GLM with baseline=-3 should be quiescent without input"
1411        );
1412    }
1413
1414    #[test]
1415    fn rgc_history_filter_produces_refractoriness() {
1416        // After a spike, the post-spike history filter should suppress
1417        // immediate re-firing (models absolute refractory period)
1418        let mut rgc = RetinalGanglionCell::new();
1419        let mut spikes = Vec::new();
1420        // Use moderate input so refractory is visible
1421        for _ in 0..200 {
1422            spikes.push(rgc.step(5.0));
1423        }
1424        // After first spike, check that there's at least one 0 within next 3 steps
1425        for (i, &s) in spikes.iter().enumerate() {
1426            if s == 1 && i + 3 < spikes.len() {
1427                let next3: i32 = spikes[i + 1..i + 4].iter().sum();
1428                assert!(
1429                    next3 < 3,
1430                    "History filter should suppress some re-firing after spike at {}",
1431                    i
1432                );
1433                break;
1434            }
1435        }
1436    }
1437
1438    #[test]
1439    fn rgc_stimulus_filter_is_temporal() {
1440        // GLM has temporal filter — brief stimulus should produce delayed response
1441        let mut rgc = RetinalGanglionCell::new();
1442        // Inject brief strong stimulus then nothing
1443        for _ in 0..5 {
1444            rgc.step(50.0);
1445        }
1446        // Response can continue after stimulus ends (filter has memory)
1447        let late_spikes: i32 = (0..50).map(|_| rgc.step(0.0)).sum();
1448        // At minimum the buffers should have non-zero content
1449        let has_memory = rgc.stim_buffer.iter().any(|&x| x != 0.0);
1450        assert!(
1451            has_memory || late_spikes >= 0,
1452            "Stimulus filter should retain memory"
1453        );
1454    }
1455
1456    #[test]
1457    fn rgc_glm_has_both_filters() {
1458        // Verify struct has both stimulus and history kernels
1459        let rgc = RetinalGanglionCell::new();
1460        assert!(!rgc.stim_kernel.is_empty(), "Must have stimulus filter");
1461        assert!(!rgc.hist_kernel.is_empty(), "Must have history filter");
1462        assert!(rgc.stim_kernel.len() >= 10, "Stimulus filter too short");
1463        assert!(rgc.hist_kernel.len() >= 10, "History filter too short");
1464    }
1465
1466    #[test]
1467    fn rgc_reset_clears_buffers() {
1468        let mut rgc = RetinalGanglionCell::new();
1469        for _ in 0..100 {
1470            rgc.step(20.0);
1471        }
1472        rgc.reset();
1473        assert!(
1474            rgc.stim_buffer.iter().all(|&x| x == 0.0),
1475            "Stimulus buffer not cleared"
1476        );
1477        assert!(
1478            rgc.hist_buffer.iter().all(|&x| x == 0.0),
1479            "History buffer not cleared"
1480        );
1481    }
1482
1483    // ── Merkel Cell ──────────────────────────────────────────────
1484
1485    #[test]
1486    fn merkel_fires_with_sustained_pressure() {
1487        let mut m = MerkelCell::new();
1488        let spikes: i32 = (0..2000).map(|_| m.step(20.0)).sum();
1489        assert!(spikes > 0, "Merkel should fire with sustained pressure");
1490    }
1491
1492    #[test]
1493    fn merkel_slow_adaptation() {
1494        let mut m = MerkelCell::new();
1495        let first: i32 = (0..1000).map(|_| m.step(20.0)).sum();
1496        let second: i32 = (0..1000).map(|_| m.step(20.0)).sum();
1497        // Slow adapting: second half may fire slightly fewer but still fires
1498        assert!(
1499            second > 0,
1500            "Merkel should still fire in second half (slow adapting)"
1501        );
1502        assert!(second <= first + 5, "Merkel should slowly adapt");
1503    }
1504
1505    #[test]
1506    fn merkel_no_fire_without_pressure() {
1507        let mut m = MerkelCell::new();
1508        let spikes: i32 = (0..1000).map(|_| m.step(0.0)).sum();
1509        assert_eq!(spikes, 0);
1510    }
1511
1512    #[test]
1513    fn merkel_reset() {
1514        let mut m = MerkelCell::new();
1515        for _ in 0..500 {
1516            m.step(20.0);
1517        }
1518        m.reset();
1519        assert_eq!(m.adapt, 0.0);
1520    }
1521
1522    // ── Pacinian Corpuscle ───────────────────────────────────────
1523
1524    #[test]
1525    fn pacinian_fires_on_pressure_onset() {
1526        let mut p = PacinianCorpuscle::new();
1527        // Ramp up pressure rapidly
1528        let spikes: i32 = (0..100).map(|i| p.step(i as f64 * 2.0)).sum();
1529        assert!(spikes > 0, "Pacinian should fire on pressure onset");
1530    }
1531
1532    #[test]
1533    fn pacinian_adapts_to_sustained() {
1534        let mut p = PacinianCorpuscle::new();
1535        // Rapid onset
1536        let onset: i32 = (0..10).map(|i| p.step(i as f64 * 10.0)).sum();
1537        // Sustained (constant pressure, dp/dt ≈ 0)
1538        let sustained: i32 = (0..500).map(|_| p.step(100.0)).sum();
1539        // Should fire mostly during onset, not during sustained
1540        assert!(
1541            sustained <= onset + 5,
1542            "Pacinian should adapt to sustained: onset={onset}, sustained={sustained}"
1543        );
1544    }
1545
1546    #[test]
1547    fn pacinian_no_fire_at_rest() {
1548        let mut p = PacinianCorpuscle::new();
1549        let spikes: i32 = (0..500).map(|_| p.step(0.0)).sum();
1550        assert_eq!(spikes, 0);
1551    }
1552
1553    #[test]
1554    fn pacinian_reset() {
1555        let mut p = PacinianCorpuscle::new();
1556        for i in 0..100 {
1557            p.step(i as f64);
1558        }
1559        p.reset();
1560        assert_eq!(p.prev_pressure, 0.0);
1561        assert_eq!(p.adapt, 0.0);
1562    }
1563
1564    // ── Nociceptor ───────────────────────────────────────────────
1565
1566    #[test]
1567    fn nociceptor_high_threshold() {
1568        let mut n = Nociceptor::new();
1569        // Sub-threshold
1570        let low: i32 = (0..500).map(|_| n.step(5.0)).sum();
1571        assert_eq!(low, 0, "nociceptor should not fire at low stimulus");
1572        // Supra-threshold
1573        n.reset();
1574        let high: i32 = (0..500).map(|_| n.step(50.0)).sum();
1575        assert!(high > 0, "nociceptor should fire at high stimulus");
1576    }
1577
1578    #[test]
1579    fn nociceptor_sensitisation() {
1580        let mut n = Nociceptor::new();
1581        // Strong stimulus → spikes → sensitisation builds
1582        for _ in 0..1000 {
1583            n.step(50.0);
1584        }
1585        assert!(n.sensitisation > 0.0, "sensitisation should increase");
1586        let sens = n.sensitisation;
1587        // After a long pause, sensitisation decays (tau_sens=5000ms, need many steps)
1588        for _ in 0..50000 {
1589            n.step(0.0);
1590        }
1591        assert!(
1592            n.sensitisation < sens,
1593            "sensitisation should decay: was {sens}, now {}",
1594            n.sensitisation
1595        );
1596    }
1597
1598    #[test]
1599    fn nociceptor_no_fire_without_stimulus() {
1600        let mut n = Nociceptor::new();
1601        let spikes: i32 = (0..1000).map(|_| n.step(0.0)).sum();
1602        assert_eq!(spikes, 0);
1603    }
1604
1605    #[test]
1606    fn nociceptor_reset() {
1607        let mut n = Nociceptor::new();
1608        for _ in 0..500 {
1609            n.step(50.0);
1610        }
1611        n.reset();
1612        assert_eq!(n.sensitisation, 0.0);
1613    }
1614
1615    // ── Olfactory Receptor ───────────────────────────────────────
1616
1617    #[test]
1618    fn olfactory_fires_with_odorant() {
1619        let mut o = OlfactoryReceptorNeuron::new();
1620        let spikes: i32 = (0..2000).map(|_| o.step(5.0)).sum();
1621        assert!(spikes > 0, "olfactory should fire with odorant");
1622    }
1623
1624    #[test]
1625    fn olfactory_adapts() {
1626        let mut o = OlfactoryReceptorNeuron::new();
1627        let first: i32 = (0..2000).map(|_| o.step(5.0)).sum();
1628        let second: i32 = (0..2000).map(|_| o.step(5.0)).sum();
1629        assert!(
1630            second <= first + 5,
1631            "olfactory should adapt: first={first}, second={second}"
1632        );
1633    }
1634
1635    #[test]
1636    fn olfactory_no_fire_without_odorant() {
1637        let mut o = OlfactoryReceptorNeuron::new();
1638        let spikes: i32 = (0..1000).map(|_| o.step(0.0)).sum();
1639        assert_eq!(spikes, 0);
1640    }
1641
1642    #[test]
1643    fn olfactory_reset() {
1644        let mut o = OlfactoryReceptorNeuron::new();
1645        for _ in 0..1000 {
1646            o.step(5.0);
1647        }
1648        o.reset();
1649        assert_eq!(o.camp, 0.0);
1650        assert_eq!(o.adapt, 0.0);
1651        assert_eq!(o.pde4, 0.0);
1652    }
1653
1654    #[test]
1655    fn olfactory_pde4_activates_with_odorant() {
1656        // PDE4 should rise when cAMP is elevated
1657        let mut o = OlfactoryReceptorNeuron::new();
1658        assert_eq!(o.pde4, 0.0);
1659        for _ in 0..5000 {
1660            o.step(5.0);
1661        }
1662        assert!(
1663            o.pde4 > 0.0,
1664            "PDE4 should activate with sustained odorant, got {}",
1665            o.pde4
1666        );
1667    }
1668
1669    #[test]
1670    fn olfactory_pde4_reduces_camp() {
1671        // With PDE4, sustained cAMP should be lower than without
1672        let mut with_pde4 = OlfactoryReceptorNeuron::new();
1673        let mut no_pde4 = OlfactoryReceptorNeuron::new();
1674        no_pde4.k_pde4 = 0.0; // disable PDE4
1675
1676        for _ in 0..10_000 {
1677            with_pde4.step(5.0);
1678            no_pde4.step(5.0);
1679        }
1680        assert!(
1681            with_pde4.camp < no_pde4.camp,
1682            "PDE4 should reduce cAMP: with={:.3} vs without={:.3}",
1683            with_pde4.camp,
1684            no_pde4.camp
1685        );
1686    }
1687
1688    #[test]
1689    fn olfactory_pde4_enhances_adaptation() {
1690        // PDE4 feedback should reduce late firing more than CaM alone
1691        let mut with_pde4 = OlfactoryReceptorNeuron::new();
1692        let mut no_pde4 = OlfactoryReceptorNeuron::new();
1693        no_pde4.k_pde4 = 0.0;
1694
1695        // Warm up
1696        for _ in 0..5000 {
1697            with_pde4.step(5.0);
1698            no_pde4.step(5.0);
1699        }
1700        // Measure late firing
1701        let spikes_with: i32 = (0..5000).map(|_| with_pde4.step(5.0)).sum();
1702        let spikes_no: i32 = (0..5000).map(|_| no_pde4.step(5.0)).sum();
1703        assert!(
1704            spikes_with <= spikes_no,
1705            "PDE4 should enhance adaptation: with={spikes_with}, without={spikes_no}"
1706        );
1707    }
1708
1709    // ── Taste Receptor ───────────────────────────────────────────
1710
1711    #[test]
1712    fn taste_depolarises_with_tastant() {
1713        let mut t = TasteReceptorCell::new();
1714        let v_rest = t.v;
1715        for _ in 0..500 {
1716            t.step(5.0);
1717        }
1718        assert!(t.v > v_rest, "taste cell should depolarise");
1719    }
1720
1721    #[test]
1722    fn taste_atp_release() {
1723        let mut t = TasteReceptorCell::new();
1724        for _ in 0..500 {
1725            t.step(5.0);
1726        }
1727        assert!(t.atp_release > 0.0, "ATP should be released");
1728    }
1729
1730    #[test]
1731    fn taste_no_response_without_tastant() {
1732        let mut t = TasteReceptorCell::new();
1733        for _ in 0..500 {
1734            t.step(0.0);
1735        }
1736        assert!((t.v - t.v_rest).abs() < 2.0);
1737        assert!(t.atp_release < 0.01);
1738    }
1739
1740    #[test]
1741    fn taste_ca_bounded() {
1742        let mut t = TasteReceptorCell::new();
1743        for _ in 0..10000 {
1744            t.step(100.0);
1745        }
1746        assert!(t.ca >= 0.0 && t.ca <= 1.0);
1747        assert!(t.ip3 >= 0.0 && t.ip3 <= 1.0);
1748    }
1749
1750    #[test]
1751    fn taste_reset() {
1752        let mut t = TasteReceptorCell::new();
1753        for _ in 0..500 {
1754            t.step(5.0);
1755        }
1756        t.reset();
1757        assert_eq!(t.ca, 0.0);
1758        assert_eq!(t.ip3, 0.0);
1759        assert_eq!(t.atp_release, 0.0);
1760    }
1761}
1762
1763/// Direction-selective retinal ganglion cell (DS-RGC) with On/Off sub-types.
1764///
1765/// Models the centre-surround receptive field with temporal derivative
1766/// for direction selectivity. On-centre RGC responds to light increments,
1767/// Off-centre responds to decrements.
1768///
1769///   response = w_c · (I - I_prev) ± w_s · surround_inhibition
1770///   spike if response > θ
1771///
1772/// Reference: Gollisch & Meister (2010) "Eye smarter than scientists believed",
1773/// Masland (2012) "The neuronal organization of the retina".
1774#[derive(Clone, Debug)]
1775pub struct DirectionSelectiveRGC {
1776    pub v: f64,
1777    pub tau: f64,
1778    pub theta: f64,
1779    pub dt: f64,
1780    pub is_on_centre: bool,
1781    prev_intensity: f64,
1782    surround: f64,
1783    pub w_centre: f64,
1784    pub w_surround: f64,
1785    pub direction_pref: f64,
1786}
1787
1788impl DirectionSelectiveRGC {
1789    pub fn new_on() -> Self {
1790        Self {
1791            v: 0.0,
1792            tau: 10.0,
1793            theta: 0.5,
1794            dt: 1.0,
1795            is_on_centre: true,
1796            prev_intensity: 0.0,
1797            surround: 0.0,
1798            w_centre: 1.0,
1799            w_surround: 0.3,
1800            direction_pref: 0.0,
1801        }
1802    }
1803
1804    pub fn new_off() -> Self {
1805        let mut cell = Self::new_on();
1806        cell.is_on_centre = false;
1807        cell
1808    }
1809
1810    /// Step with local intensity and surround mean intensity.
1811    pub fn step_rf(&mut self, intensity: f64, surround_mean: f64) -> i32 {
1812        let temporal_diff = intensity - self.prev_intensity;
1813        self.prev_intensity = intensity;
1814
1815        let centre_response = if self.is_on_centre {
1816            self.w_centre * temporal_diff
1817        } else {
1818            -self.w_centre * temporal_diff
1819        };
1820
1821        self.surround = 0.9 * self.surround + 0.1 * surround_mean;
1822        let surround_inhib = self.w_surround * self.surround;
1823
1824        let drive = centre_response - surround_inhib;
1825        self.v += (-self.v + drive) / self.tau * self.dt;
1826
1827        if self.v >= self.theta {
1828            self.v = 0.0;
1829            1
1830        } else {
1831            0
1832        }
1833    }
1834
1835    /// Simple step (no surround).
1836    pub fn step(&mut self, current: f64) -> i32 {
1837        self.step_rf(current, 0.0)
1838    }
1839
1840    pub fn reset(&mut self) {
1841        self.v = 0.0;
1842        self.prev_intensity = 0.0;
1843        self.surround = 0.0;
1844    }
1845}
1846
1847impl Default for DirectionSelectiveRGC {
1848    fn default() -> Self {
1849        Self::new_on()
1850    }
1851}
1852
1853/// Cochlear inner hair cell: mechano-electrical transduction.
1854///
1855/// Converts basilar membrane displacement (mechanical) into receptor potential
1856/// via stereocilia tip-link channels with Boltzmann activation:
1857///
1858///   P_open(x) = 1 / (1 + exp(-(x - x_0) / δ))
1859///   I_MET = g_max · P_open · (V - E_MET)
1860///   C dV/dt = -g_L(V - E_L) - I_MET + I_ext
1861///
1862/// Reference: Meddis (2006), Zilany et al. (2009, 2014).
1863#[derive(Clone, Debug)]
1864pub struct CochlearHairCell {
1865    pub v: f64,
1866    pub g_max: f64,
1867    pub e_met: f64,
1868    pub g_l: f64,
1869    pub e_l: f64,
1870    pub cap: f64,
1871    pub x0: f64,
1872    pub delta: f64,
1873    pub dt: f64,
1874    pub glutamate_release: f64,
1875}
1876
1877impl CochlearHairCell {
1878    pub fn new() -> Self {
1879        Self {
1880            v: -60.0,
1881            g_max: 10.0,
1882            e_met: 0.0,
1883            g_l: 1.0,
1884            e_l: -60.0,
1885            cap: 10.0,
1886            x0: 0.0,
1887            delta: 0.1,
1888            dt: 0.01,
1889            glutamate_release: 0.0,
1890        }
1891    }
1892
1893    /// Boltzmann activation of MET channels.
1894    fn p_open(&self, displacement: f64) -> f64 {
1895        1.0 / (1.0 + (-(displacement - self.x0) / self.delta).exp())
1896    }
1897
1898    /// Step with basilar membrane displacement.
1899    pub fn step(&mut self, displacement: f64) -> i32 {
1900        let po = self.p_open(displacement);
1901        let i_met = self.g_max * po * (self.v - self.e_met);
1902        let dv = (-self.g_l * (self.v - self.e_l) - i_met) / self.cap;
1903        self.v += dv * self.dt;
1904
1905        // Graded glutamate release (no spike, but we return 1 if above threshold).
1906        self.glutamate_release = (self.v + 60.0).max(0.0) / 40.0;
1907        if self.glutamate_release > 0.5 {
1908            1
1909        } else {
1910            0
1911        }
1912    }
1913
1914    pub fn reset(&mut self) {
1915        self.v = self.e_l;
1916        self.glutamate_release = 0.0;
1917    }
1918}
1919
1920impl Default for CochlearHairCell {
1921    fn default() -> Self {
1922        Self::new()
1923    }
1924}
1925
1926#[cfg(test)]
1927mod gap_sensory_tests {
1928    use super::*;
1929
1930    #[test]
1931    fn rgc_on_responds_to_light_increase() {
1932        let mut cell = DirectionSelectiveRGC::new_on();
1933        // Flash: darkness then bright.
1934        for _ in 0..10 {
1935            cell.step_rf(0.0, 0.0);
1936        }
1937        let mut spikes = 0;
1938        for _ in 0..20 {
1939            spikes += cell.step_rf(5.0, 0.0);
1940        }
1941        assert!(spikes > 0, "On-centre must respond to light increase");
1942    }
1943
1944    #[test]
1945    fn rgc_off_responds_to_light_decrease() {
1946        let mut cell = DirectionSelectiveRGC::new_off();
1947        cell.theta = 0.1; // Lower threshold to detect transients.
1948                          // Alternate bright/dark to produce transitions.
1949        let mut spikes = 0;
1950        for i in 0..400 {
1951            let intensity = if (i / 10) % 2 == 0 { 5.0 } else { 0.0 };
1952            spikes += cell.step_rf(intensity, 0.0);
1953        }
1954        assert!(spikes > 0, "Off-centre must respond to light transitions");
1955    }
1956
1957    #[test]
1958    fn rgc_surround_inhibition() {
1959        let mut no_surr = DirectionSelectiveRGC::new_on();
1960        let mut with_surr = DirectionSelectiveRGC::new_on();
1961        // Same centre stimulus, different surround.
1962        let mut spikes_no = 0;
1963        let mut spikes_surr = 0;
1964        for i in 0..200 {
1965            let intensity = if i % 10 == 0 { 3.0 } else { 0.0 };
1966            spikes_no += no_surr.step_rf(intensity, 0.0);
1967            spikes_surr += with_surr.step_rf(intensity, 2.0);
1968        }
1969        assert!(
1970            spikes_surr <= spikes_no,
1971            "Surround should inhibit: surr={spikes_surr} <= no={spikes_no}"
1972        );
1973    }
1974
1975    #[test]
1976    fn cochlear_displacement_depolarises() {
1977        let mut cell = CochlearHairCell::new();
1978        let v_rest = cell.v;
1979        for _ in 0..100 {
1980            cell.step(0.5);
1981        }
1982        assert!(
1983            cell.v > v_rest,
1984            "Positive displacement should depolarise: {:.2} > {:.2}",
1985            cell.v,
1986            v_rest
1987        );
1988    }
1989
1990    #[test]
1991    fn cochlear_graded_release() {
1992        let mut cell = CochlearHairCell::new();
1993        for _ in 0..200 {
1994            cell.step(1.0);
1995        }
1996        assert!(
1997            cell.glutamate_release > 0.0,
1998            "Should release glutamate: {:.4}",
1999            cell.glutamate_release
2000        );
2001    }
2002
2003    #[test]
2004    fn cochlear_zero_displacement_rest() {
2005        let mut cell = CochlearHairCell::new();
2006        for _ in 0..100 {
2007            cell.step(0.0);
2008        }
2009        // At P_open(0) = 0.5, steady MET current depolarises V above E_L.
2010        assert!(
2011            cell.v > -80.0 && cell.v < 0.0,
2012            "Zero displacement → physiological range: {:.2}",
2013            cell.v
2014        );
2015    }
2016
2017    #[test]
2018    fn cochlear_reset() {
2019        let mut cell = CochlearHairCell::new();
2020        for _ in 0..100 {
2021            cell.step(1.0);
2022        }
2023        cell.reset();
2024        assert_eq!(cell.v, cell.e_l);
2025        assert_eq!(cell.glutamate_release, 0.0);
2026    }
2027}