1#[derive(Clone, Debug)]
54pub struct InnerHairCell {
55 pub v: f64, pub v_rest: f64,
58 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,
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, s_ca: 8.0,
96 q: 8.0,
98 c: 0.0,
99 w: 0.0,
100 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,
108 }
109 }
110
111 #[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 pub fn step(&mut self, displacement: f64) -> f64 {
121 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 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; self.ca += (-self.ca / self.tau_ca + ca_entry) * self.dt;
130 self.ca = self.ca.max(0.0);
131
132 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 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#[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 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,
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, v_pk: -40.0, z_e: 0.7, v_t: 26.0, q_max: 0.8, asym_factor: 0.3, dt: 0.025,
242 }
243 }
244
245 #[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 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 let compact = self.prestin_compact();
260 let raw_motility = self.l_max * (0.5 - compact);
263 let asym = if raw_motility > 0.0 {
265 1.0 + self.asym_factor } else {
267 1.0 - self.asym_factor };
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#[derive(Clone, Debug)]
314pub struct RodPhotoreceptor {
315 pub v: f64,
316 pub v_dark: f64,
317 pub v_hyper: f64,
318 pub cgmp: f64, pub ca: f64, pub tau_act: f64, pub tau_ca: f64, pub sensitivity: f64,
323 pub alpha_max: f64, pub k_gc: f64, pub n_gc: f64, pub eta_ca: f64, 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, tau_act: 20.0,
339 tau_ca: 30.0, sensitivity: 0.01,
341 alpha_max: 0.05, k_gc: 0.5, n_gc: 4.0, eta_ca: 0.3, dt: 0.1,
346 }
347 }
348
349 #[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 pub fn step(&mut self, light: f64) -> f64 {
360 let light_clamped = light.max(0.0);
361
362 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; self.cgmp += d_cgmp * self.dt;
367 self.cgmp = self.cgmp.clamp(0.0, 1.5); let cng_fraction = self.cgmp.powi(3).min(1.0);
371
372 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 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#[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, tau_rec: 50.0, sensitivity: 0.001, 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#[derive(Clone, Debug)]
498pub struct RetinalGanglionCell {
499 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,
508
509 pub baseline: f64, pub on_centre: bool, pub spike_threshold: f64, pub dt: f64,
513 pub gain: f64,
514}
515
516impl RetinalGanglionCell {
517 pub fn new() -> Self {
520 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 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 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 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; 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;
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, on_centre: true,
560 spike_threshold: 0.7, 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 #[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 let buf_idx = (write_idx + n - 1 - i) % n;
581 sum += buffer[buf_idx] * kernel[i];
582 }
583 sum
584 }
585
586 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 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 let filtered_stim = Self::convolve(&self.stim_buffer, &self.stim_kernel, self.stim_idx);
600
601 let n_hist = self.hist_kernel.len();
603 let filtered_hist = Self::convolve(&self.hist_buffer, &self.hist_kernel, self.hist_idx);
604
605 let log_rate = filtered_stim + filtered_hist + self.baseline;
607 let lambda = log_rate.exp().min(1000.0); let spiked = if lambda * self.dt > self.spike_threshold {
611 1
612 } else {
613 0
614 };
615
616 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#[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, pub tau_adapt: f64, pub a_adapt: f64, 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, a_adapt: 0.3,
677 gain: 1.5,
678 dt: 0.5,
679 }
680 }
681
682 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#[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, gain: 10.0, dt: 0.5,
747 }
748 }
749
750 pub fn step(&mut self, pressure: f64) -> i32 {
752 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#[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, pub tau_sens: f64, pub sens_rate: f64, 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, tau: 8.0,
814 sensitisation: 0.0,
815 tau_sens: 5000.0, sens_rate: 0.5,
817 gain: 1.0,
818 dt: 0.5,
819 }
820 }
821
822 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 self.sensitisation = (self.sensitisation + self.sens_rate).min(10.0);
832 1
833 } else {
834 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#[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, 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,
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, k_pde4: 1.5, gain: 1.5,
903 dt: 0.5,
904 }
905 }
906
907 pub fn step(&mut self, concentration: f64) -> i32 {
909 let conc = concentration.max(0.0);
910
911 let camp_production = conc / (conc + 1.0) * (1.0 - 0.8 * self.adapt);
913 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 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; self.v += (-(self.v - self.v_rest) + drive) / self.tau * self.dt;
925
926 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#[derive(Clone, Debug)]
969pub struct TasteReceptorCell {
970 pub v: f64,
971 pub v_rest: f64,
972 pub tau: f64,
973 pub ca: f64, pub ip3: f64, pub tau_ip3: f64,
976 pub tau_ca: f64,
977 pub gain: f64,
978 pub atp_release: f64, 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 pub fn step(&mut self, tastant: f64) -> f64 {
1000 let conc = tastant.max(0.0);
1001 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 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 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 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#[cfg(test)]
1040mod tests {
1041 use super::*;
1042
1043 #[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 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 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 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 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 #[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 let mut dep = OuterHairCell::new();
1185 dep.v = -20.0; dep.step(0.0); let mot_dep = dep.motility;
1188
1189 let mut hyp = OuterHairCell::new();
1190 hyp.v = -80.0; 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 let mut dep = OuterHairCell::new();
1209 for _ in 0..2000 {
1210 dep.step(80.0);
1211 } let contraction = dep.motility;
1213
1214 let mut hyp = OuterHairCell::new();
1216 for _ in 0..2000 {
1217 hyp.step(0.0);
1218 } let elongation = hyp.motility;
1220
1221 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 #[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 for _ in 0..500 {
1275 r.step(200.0);
1276 }
1277 let v_after_flash = r.v;
1278 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 } 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 let mut r = RodPhotoreceptor::new();
1314 for _ in 0..5000 {
1316 r.step(100.0);
1317 }
1318 let v_adapted = r.v;
1319 let ca_adapted = r.ca;
1320 assert!(
1322 ca_adapted < 1.0,
1323 "Ca²⁺ should drop during light: ca={ca_adapted:.3}"
1324 );
1325 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 #[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 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 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 #[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 let mut rgc = RetinalGanglionCell::new();
1419 let mut spikes = Vec::new();
1420 for _ in 0..200 {
1422 spikes.push(rgc.step(5.0));
1423 }
1424 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 let mut rgc = RetinalGanglionCell::new();
1442 for _ in 0..5 {
1444 rgc.step(50.0);
1445 }
1446 let late_spikes: i32 = (0..50).map(|_| rgc.step(0.0)).sum();
1448 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 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 #[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 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 #[test]
1525 fn pacinian_fires_on_pressure_onset() {
1526 let mut p = PacinianCorpuscle::new();
1527 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 let onset: i32 = (0..10).map(|i| p.step(i as f64 * 10.0)).sum();
1537 let sustained: i32 = (0..500).map(|_| p.step(100.0)).sum();
1539 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 #[test]
1567 fn nociceptor_high_threshold() {
1568 let mut n = Nociceptor::new();
1569 let low: i32 = (0..500).map(|_| n.step(5.0)).sum();
1571 assert_eq!(low, 0, "nociceptor should not fire at low stimulus");
1572 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 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 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 #[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 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 let mut with_pde4 = OlfactoryReceptorNeuron::new();
1673 let mut no_pde4 = OlfactoryReceptorNeuron::new();
1674 no_pde4.k_pde4 = 0.0; 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 let mut with_pde4 = OlfactoryReceptorNeuron::new();
1692 let mut no_pde4 = OlfactoryReceptorNeuron::new();
1693 no_pde4.k_pde4 = 0.0;
1694
1695 for _ in 0..5000 {
1697 with_pde4.step(5.0);
1698 no_pde4.step(5.0);
1699 }
1700 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 #[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#[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 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 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#[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 fn p_open(&self, displacement: f64) -> f64 {
1895 1.0 / (1.0 + (-(displacement - self.x0) / self.delta).exp())
1896 }
1897
1898 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 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 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; 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 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 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}