#[derive(Clone, Debug)]
pub struct InnerHairCell {
pub v: f64, pub v_rest: f64,
pub tau: f64, pub g_met: f64, pub x_half: f64, pub s_met: f64, pub ca: f64, pub tau_ca: f64, pub g_ca: f64, pub v_ca_half: f64, pub s_ca: f64, pub q: f64, pub c: f64, pub w: f64, pub m_pool: f64, pub y: f64, pub x_r: f64, pub k_rel: f64, pub l: f64, pub r_up: f64, pub k_d: f64, pub dt: f64,
}
impl InnerHairCell {
pub fn new() -> Self {
Self {
v: -60.0,
v_rest: -60.0,
tau: 0.5,
g_met: 10.0,
x_half: 50.0,
s_met: 10.0,
ca: 0.05,
tau_ca: 1.0,
g_ca: 0.5,
v_ca_half: -35.0, s_ca: 8.0,
q: 8.0,
c: 0.0,
w: 0.0,
m_pool: 10.0, y: 0.01, x_r: 0.005, k_rel: 0.2, l: 0.05, r_up: 0.05, k_d: 0.1, dt: 0.025,
}
}
#[inline]
fn release_rate(&self) -> f64 {
let ca2 = self.ca * self.ca;
let kd2 = self.k_d * self.k_d;
self.k_rel * ca2 / (ca2 + kd2)
}
pub fn step(&mut self, displacement: f64) -> f64 {
let p_open = 1.0 / (1.0 + (-(displacement - self.x_half) / self.s_met).exp());
let i_met = self.g_met * p_open * (0.0 - self.v);
self.v += (-(self.v - self.v_rest) + i_met) / self.tau * self.dt;
let m_ca = 1.0 / (1.0 + (-(self.v - self.v_ca_half) / self.s_ca).exp());
let ca_entry = self.g_ca * m_ca * m_ca; self.ca += (-self.ca / self.tau_ca + ca_entry) * self.dt;
self.ca = self.ca.max(0.0);
let f_ca = self.release_rate();
let dq = self.y * (self.m_pool - self.q) + self.x_r * self.w - f_ca * self.q;
let dc = f_ca * self.q - self.l * self.c - self.r_up * self.c;
let dw = self.r_up * self.c - self.x_r * self.w;
self.q += dq * self.dt;
self.c += dc * self.dt;
self.w += dw * self.dt;
self.q = self.q.clamp(0.0, self.m_pool);
self.c = self.c.max(0.0);
self.w = self.w.max(0.0);
if !self.v.is_finite() {
self.v = self.v_rest;
}
if !self.ca.is_finite() {
self.ca = 0.05;
}
if !self.q.is_finite() {
self.q = 8.0;
}
if !self.c.is_finite() {
self.c = 0.0;
}
if !self.w.is_finite() {
self.w = 0.0;
}
self.v
}
pub fn reset(&mut self) {
self.v = self.v_rest;
self.ca = 0.05;
self.q = 8.0;
self.c = 0.0;
self.w = 0.0;
}
}
impl Default for InnerHairCell {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Debug)]
pub struct OuterHairCell {
pub v: f64,
pub v_rest: f64,
pub tau: f64,
pub g_met: f64,
pub x_half: f64,
pub s_met: f64,
pub motility: f64, pub l_max: f64, pub v_pk: f64, pub z_e: f64, pub v_t: f64, pub q_max: f64, pub asym_factor: f64, pub dt: f64,
}
impl OuterHairCell {
pub fn new() -> Self {
Self {
v: -70.0,
v_rest: -70.0,
tau: 0.3,
g_met: 15.0,
x_half: 20.0,
s_met: 6.0,
motility: 0.0,
l_max: 4.0, v_pk: -40.0, z_e: 0.7, v_t: 26.0, q_max: 0.8, asym_factor: 0.3, dt: 0.025,
}
}
#[inline]
fn prestin_compact(&self) -> f64 {
1.0 / (1.0 + (self.z_e * (self.v - self.v_pk) / self.v_t).exp())
}
pub fn step(&mut self, displacement: f64) -> f64 {
let p_open = 1.0 / (1.0 + (-(displacement - self.x_half) / self.s_met).exp());
let i_met = self.g_met * p_open * (0.0 - self.v);
self.v += (-(self.v - self.v_rest) + i_met) / self.tau * self.dt;
let compact = self.prestin_compact();
let raw_motility = self.l_max * (0.5 - compact);
let asym = if raw_motility > 0.0 {
1.0 + self.asym_factor } else {
1.0 - self.asym_factor };
self.motility = raw_motility * asym;
if !self.v.is_finite() {
self.v = self.v_rest;
}
self.v
}
pub fn reset(&mut self) {
self.v = self.v_rest;
self.motility = 0.0;
}
}
impl Default for OuterHairCell {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Debug)]
pub struct RodPhotoreceptor {
pub v: f64,
pub v_dark: f64,
pub v_hyper: f64,
pub cgmp: f64, pub ca: f64, pub tau_act: f64, pub tau_ca: f64, pub sensitivity: f64,
pub alpha_max: f64, pub k_gc: f64, pub n_gc: f64, pub eta_ca: f64, pub dt: f64,
}
impl RodPhotoreceptor {
pub fn new() -> Self {
Self {
v: -40.0,
v_dark: -40.0,
v_hyper: -70.0,
cgmp: 1.0,
ca: 1.0, tau_act: 20.0,
tau_ca: 30.0, sensitivity: 0.01,
alpha_max: 0.05, k_gc: 0.5, n_gc: 4.0, eta_ca: 0.3, dt: 0.1,
}
}
#[inline]
fn gc_rate(&self) -> f64 {
let ca_n = self.ca.powf(self.n_gc);
let k_n = self.k_gc.powf(self.n_gc);
self.alpha_max * k_n / (k_n + ca_n)
}
pub fn step(&mut self, light: f64) -> f64 {
let light_clamped = light.max(0.0);
let gc = self.gc_rate();
let pde = self.sensitivity * light_clamped / self.tau_act;
let d_cgmp = gc - pde * self.cgmp + (1.0 - self.cgmp) * 0.001; self.cgmp += d_cgmp * self.dt;
self.cgmp = self.cgmp.clamp(0.0, 1.5);
let cng_fraction = self.cgmp.powi(3).min(1.0);
let d_ca = self.eta_ca * cng_fraction - self.ca / self.tau_ca;
self.ca += d_ca * self.dt;
self.ca = self.ca.max(0.0);
self.v = self.v_hyper + (self.v_dark - self.v_hyper) * cng_fraction;
if !self.v.is_finite() {
self.v = self.v_dark;
}
if !self.cgmp.is_finite() {
self.cgmp = 1.0;
}
if !self.ca.is_finite() {
self.ca = 1.0;
}
self.v
}
pub fn reset(&mut self) {
self.v = self.v_dark;
self.cgmp = 1.0;
self.ca = 1.0;
}
}
impl Default for RodPhotoreceptor {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Debug)]
pub struct ConePhotoreceptor {
pub v: f64,
pub v_dark: f64,
pub v_hyper: f64,
pub cgmp: f64,
pub tau_act: f64,
pub tau_rec: f64,
pub sensitivity: f64,
pub dt: f64,
}
impl ConePhotoreceptor {
pub fn new() -> Self {
Self {
v: -40.0,
v_dark: -40.0,
v_hyper: -65.0,
cgmp: 1.0,
tau_act: 5.0, tau_rec: 50.0, sensitivity: 0.001, dt: 0.1,
}
}
pub fn step(&mut self, light: f64) -> f64 {
let light_clamped = light.max(0.0);
let d_cgmp = -self.sensitivity * light_clamped * self.cgmp / self.tau_act
+ (1.0 - self.cgmp) / self.tau_rec;
self.cgmp += d_cgmp * self.dt;
self.cgmp = self.cgmp.clamp(0.0, 1.0);
let cng_fraction = self.cgmp.powi(3);
self.v = self.v_hyper + (self.v_dark - self.v_hyper) * cng_fraction;
self.v
}
pub fn reset(&mut self) {
self.v = self.v_dark;
self.cgmp = 1.0;
}
}
impl Default for ConePhotoreceptor {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Debug)]
pub struct RetinalGanglionCell {
pub stim_buffer: Vec<f64>, pub stim_kernel: Vec<f64>, pub stim_idx: usize,
pub hist_buffer: Vec<f64>, pub hist_kernel: Vec<f64>, pub hist_idx: usize,
pub baseline: f64, pub on_centre: bool, pub spike_threshold: f64, pub dt: f64,
pub gain: f64,
}
impl RetinalGanglionCell {
pub fn new() -> Self {
let n_stim = 20;
let mut stim_kernel = vec![0.0; n_stim];
for i in 0..n_stim {
let t = i as f64;
let excit = (-(t - 3.0).powi(2) / 8.0).exp();
let inhib = 0.5 * (-(t - 8.0).powi(2) / 32.0).exp();
stim_kernel[i] = excit - inhib;
}
let peak: f64 = stim_kernel.iter().map(|x| x.abs()).fold(0.0_f64, f64::max);
if peak > 0.0 {
for k in &mut stim_kernel {
*k /= peak;
}
}
let n_hist = 30;
let mut hist_kernel = vec![0.0; n_hist];
for i in 0..n_hist {
let t = i as f64 * 0.5; let refrac = -15.0 * (-t / 1.5).exp(); let burst = 0.3 * (-((t - 5.0) / 3.0).powi(2)).exp(); hist_kernel[i] = refrac + burst;
}
Self {
stim_buffer: vec![0.0; n_stim],
stim_kernel,
stim_idx: 0,
hist_buffer: vec![0.0; n_hist],
hist_kernel,
hist_idx: 0,
baseline: -3.0, on_centre: true,
spike_threshold: 0.7, dt: 0.5,
gain: 1.0,
}
}
pub fn off_centre() -> Self {
Self {
on_centre: false,
..Self::new()
}
}
#[inline]
fn convolve(buffer: &[f64], kernel: &[f64], write_idx: usize) -> f64 {
let n = kernel.len();
let mut sum = 0.0;
for i in 0..n {
let buf_idx = (write_idx + n - 1 - i) % n;
sum += buffer[buf_idx] * kernel[i];
}
sum
}
pub fn step(&mut self, input: f64) -> i32 {
let effective = if self.on_centre { input } else { -input };
let stimulus = self.gain * effective;
let n_stim = self.stim_kernel.len();
self.stim_buffer[self.stim_idx % n_stim] = stimulus;
self.stim_idx = (self.stim_idx + 1) % n_stim;
let filtered_stim = Self::convolve(&self.stim_buffer, &self.stim_kernel, self.stim_idx);
let n_hist = self.hist_kernel.len();
let filtered_hist = Self::convolve(&self.hist_buffer, &self.hist_kernel, self.hist_idx);
let log_rate = filtered_stim + filtered_hist + self.baseline;
let lambda = log_rate.exp().min(1000.0);
let spiked = if lambda * self.dt > self.spike_threshold {
1
} else {
0
};
self.hist_buffer[self.hist_idx % n_hist] = spiked as f64;
self.hist_idx = (self.hist_idx + 1) % n_hist;
spiked
}
pub fn reset(&mut self) {
for x in &mut self.stim_buffer {
*x = 0.0;
}
for x in &mut self.hist_buffer {
*x = 0.0;
}
self.stim_idx = 0;
self.hist_idx = 0;
}
}
impl Default for RetinalGanglionCell {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Debug)]
pub struct MerkelCell {
pub v: f64,
pub v_rest: f64,
pub v_reset: f64,
pub v_threshold: f64,
pub tau: f64,
pub adapt: f64, pub tau_adapt: f64, pub a_adapt: f64, pub gain: f64,
pub dt: f64,
}
impl MerkelCell {
pub fn new() -> Self {
Self {
v: -65.0,
v_rest: -65.0,
v_reset: -70.0,
v_threshold: -50.0,
tau: 5.0,
adapt: 0.0,
tau_adapt: 200.0, a_adapt: 0.3,
gain: 1.5,
dt: 0.5,
}
}
#[inline]
fn exact_relax(value: f64, target: f64, tau: f64, dt: f64) -> f64 {
target + (value - target) * (-dt / tau).exp()
}
fn is_valid(&self) -> bool {
[
self.v,
self.v_rest,
self.v_reset,
self.v_threshold,
self.tau,
self.adapt,
self.tau_adapt,
self.a_adapt,
self.gain,
self.dt,
]
.iter()
.all(|value| value.is_finite())
&& (-100.0..=60.0).contains(&self.v)
&& self.tau > 0.0
&& self.tau_adapt > 0.0
&& self.a_adapt >= 0.0
&& self.gain >= 0.0
&& self.dt > 0.0
&& self.adapt >= 0.0
&& self.v_threshold > self.v_reset
&& self.v_threshold > self.v_rest
}
pub fn step(&mut self, pressure: f64) -> i32 {
if !self.is_valid() || !pressure.is_finite() {
return 0;
}
let rectified_pressure = pressure.max(0.0);
let v_inf = self.v_rest + self.gain * rectified_pressure - self.adapt;
let v_next = Self::exact_relax(self.v, v_inf, self.tau, self.dt);
let adapt_inf = (self.a_adapt * (v_next - self.v_rest).max(0.0)).max(0.0);
let adapt_next = Self::exact_relax(self.adapt, adapt_inf, self.tau_adapt, self.dt).max(0.0);
if !v_next.is_finite() || !adapt_next.is_finite() {
return 0;
}
if v_next >= self.v_threshold {
self.v = self.v_reset;
self.adapt = adapt_next;
1
} else {
self.v = v_next.clamp(-100.0, 60.0);
self.adapt = adapt_next;
0
}
}
pub fn reset(&mut self) {
self.v = self.v_rest;
self.adapt = 0.0;
}
}
impl Default for MerkelCell {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Debug)]
pub struct PacinianCorpuscle {
pub v: f64,
pub v_rest: f64,
pub v_reset: f64,
pub v_threshold: f64,
pub tau: f64,
pub prev_pressure: f64,
pub adapt: f64,
pub tau_adapt: f64,
pub gain: f64,
pub dt: f64,
}
impl PacinianCorpuscle {
pub fn new() -> Self {
Self {
v: -65.0,
v_rest: -65.0,
v_reset: -70.0,
v_threshold: -50.0,
tau: 2.0,
prev_pressure: 0.0,
adapt: 0.0,
tau_adapt: 5.0, gain: 10.0, dt: 0.5,
}
}
#[inline]
fn exact_relax(value: f64, target: f64, tau: f64, dt: f64) -> f64 {
target + (value - target) * (-dt / tau).exp()
}
fn is_valid(&self) -> bool {
[
self.v,
self.v_rest,
self.v_reset,
self.v_threshold,
self.tau,
self.prev_pressure,
self.adapt,
self.tau_adapt,
self.gain,
self.dt,
]
.iter()
.all(|value| value.is_finite())
&& (-100.0..=60.0).contains(&self.v)
&& self.tau > 0.0
&& self.tau_adapt > 0.0
&& self.gain >= 0.0
&& self.dt > 0.0
&& self.adapt >= 0.0
&& self.v_threshold > self.v_reset
&& self.v_threshold > self.v_rest
}
pub fn step(&mut self, pressure: f64) -> i32 {
if !self.is_valid() || !pressure.is_finite() {
return 0;
}
let dp = (pressure - self.prev_pressure) / self.dt;
let drive = self.gain * dp.abs() - self.adapt;
let v_inf = self.v_rest + drive;
let v_next = Self::exact_relax(self.v, v_inf, self.tau, self.dt);
let adapt_inf = 0.5 * drive.max(0.0);
let adapt_next = Self::exact_relax(self.adapt, adapt_inf, self.tau_adapt, self.dt).max(0.0);
if !dp.is_finite() || !drive.is_finite() || !v_next.is_finite() || !adapt_next.is_finite() {
return 0;
}
self.prev_pressure = pressure;
self.adapt = adapt_next;
if v_next >= self.v_threshold {
self.v = self.v_reset;
1
} else {
self.v = v_next.clamp(-100.0, 60.0);
0
}
}
pub fn reset(&mut self) {
self.v = self.v_rest;
self.prev_pressure = 0.0;
self.adapt = 0.0;
}
}
impl Default for PacinianCorpuscle {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Debug)]
pub struct Nociceptor {
pub v: f64,
pub v_rest: f64,
pub v_reset: f64,
pub v_threshold: f64,
pub tau: f64,
pub sensitisation: f64, pub tau_sens: f64, pub sens_rate: f64, pub gain: f64,
pub dt: f64,
}
impl Nociceptor {
pub fn new() -> Self {
Self {
v: -65.0,
v_rest: -65.0,
v_reset: -68.0,
v_threshold: -30.0, tau: 8.0,
sensitisation: 0.0,
tau_sens: 5000.0, sens_rate: 0.5,
gain: 1.0,
dt: 0.5,
}
}
fn exact_relax(value: f64, target: f64, tau: f64, dt: f64) -> f64 {
target + (value - target) * (-dt / tau).exp()
}
fn biological_voltage(value: f64) -> bool {
value.is_finite() && (-100.0..=60.0).contains(&value)
}
fn is_valid(&self) -> bool {
Self::biological_voltage(self.v)
&& Self::biological_voltage(self.v_rest)
&& Self::biological_voltage(self.v_reset)
&& Self::biological_voltage(self.v_threshold)
&& self.tau.is_finite()
&& self.tau > 0.0
&& self.sensitisation.is_finite()
&& (0.0..=10.0).contains(&self.sensitisation)
&& self.tau_sens.is_finite()
&& self.tau_sens > 0.0
&& self.sens_rate.is_finite()
&& self.sens_rate >= 0.0
&& self.gain.is_finite()
&& self.gain >= 0.0
&& self.dt.is_finite()
&& self.dt > 0.0
&& self.v_threshold > self.v_reset
&& self.v_threshold > self.v_rest
}
pub fn step(&mut self, stimulus: f64) -> i32 {
if !self.is_valid() || !stimulus.is_finite() {
return 0;
}
let drive = self.gain * stimulus.max(0.0);
let v_next = Self::exact_relax(self.v, self.v_rest + drive, self.tau, self.dt);
if !drive.is_finite() || !v_next.is_finite() {
return 0;
}
let effective_threshold = self.v_threshold - self.sensitisation;
if v_next >= effective_threshold {
self.v = self.v_reset;
self.sensitisation = (self.sensitisation + self.sens_rate).min(10.0);
1
} else {
let sensitisation_next =
Self::exact_relax(self.sensitisation, 0.0, self.tau_sens, self.dt).max(0.0);
if !sensitisation_next.is_finite() {
return 0;
}
self.v = v_next.clamp(-100.0, 60.0);
self.sensitisation = sensitisation_next;
0
}
}
pub fn reset(&mut self) {
self.v = self.v_rest;
self.sensitisation = 0.0;
}
}
impl Default for Nociceptor {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Debug)]
pub struct OlfactoryReceptorNeuron {
pub v: f64,
pub v_rest: f64,
pub v_reset: f64,
pub v_threshold: f64,
pub tau: f64,
pub camp: f64, pub adapt: f64, pub pde4: f64, pub tau_camp: f64, pub tau_adapt: f64, pub tau_pde4: f64, pub k_pde4: f64, pub gain: f64,
pub dt: f64,
}
impl OlfactoryReceptorNeuron {
pub fn new() -> Self {
Self {
v: -65.0,
v_rest: -65.0,
v_reset: -70.0,
v_threshold: -45.0,
tau: 5.0,
camp: 0.0,
adapt: 0.0,
pde4: 0.0,
tau_camp: 50.0,
tau_adapt: 500.0,
tau_pde4: 300.0, k_pde4: 1.5, gain: 1.5,
dt: 0.5,
}
}
pub fn step(&mut self, concentration: f64) -> i32 {
let conc = concentration.max(0.0);
let camp_production = conc / (conc + 1.0) * (1.0 - 0.8 * self.adapt);
let pde4_degradation = self.k_pde4 * self.pde4 * self.camp;
let camp_target = (camp_production - pde4_degradation).max(0.0);
self.camp += (camp_target - self.camp) / self.tau_camp * self.dt;
self.camp = self.camp.clamp(0.0, 1.0);
self.pde4 += (self.camp - self.pde4) / self.tau_pde4 * self.dt;
self.pde4 = self.pde4.clamp(0.0, 1.0);
let drive = self.gain * self.camp * 50.0; self.v += (-(self.v - self.v_rest) + drive) / self.tau * self.dt;
let ca_proxy = if self.v > self.v_rest {
(self.v - self.v_rest) / 20.0
} else {
0.0
};
self.adapt += (ca_proxy - self.adapt) / self.tau_adapt * self.dt;
self.adapt = self.adapt.clamp(0.0, 1.0);
if self.v >= self.v_threshold {
self.v = self.v_reset;
1
} else {
0
}
}
pub fn reset(&mut self) {
self.v = self.v_rest;
self.camp = 0.0;
self.adapt = 0.0;
self.pde4 = 0.0;
}
}
impl Default for OlfactoryReceptorNeuron {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Debug)]
pub struct TasteReceptorCell {
pub v: f64,
pub v_rest: f64,
pub tau: f64,
pub ca: f64, pub ip3: f64, pub tau_ip3: f64,
pub tau_ca: f64,
pub gain: f64,
pub atp_release: f64, pub dt: f64,
}
impl TasteReceptorCell {
pub fn new() -> Self {
Self {
v: -50.0,
v_rest: -50.0,
tau: 10.0,
ca: 0.0,
ip3: 0.0,
tau_ip3: 100.0,
tau_ca: 200.0,
gain: 1.0,
atp_release: 0.0,
dt: 0.5,
}
}
pub fn step(&mut self, tastant: f64) -> f64 {
let conc = tastant.max(0.0);
let ip3_target = conc / (conc + 0.5);
self.ip3 += (ip3_target - self.ip3) / self.tau_ip3 * self.dt;
self.ip3 = self.ip3.clamp(0.0, 1.0);
let ca_release = self.ip3.powi(2) * (1.0 - self.ca);
self.ca += (ca_release - self.ca / self.tau_ca) * self.dt;
self.ca = self.ca.clamp(0.0, 1.0);
let i_trpm5 = self.gain * self.ca * 20.0;
self.v += (-(self.v - self.v_rest) + i_trpm5) / self.tau * self.dt;
self.atp_release = self.ca;
self.v
}
pub fn reset(&mut self) {
self.v = self.v_rest;
self.ca = 0.0;
self.ip3 = 0.0;
self.atp_release = 0.0;
}
}
impl Default for TasteReceptorCell {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ihc_depolarises_with_displacement() {
let mut c = InnerHairCell::new();
let v_rest = c.v;
for _ in 0..200 {
c.step(50.0);
}
assert!(c.v > v_rest, "IHC should depolarise: v={}", c.v);
}
#[test]
fn ihc_no_change_at_zero() {
let mut c = InnerHairCell::new();
for _ in 0..200 {
c.step(0.0);
}
assert!(
(c.v - c.v_rest).abs() < 5.0,
"IHC should stay near rest with no displacement"
);
}
#[test]
fn ihc_ca_increases_with_depolarisation() {
let mut c = InnerHairCell::new();
for _ in 0..200 {
c.step(60.0);
}
assert!(c.ca > 0.0, "Ca2+ should increase during depolarisation");
}
#[test]
fn ihc_reset_roundtrip() {
let mut c = InnerHairCell::new();
for _ in 0..100 {
c.step(50.0);
}
c.reset();
assert_eq!(c.v, c.v_rest);
assert_eq!(c.ca, 0.05);
assert_eq!(c.q, 8.0);
assert_eq!(c.c, 0.0);
assert_eq!(c.w, 0.0);
}
#[test]
fn ihc_bounded() {
let mut c = InnerHairCell::new();
for _ in 0..10000 {
c.step(100.0);
}
assert!(c.v.is_finite());
assert!(c.ca.is_finite());
}
#[test]
fn ihc_vesicle_pool_depletes() {
let mut c = InnerHairCell::new();
let q0 = c.q;
for _ in 0..5000 {
c.step(80.0);
}
assert!(
c.q < q0,
"Vesicle pool should deplete: q0={q0}, q_now={}",
c.q
);
}
#[test]
fn ihc_cleft_transmitter_rises() {
let mut c = InnerHairCell::new();
for _ in 0..2000 {
c.step(80.0);
}
assert!(
c.c > 0.0,
"Cleft transmitter should rise with stimulation: c={}",
c.c
);
}
#[test]
fn ihc_reprocessing_store_fills() {
let mut c = InnerHairCell::new();
for _ in 0..5000 {
c.step(80.0);
}
assert!(
c.w > 0.0,
"Reprocessing store should fill via reuptake: w={}",
c.w
);
}
#[test]
fn ihc_pool_mass_conserved() {
let mut c = InnerHairCell::new();
for _ in 0..10000 {
c.step(80.0);
}
let total = c.q + c.c + c.w;
assert!(
total <= c.m_pool * 1.5,
"Total transmitter should be bounded: q+c+w={total:.2}, m={}",
c.m_pool
);
}
#[test]
fn ihc_performance() {
let mut c = InnerHairCell::new();
let start = std::time::Instant::now();
for _ in 0..100_000 {
c.step(50.0);
}
assert!(start.elapsed().as_millis() < 50);
}
#[test]
fn ohc_depolarises_and_motility() {
let mut c = OuterHairCell::new();
for _ in 0..200 {
c.step(40.0);
}
assert!(c.v > c.v_rest);
assert!(c.motility.abs() > 0.01, "OHC should show motility");
}
#[test]
fn ohc_prestin_bidirectional() {
let mut dep = OuterHairCell::new();
dep.v = -20.0; dep.step(0.0); let mot_dep = dep.motility;
let mut hyp = OuterHairCell::new();
hyp.v = -80.0; hyp.step(0.0);
let mot_hyp = hyp.motility;
assert!(
mot_dep > 0.0,
"Depolarisation should contract: motility={mot_dep:.3}"
);
assert!(
mot_hyp < 0.0,
"Hyperpolarisation should elongate: motility={mot_hyp:.3}"
);
}
#[test]
fn ohc_prestin_asymmetric() {
let mut dep = OuterHairCell::new();
for _ in 0..2000 {
dep.step(80.0);
} let contraction = dep.motility;
let mut hyp = OuterHairCell::new();
for _ in 0..2000 {
hyp.step(0.0);
} let elongation = hyp.motility;
assert!(
contraction.abs() > elongation.abs() * 0.5,
"Asymmetric prestin: contraction={contraction:.3}, elongation={elongation:.3}"
);
}
#[test]
fn ohc_reset() {
let mut c = OuterHairCell::new();
for _ in 0..100 {
c.step(40.0);
}
c.reset();
assert_eq!(c.motility, 0.0);
}
#[test]
fn ohc_bounded() {
let mut c = OuterHairCell::new();
for _ in 0..10000 {
c.step(100.0);
}
assert!(c.v.is_finite());
}
#[test]
fn rod_hyperpolarises_with_light() {
let mut r = RodPhotoreceptor::new();
let v_dark = r.v;
for _ in 0..1000 {
r.step(100.0);
}
assert!(r.v < v_dark, "rod should hyperpolarise: v={}", r.v);
}
#[test]
fn rod_stays_dark_without_light() {
let mut r = RodPhotoreceptor::new();
for _ in 0..500 {
r.step(0.0);
}
assert!((r.v - r.v_dark).abs() < 1.0);
}
#[test]
fn rod_slow_recovery() {
let mut r = RodPhotoreceptor::new();
for _ in 0..500 {
r.step(200.0);
}
let v_after_flash = r.v;
for _ in 0..1000 {
r.step(0.0);
}
assert!(r.v > v_after_flash, "rod should recover in dark");
assert!(r.v < r.v_dark, "rod should not fully recover in 1000 steps");
}
#[test]
fn rod_cgmp_bounded() {
let mut r = RodPhotoreceptor::new();
for _ in 0..10000 {
r.step(1000.0);
}
assert!(
r.cgmp >= 0.0 && r.cgmp <= 1.5,
"cGMP should be bounded: {}",
r.cgmp
);
r.reset();
for _ in 0..10000 {
r.step(-10.0);
} assert!(
r.cgmp >= 0.0 && r.cgmp <= 1.5,
"cGMP should be bounded: {}",
r.cgmp
);
}
#[test]
fn rod_ca_feedback_adaptation() {
let mut r = RodPhotoreceptor::new();
for _ in 0..5000 {
r.step(100.0);
}
let v_adapted = r.v;
let ca_adapted = r.ca;
assert!(
ca_adapted < 1.0,
"Ca²⁺ should drop during light: ca={ca_adapted:.3}"
);
assert!(
v_adapted > r.v_hyper + 1.0,
"Adaptation should partially restore: v={v_adapted:.1}, v_hyper={}",
r.v_hyper
);
}
#[test]
fn rod_performance() {
let mut r = RodPhotoreceptor::new();
let start = std::time::Instant::now();
for _ in 0..100_000 {
r.step(50.0);
}
assert!(start.elapsed().as_millis() < 50);
}
#[test]
fn cone_hyperpolarises_with_light() {
let mut c = ConePhotoreceptor::new();
let v_dark = c.v;
for _ in 0..500 {
c.step(500.0);
}
assert!(c.v < v_dark);
}
#[test]
fn cone_faster_than_rod() {
let mut rod = RodPhotoreceptor::new();
let mut cone = ConePhotoreceptor::new();
for _ in 0..500 {
rod.step(100.0);
cone.step(100.0);
}
for _ in 0..2000 {
rod.step(0.0);
cone.step(0.0);
}
let rod_recovery = rod.v - rod.v_hyper;
let cone_recovery = cone.v - cone.v_hyper;
assert!(
cone_recovery > rod_recovery,
"cone ({cone_recovery:.1}) should recover more than rod ({rod_recovery:.1})"
);
}
#[test]
fn cone_reset() {
let mut c = ConePhotoreceptor::new();
for _ in 0..500 {
c.step(500.0);
}
c.reset();
assert_eq!(c.cgmp, 1.0);
assert_eq!(c.v, c.v_dark);
}
#[test]
fn rgc_on_fires_with_positive_input() {
let mut rgc = RetinalGanglionCell::new();
let spikes: i32 = (0..500).map(|_| rgc.step(20.0)).sum();
assert!(spikes > 0, "ON-RGC should fire with positive input via GLM");
}
#[test]
fn rgc_off_fires_with_negative_input() {
let mut rgc = RetinalGanglionCell::off_centre();
let spikes: i32 = (0..500).map(|_| rgc.step(-20.0)).sum();
assert!(spikes > 0, "OFF-RGC should fire with negative input");
}
#[test]
fn rgc_on_no_fire_without_input() {
let mut rgc = RetinalGanglionCell::new();
let spikes: i32 = (0..500).map(|_| rgc.step(0.0)).sum();
assert_eq!(
spikes, 0,
"GLM with baseline=-3 should be quiescent without input"
);
}
#[test]
fn rgc_history_filter_produces_refractoriness() {
let mut rgc = RetinalGanglionCell::new();
let mut spikes = Vec::new();
for _ in 0..200 {
spikes.push(rgc.step(5.0));
}
for (i, &s) in spikes.iter().enumerate() {
if s == 1 && i + 3 < spikes.len() {
let next3: i32 = spikes[i + 1..i + 4].iter().sum();
assert!(
next3 < 3,
"History filter should suppress some re-firing after spike at {}",
i
);
break;
}
}
}
#[test]
fn rgc_stimulus_filter_is_temporal() {
let mut rgc = RetinalGanglionCell::new();
for _ in 0..5 {
rgc.step(50.0);
}
let late_spikes: i32 = (0..50).map(|_| rgc.step(0.0)).sum();
let has_memory = rgc.stim_buffer.iter().any(|&x| x != 0.0);
assert!(
has_memory || late_spikes >= 0,
"Stimulus filter should retain memory"
);
}
#[test]
fn rgc_glm_has_both_filters() {
let rgc = RetinalGanglionCell::new();
assert!(!rgc.stim_kernel.is_empty(), "Must have stimulus filter");
assert!(!rgc.hist_kernel.is_empty(), "Must have history filter");
assert!(rgc.stim_kernel.len() >= 10, "Stimulus filter too short");
assert!(rgc.hist_kernel.len() >= 10, "History filter too short");
}
#[test]
fn rgc_reset_clears_buffers() {
let mut rgc = RetinalGanglionCell::new();
for _ in 0..100 {
rgc.step(20.0);
}
rgc.reset();
assert!(
rgc.stim_buffer.iter().all(|&x| x == 0.0),
"Stimulus buffer not cleared"
);
assert!(
rgc.hist_buffer.iter().all(|&x| x == 0.0),
"History buffer not cleared"
);
}
#[test]
fn merkel_fires_with_sustained_pressure() {
let mut m = MerkelCell::new();
let spikes: i32 = (0..2000).map(|_| m.step(20.0)).sum();
assert!(spikes > 0, "Merkel should fire with sustained pressure");
}
#[test]
fn merkel_slow_adaptation() {
let mut m = MerkelCell::new();
let first: i32 = (0..1000).map(|_| m.step(20.0)).sum();
let second: i32 = (0..1000).map(|_| m.step(20.0)).sum();
assert!(
second > 0,
"Merkel should still fire in second half (slow adapting)"
);
assert!(second <= first + 5, "Merkel should slowly adapt");
}
#[test]
fn merkel_no_fire_without_pressure() {
let mut m = MerkelCell::new();
let spikes: i32 = (0..1000).map(|_| m.step(0.0)).sum();
assert_eq!(spikes, 0);
}
#[test]
fn merkel_closed_form_membrane_and_adaptation_relaxation() {
let mut m = MerkelCell::new();
m.v = -66.0;
m.adapt = 0.2;
m.gain = 0.0;
let v_inf = m.v_rest - m.adapt;
let expected_v = exact_relax_merkel(m.v, v_inf, m.tau, m.dt);
let adapt_inf = (m.a_adapt * (expected_v - m.v_rest).max(0.0)).max(0.0);
let expected_adapt = exact_relax_merkel(m.adapt, adapt_inf, m.tau_adapt, m.dt).max(0.0);
assert_eq!(m.step(0.0), 0);
assert_close_merkel(m.v, expected_v, 1e-12);
assert_close_merkel(m.adapt, expected_adapt, 1e-12);
}
#[test]
fn merkel_invalid_input_preserves_state() {
let mut m = MerkelCell::new();
let before = m.clone();
assert_eq!(m.step(f64::NAN), 0);
assert_eq!(m.v, before.v);
assert_eq!(m.adapt, before.adapt);
}
#[test]
fn merkel_corrupted_state_preserved_on_step() {
let mut m = MerkelCell::new();
m.adapt = f64::NAN;
let before = m.clone();
assert_eq!(m.step(20.0), 0);
assert_eq!(m.v, before.v);
assert!(m.adapt.is_nan());
}
#[test]
fn merkel_invalid_voltage_preserved_on_step() {
let mut m = MerkelCell::new();
m.v = 60.1;
let before = m.clone();
assert_eq!(m.step(20.0), 0);
assert_eq!(m.v, before.v);
assert_eq!(m.adapt, before.adapt);
}
fn exact_relax_merkel(value: f64, target: f64, tau: f64, dt: f64) -> f64 {
target + (value - target) * (-dt / tau).exp()
}
fn assert_close_merkel(actual: f64, expected: f64, tolerance: f64) {
assert!(
(actual - expected).abs() <= tolerance,
"actual={:.16e} expected={:.16e} tolerance={:.3e}",
actual,
expected,
tolerance
);
}
#[test]
fn merkel_reset() {
let mut m = MerkelCell::new();
for _ in 0..500 {
m.step(20.0);
}
m.reset();
assert_eq!(m.adapt, 0.0);
}
#[test]
fn pacinian_fires_on_pressure_onset() {
let mut p = PacinianCorpuscle::new();
let spikes: i32 = (0..100).map(|i| p.step(i as f64 * 2.0)).sum();
assert!(spikes > 0, "Pacinian should fire on pressure onset");
}
#[test]
fn pacinian_adapts_to_sustained() {
let mut p = PacinianCorpuscle::new();
let onset: i32 = (0..10).map(|i| p.step(i as f64 * 10.0)).sum();
let sustained: i32 = (0..500).map(|_| p.step(100.0)).sum();
assert!(
sustained <= onset + 5,
"Pacinian should adapt to sustained: onset={onset}, sustained={sustained}"
);
}
#[test]
fn pacinian_no_fire_at_rest() {
let mut p = PacinianCorpuscle::new();
let spikes: i32 = (0..500).map(|_| p.step(0.0)).sum();
assert_eq!(spikes, 0);
}
#[test]
fn pacinian_closed_form_membrane_and_adaptation_relaxation() {
let mut p = PacinianCorpuscle::new();
p.v = -66.0;
p.prev_pressure = 5.0;
p.adapt = 0.2;
p.gain = 0.0;
let pressure = 5.0;
let dp = (pressure - p.prev_pressure) / p.dt;
let drive = p.gain * dp.abs() - p.adapt;
let expected_v = exact_relax_pacinian(p.v, p.v_rest + drive, p.tau, p.dt);
let expected_adapt =
exact_relax_pacinian(p.adapt, 0.5 * drive.max(0.0), p.tau_adapt, p.dt).max(0.0);
assert_eq!(p.step(pressure), 0);
assert_eq!(p.prev_pressure, pressure);
assert_close_pacinian(p.v, expected_v, 1e-12);
assert_close_pacinian(p.adapt, expected_adapt, 1e-12);
}
#[test]
fn pacinian_invalid_input_preserves_state() {
let mut p = PacinianCorpuscle::new();
p.prev_pressure = 12.0;
p.adapt = 0.4;
let before = p.clone();
assert_eq!(p.step(f64::NAN), 0);
assert_eq!(p.v, before.v);
assert_eq!(p.prev_pressure, before.prev_pressure);
assert_eq!(p.adapt, before.adapt);
}
#[test]
fn pacinian_corrupted_state_preserved_on_step() {
let mut p = PacinianCorpuscle::new();
p.prev_pressure = f64::NAN;
let before = p.clone();
assert_eq!(p.step(10.0), 0);
assert_eq!(p.v, before.v);
assert!(p.prev_pressure.is_nan());
assert_eq!(p.adapt, before.adapt);
}
#[test]
fn pacinian_invalid_voltage_preserved_on_step() {
let mut p = PacinianCorpuscle::new();
p.v = -100.1;
let before = p.clone();
assert_eq!(p.step(10.0), 0);
assert_eq!(p.v, before.v);
assert_eq!(p.prev_pressure, before.prev_pressure);
assert_eq!(p.adapt, before.adapt);
}
fn exact_relax_pacinian(value: f64, target: f64, tau: f64, dt: f64) -> f64 {
target + (value - target) * (-dt / tau).exp()
}
fn assert_close_pacinian(actual: f64, expected: f64, tolerance: f64) {
assert!(
(actual - expected).abs() <= tolerance,
"actual={:.16e} expected={:.16e} tolerance={:.3e}",
actual,
expected,
tolerance
);
}
#[test]
fn pacinian_reset() {
let mut p = PacinianCorpuscle::new();
for i in 0..100 {
p.step(i as f64);
}
p.reset();
assert_eq!(p.prev_pressure, 0.0);
assert_eq!(p.adapt, 0.0);
}
#[test]
fn nociceptor_high_threshold() {
let mut n = Nociceptor::new();
let low: i32 = (0..500).map(|_| n.step(5.0)).sum();
assert_eq!(low, 0, "nociceptor should not fire at low stimulus");
n.reset();
let high: i32 = (0..500).map(|_| n.step(50.0)).sum();
assert!(high > 0, "nociceptor should fire at high stimulus");
}
#[test]
fn nociceptor_closed_form_membrane_and_sensitisation_decay() {
let mut n = Nociceptor::new();
n.v = -60.0;
n.sensitisation = 4.0;
n.gain = 0.5;
let stimulus = 8.0;
let drive = n.gain * stimulus;
let expected_v = exact_relax_nociceptor(n.v, n.v_rest + drive, n.tau, n.dt);
let expected_sensitisation =
exact_relax_nociceptor(n.sensitisation, 0.0, n.tau_sens, n.dt).max(0.0);
assert_eq!(n.step(stimulus), 0);
assert_close_nociceptor(n.v, expected_v, 1e-12);
assert_close_nociceptor(n.sensitisation, expected_sensitisation, 1e-12);
}
#[test]
fn nociceptor_invalid_input_preserves_state() {
let mut n = Nociceptor::new();
n.v = -60.0;
n.sensitisation = 2.0;
let before = n.clone();
assert_eq!(n.step(f64::NAN), 0);
assert_eq!(n.v, before.v);
assert_eq!(n.sensitisation, before.sensitisation);
}
#[test]
fn nociceptor_corrupted_state_preserved_on_step() {
let mut n = Nociceptor::new();
n.sensitisation = f64::NAN;
let before = n.clone();
assert_eq!(n.step(50.0), 0);
assert_eq!(n.v, before.v);
assert!(n.sensitisation.is_nan());
}
#[test]
fn nociceptor_invalid_voltage_preserved_on_step() {
let mut n = Nociceptor::new();
n.v = -100.1;
let before = n.clone();
assert_eq!(n.step(50.0), 0);
assert_eq!(n.v, before.v);
assert_eq!(n.sensitisation, before.sensitisation);
}
#[test]
fn nociceptor_overflowing_drive_preserves_state() {
let mut n = Nociceptor::new();
n.gain = f64::MAX;
let before = n.clone();
assert_eq!(n.step(2.0), 0);
assert_eq!(n.v, before.v);
assert_eq!(n.sensitisation, before.sensitisation);
}
fn exact_relax_nociceptor(value: f64, target: f64, tau: f64, dt: f64) -> f64 {
target + (value - target) * (-dt / tau).exp()
}
fn assert_close_nociceptor(actual: f64, expected: f64, tolerance: f64) {
assert!(
(actual - expected).abs() <= tolerance,
"actual={:.16e} expected={:.16e} tolerance={:.3e}",
actual,
expected,
tolerance
);
}
#[test]
fn nociceptor_sensitisation() {
let mut n = Nociceptor::new();
for _ in 0..1000 {
n.step(50.0);
}
assert!(n.sensitisation > 0.0, "sensitisation should increase");
let sens = n.sensitisation;
for _ in 0..50000 {
n.step(0.0);
}
assert!(
n.sensitisation < sens,
"sensitisation should decay: was {sens}, now {}",
n.sensitisation
);
}
#[test]
fn nociceptor_no_fire_without_stimulus() {
let mut n = Nociceptor::new();
let spikes: i32 = (0..1000).map(|_| n.step(0.0)).sum();
assert_eq!(spikes, 0);
}
#[test]
fn nociceptor_reset() {
let mut n = Nociceptor::new();
for _ in 0..500 {
n.step(50.0);
}
n.reset();
assert_eq!(n.sensitisation, 0.0);
}
#[test]
fn olfactory_fires_with_odorant() {
let mut o = OlfactoryReceptorNeuron::new();
let spikes: i32 = (0..2000).map(|_| o.step(5.0)).sum();
assert!(spikes > 0, "olfactory should fire with odorant");
}
#[test]
fn olfactory_adapts() {
let mut o = OlfactoryReceptorNeuron::new();
let first: i32 = (0..2000).map(|_| o.step(5.0)).sum();
let second: i32 = (0..2000).map(|_| o.step(5.0)).sum();
assert!(
second <= first + 5,
"olfactory should adapt: first={first}, second={second}"
);
}
#[test]
fn olfactory_no_fire_without_odorant() {
let mut o = OlfactoryReceptorNeuron::new();
let spikes: i32 = (0..1000).map(|_| o.step(0.0)).sum();
assert_eq!(spikes, 0);
}
#[test]
fn olfactory_reset() {
let mut o = OlfactoryReceptorNeuron::new();
for _ in 0..1000 {
o.step(5.0);
}
o.reset();
assert_eq!(o.camp, 0.0);
assert_eq!(o.adapt, 0.0);
assert_eq!(o.pde4, 0.0);
}
#[test]
fn olfactory_pde4_activates_with_odorant() {
let mut o = OlfactoryReceptorNeuron::new();
assert_eq!(o.pde4, 0.0);
for _ in 0..5000 {
o.step(5.0);
}
assert!(
o.pde4 > 0.0,
"PDE4 should activate with sustained odorant, got {}",
o.pde4
);
}
#[test]
fn olfactory_pde4_reduces_camp() {
let mut with_pde4 = OlfactoryReceptorNeuron::new();
let mut no_pde4 = OlfactoryReceptorNeuron::new();
no_pde4.k_pde4 = 0.0;
for _ in 0..10_000 {
with_pde4.step(5.0);
no_pde4.step(5.0);
}
assert!(
with_pde4.camp < no_pde4.camp,
"PDE4 should reduce cAMP: with={:.3} vs without={:.3}",
with_pde4.camp,
no_pde4.camp
);
}
#[test]
fn olfactory_pde4_enhances_adaptation() {
let mut with_pde4 = OlfactoryReceptorNeuron::new();
let mut no_pde4 = OlfactoryReceptorNeuron::new();
no_pde4.k_pde4 = 0.0;
for _ in 0..5000 {
with_pde4.step(5.0);
no_pde4.step(5.0);
}
let spikes_with: i32 = (0..5000).map(|_| with_pde4.step(5.0)).sum();
let spikes_no: i32 = (0..5000).map(|_| no_pde4.step(5.0)).sum();
assert!(
spikes_with <= spikes_no,
"PDE4 should enhance adaptation: with={spikes_with}, without={spikes_no}"
);
}
#[test]
fn taste_depolarises_with_tastant() {
let mut t = TasteReceptorCell::new();
let v_rest = t.v;
for _ in 0..500 {
t.step(5.0);
}
assert!(t.v > v_rest, "taste cell should depolarise");
}
#[test]
fn taste_atp_release() {
let mut t = TasteReceptorCell::new();
for _ in 0..500 {
t.step(5.0);
}
assert!(t.atp_release > 0.0, "ATP should be released");
}
#[test]
fn taste_no_response_without_tastant() {
let mut t = TasteReceptorCell::new();
for _ in 0..500 {
t.step(0.0);
}
assert!((t.v - t.v_rest).abs() < 2.0);
assert!(t.atp_release < 0.01);
}
#[test]
fn taste_ca_bounded() {
let mut t = TasteReceptorCell::new();
for _ in 0..10000 {
t.step(100.0);
}
assert!(t.ca >= 0.0 && t.ca <= 1.0);
assert!(t.ip3 >= 0.0 && t.ip3 <= 1.0);
}
#[test]
fn taste_reset() {
let mut t = TasteReceptorCell::new();
for _ in 0..500 {
t.step(5.0);
}
t.reset();
assert_eq!(t.ca, 0.0);
assert_eq!(t.ip3, 0.0);
assert_eq!(t.atp_release, 0.0);
}
}
#[derive(Clone, Debug)]
pub struct DirectionSelectiveRGC {
pub v: f64,
pub tau: f64,
pub theta: f64,
pub dt: f64,
pub is_on_centre: bool,
prev_intensity: f64,
surround: f64,
pub w_centre: f64,
pub w_surround: f64,
pub direction_pref: f64,
}
impl DirectionSelectiveRGC {
pub fn new_on() -> Self {
Self {
v: 0.0,
tau: 10.0,
theta: 0.5,
dt: 1.0,
is_on_centre: true,
prev_intensity: 0.0,
surround: 0.0,
w_centre: 1.0,
w_surround: 0.3,
direction_pref: 0.0,
}
}
pub fn new_off() -> Self {
let mut cell = Self::new_on();
cell.is_on_centre = false;
cell
}
fn valid_runtime(&self) -> bool {
[
self.v,
self.tau,
self.theta,
self.dt,
self.prev_intensity,
self.surround,
self.w_centre,
self.w_surround,
self.direction_pref,
]
.iter()
.all(|x| x.is_finite())
&& self.tau > 0.0
&& self.theta > 0.0
&& self.dt > 0.0
&& self.prev_intensity >= 0.0
&& self.surround >= 0.0
&& self.w_centre >= 0.0
&& self.w_surround >= 0.0
}
pub fn step_rf(&mut self, intensity: f64, surround_mean: f64) -> i32 {
if !intensity.is_finite()
|| !surround_mean.is_finite()
|| intensity < 0.0
|| surround_mean < 0.0
|| !self.valid_runtime()
{
return 0;
}
let temporal_diff = intensity - self.prev_intensity;
let centre_response = if self.is_on_centre {
self.w_centre * temporal_diff
} else {
-self.w_centre * temporal_diff
};
let next_surround = 0.9 * self.surround + 0.1 * surround_mean;
let surround_inhib = self.w_surround * next_surround;
let drive = centre_response - surround_inhib;
let decay = (-self.dt / self.tau).exp();
let next_v = drive + (self.v - drive) * decay;
if !next_surround.is_finite()
|| !drive.is_finite()
|| !decay.is_finite()
|| !next_v.is_finite()
|| next_surround < 0.0
{
return 0;
}
self.prev_intensity = intensity;
self.surround = next_surround;
if next_v >= self.theta {
self.v = 0.0;
1
} else {
self.v = next_v;
0
}
}
pub fn step(&mut self, current: f64) -> i32 {
self.step_rf(current, 0.0)
}
pub fn reset(&mut self) {
self.v = 0.0;
self.prev_intensity = 0.0;
self.surround = 0.0;
}
}
impl Default for DirectionSelectiveRGC {
fn default() -> Self {
Self::new_on()
}
}
#[derive(Clone, Debug)]
pub struct CochlearHairCell {
pub v: f64,
pub g_max: f64,
pub e_met: f64,
pub g_l: f64,
pub e_l: f64,
pub cap: f64,
pub x0: f64,
pub delta: f64,
pub dt: f64,
pub glutamate_release: f64,
}
impl CochlearHairCell {
pub fn new() -> Self {
Self {
v: -60.0,
g_max: 10.0,
e_met: 0.0,
g_l: 1.0,
e_l: -60.0,
cap: 10.0,
x0: 0.0,
delta: 0.1,
dt: 0.01,
glutamate_release: 0.0,
}
}
fn p_open(&self, displacement: f64) -> f64 {
let z = (displacement - self.x0) / self.delta;
if z >= 0.0 {
1.0 / (1.0 + (-z).exp())
} else {
let ez = z.exp();
ez / (1.0 + ez)
}
}
fn valid_runtime(&self) -> bool {
[
self.v,
self.g_max,
self.e_met,
self.g_l,
self.e_l,
self.cap,
self.x0,
self.delta,
self.dt,
self.glutamate_release,
]
.iter()
.all(|x| x.is_finite())
&& self.g_max >= 0.0
&& self.g_l > 0.0
&& self.cap > 0.0
&& self.delta > 0.0
&& self.dt > 0.0
&& self.glutamate_release >= 0.0
}
pub fn step(&mut self, displacement: f64) -> i32 {
if !self.valid_runtime() || !displacement.is_finite() {
return 0;
}
let po = self.p_open(displacement);
let g_met = self.g_max * po;
let g_total = self.g_l + g_met;
if !(g_total.is_finite() && g_total > 0.0) {
return 0;
}
let v_inf = (self.g_l * self.e_l + g_met * self.e_met) / g_total;
let candidate_v = v_inf + (self.v - v_inf) * (-(g_total / self.cap) * self.dt).exp();
let candidate_release = (candidate_v + 60.0).max(0.0) / 40.0;
if !(candidate_v.is_finite() && candidate_release.is_finite()) {
return 0;
}
self.v = candidate_v;
self.glutamate_release = candidate_release;
if self.glutamate_release > 0.5 {
1
} else {
0
}
}
pub fn reset(&mut self) {
self.v = self.e_l;
self.glutamate_release = 0.0;
}
}
impl Default for CochlearHairCell {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod gap_sensory_tests {
use super::*;
#[test]
fn rgc_on_responds_to_light_increase() {
let mut cell = DirectionSelectiveRGC::new_on();
for _ in 0..10 {
cell.step_rf(0.0, 0.0);
}
let mut spikes = 0;
for _ in 0..20 {
spikes += cell.step_rf(6.0, 0.0);
}
assert!(spikes > 0, "On-centre must respond to light increase");
}
#[test]
fn rgc_off_responds_to_light_decrease() {
let mut cell = DirectionSelectiveRGC::new_off();
cell.theta = 0.1; let mut spikes = 0;
for i in 0..400 {
let intensity = if (i / 10) % 2 == 0 { 5.0 } else { 0.0 };
spikes += cell.step_rf(intensity, 0.0);
}
assert!(spikes > 0, "Off-centre must respond to light transitions");
}
#[test]
fn rgc_surround_inhibition() {
let mut no_surr = DirectionSelectiveRGC::new_on();
let mut with_surr = DirectionSelectiveRGC::new_on();
let mut spikes_no = 0;
let mut spikes_surr = 0;
for i in 0..200 {
let intensity = if i % 10 == 0 { 3.0 } else { 0.0 };
spikes_no += no_surr.step_rf(intensity, 0.0);
spikes_surr += with_surr.step_rf(intensity, 2.0);
}
assert!(
spikes_surr <= spikes_no,
"Surround should inhibit: surr={spikes_surr} <= no={spikes_no}"
);
}
#[test]
fn rgc_exact_membrane_relaxation() {
let mut cell = DirectionSelectiveRGC::new_on();
cell.tau = 7.0;
cell.theta = 100.0;
cell.dt = 1.25;
cell.w_centre = 1.4;
cell.w_surround = 0.2;
cell.v = 0.35;
let expected_surround = 0.9 * cell.surround + 0.1 * 0.5;
let expected_drive =
cell.w_centre * (2.0 - cell.prev_intensity) - cell.w_surround * expected_surround;
let expected_v = expected_drive + (cell.v - expected_drive) * (-cell.dt / cell.tau).exp();
assert_eq!(cell.step_rf(2.0, 0.5), 0);
assert!((cell.v - expected_v).abs() < 1e-12);
assert!((cell.surround - expected_surround).abs() < 1e-12);
}
#[test]
fn rgc_invalid_drive_preserves_state() {
let mut cell = DirectionSelectiveRGC::new_on();
let before = (cell.v, cell.prev_intensity, cell.surround);
assert_eq!(cell.step_rf(f64::NAN, 0.0), 0);
assert_eq!((cell.v, cell.prev_intensity, cell.surround), before);
}
#[test]
fn rgc_corrupt_runtime_state_preserves_state() {
let mut cell = DirectionSelectiveRGC::new_on();
cell.surround = f64::INFINITY;
let before = (cell.v, cell.prev_intensity, cell.surround);
assert_eq!(cell.step_rf(1.0, 0.0), 0);
assert_eq!((cell.v, cell.prev_intensity, cell.surround), before);
}
#[test]
fn cochlear_displacement_depolarises() {
let mut cell = CochlearHairCell::new();
let v_rest = cell.v;
for _ in 0..100 {
cell.step(0.5);
}
assert!(
cell.v > v_rest,
"Positive displacement should depolarise: {:.2} > {:.2}",
cell.v,
v_rest
);
}
#[test]
fn cochlear_matches_closed_form_membrane_relaxation() {
let mut cell = CochlearHairCell::new();
let po = 1.0 / (1.0 + (-(0.0 - cell.x0) / cell.delta).exp());
let g_met = cell.g_max * po;
let g_total = cell.g_l + g_met;
let v_inf = (cell.g_l * cell.e_l + g_met * cell.e_met) / g_total;
let expected = v_inf + (cell.v - v_inf) * (-(g_total / cell.cap) * cell.dt).exp();
let spike = cell.step(0.0);
assert!(spike == 0 || spike == 1);
assert!((cell.v - expected).abs() < 1e-12);
}
#[test]
fn cochlear_invalid_runtime_preserves_state() {
let mut cell = CochlearHairCell::new();
cell.v = -55.0;
cell.glutamate_release = 0.125;
let before = (cell.v, cell.glutamate_release);
cell.cap = -1.0;
assert_eq!(cell.step(0.25), 0);
assert_eq!((cell.v, cell.glutamate_release), before);
}
#[test]
fn cochlear_graded_release() {
let mut cell = CochlearHairCell::new();
for _ in 0..200 {
cell.step(1.0);
}
assert!(
cell.glutamate_release > 0.0,
"Should release glutamate: {:.4}",
cell.glutamate_release
);
}
#[test]
fn cochlear_zero_displacement_rest() {
let mut cell = CochlearHairCell::new();
for _ in 0..100 {
cell.step(0.0);
}
assert!(
cell.v > -80.0 && cell.v < 0.0,
"Zero displacement → physiological range: {:.2}",
cell.v
);
}
#[test]
fn cochlear_reset() {
let mut cell = CochlearHairCell::new();
for _ in 0..100 {
cell.step(1.0);
}
cell.reset();
assert_eq!(cell.v, cell.e_l);
assert_eq!(cell.glutamate_release, 0.0);
}
}