1#[derive(Clone, Debug)]
34pub struct GranuleCell {
35 pub v: f64, pub m: f64, pub h: f64, pub n: f64, pub a: f64, pub b: f64, pub m_t: f64, pub s: f64, pub ca: f64, pub r: f64, pub c_m: f64, pub g_na: f64, pub g_kdr: f64, pub g_ka: f64, pub g_t: f64, pub g_kca: f64, pub g_h: f64, pub g_l: f64, pub g_tonic: f64, pub e_na: f64,
55 pub e_k: f64,
56 pub e_ca: f64,
57 pub e_h: f64, pub e_l: f64,
59 pub e_gaba: f64,
60 pub tau_ca: f64, pub kd_kca: f64, pub dt: f64,
63 pub sub_steps: usize,
64 pub gain: f64,
65}
66
67impl Default for GranuleCell {
68 fn default() -> Self {
69 Self::new()
70 }
71}
72
73impl GranuleCell {
74 pub fn new() -> Self {
75 Self {
76 v: -70.0,
77 m: 0.02,
78 h: 0.85,
79 n: 0.05,
80 a: 0.1,
81 b: 0.8,
82 m_t: 0.01,
83 s: 0.95,
84 ca: 0.05,
85 r: 0.1,
86 c_m: 1.0,
87 g_na: 17.0, g_kdr: 9.0, g_ka: 1.0, g_t: 0.5, g_kca: 3.5, g_h: 0.03, g_l: 0.1, g_tonic: 0.2, e_na: 87.4, e_k: -84.7,
97 e_ca: 129.3,
98 e_h: -40.0, e_l: -58.0, e_gaba: -75.0,
101 tau_ca: 10.0, kd_kca: 0.2, dt: 0.5,
104 sub_steps: 4, gain: 1.0,
106 }
107 }
108
109 #[inline]
111 fn boltz(v: f64, vh: f64, k: f64) -> f64 {
112 let z = -(v - vh) / k;
113 if z > 60.0 {
114 0.0
115 } else if z < -60.0 {
116 1.0
117 } else {
118 1.0 / (1.0 + z.exp())
119 }
120 }
121
122 fn is_valid(&self) -> bool {
123 [
124 self.v,
125 self.m,
126 self.h,
127 self.n,
128 self.a,
129 self.b,
130 self.m_t,
131 self.s,
132 self.ca,
133 self.r,
134 self.c_m,
135 self.g_na,
136 self.g_kdr,
137 self.g_ka,
138 self.g_t,
139 self.g_kca,
140 self.g_h,
141 self.g_l,
142 self.g_tonic,
143 self.e_na,
144 self.e_k,
145 self.e_ca,
146 self.e_h,
147 self.e_l,
148 self.e_gaba,
149 self.tau_ca,
150 self.kd_kca,
151 self.dt,
152 self.gain,
153 ]
154 .iter()
155 .all(|value| value.is_finite())
156 && [
157 self.m, self.h, self.n, self.a, self.b, self.m_t, self.s, self.r,
158 ]
159 .iter()
160 .all(|gate| (0.0..=1.0).contains(gate))
161 && (-100.0..=60.0).contains(&self.v)
162 && self.ca >= 0.0
163 && [
164 self.g_na,
165 self.g_kdr,
166 self.g_ka,
167 self.g_t,
168 self.g_kca,
169 self.g_h,
170 self.g_l,
171 self.g_tonic,
172 ]
173 .iter()
174 .all(|conductance| *conductance >= 0.0)
175 && self.c_m > 0.0
176 && self.tau_ca > 0.0
177 && self.kd_kca > 0.0
178 && self.dt > 0.0
179 && self.sub_steps > 0
180 && self.gain >= 0.0
181 }
182
183 pub fn step(&mut self, current: f64) -> i32 {
184 if !self.is_valid() || !current.is_finite() {
185 return 0;
186 }
187
188 let input = self.gain * current;
189 let dt_sub = self.dt / self.sub_steps as f64;
190 let v_prev = self.v;
191 let mut v = self.v;
192 let mut m = self.m;
193 let mut h = self.h;
194 let mut n = self.n;
195 let mut a = self.a;
196 let mut b = self.b;
197 let mut m_t = self.m_t;
198 let mut s_gate = self.s;
199 let mut ca = self.ca;
200 let mut r = self.r;
201
202 for _ in 0..self.sub_steps {
203 let m_inf = Self::boltz(v, -30.0, 7.0);
205 let tau_m = 0.1 + 0.3 / (1.0 + ((v + 30.0) / 10.0).powi(2)).max(0.01);
206 m = granule_exact_relax(m, m_inf, tau_m, dt_sub).clamp(0.0, 1.0);
207
208 let h_inf = Self::boltz(v, -52.0, -6.0);
210 let tau_h = 0.5 + 5.0 / (1.0 + ((v + 50.0) / 15.0).powi(2)).max(0.01);
211 h = granule_exact_relax(h, h_inf, tau_h, dt_sub).clamp(0.0, 1.0);
212
213 let n_inf = Self::boltz(v, -35.0, 8.0);
215 let tau_n = 1.0 + 5.0 / (1.0 + ((v + 35.0) / 15.0).powi(2)).max(0.01);
216 n = granule_exact_relax(n, n_inf, tau_n, dt_sub).clamp(0.0, 1.0);
217
218 let a_inf = Self::boltz(v, -50.0, 20.0);
220 let tau_a = 2.0;
221 a = granule_exact_relax(a, a_inf, tau_a, dt_sub).clamp(0.0, 1.0);
222
223 let b_inf = Self::boltz(v, -70.0, -6.0);
225 let tau_b = 50.0;
226 b = granule_exact_relax(b, b_inf, tau_b, dt_sub).clamp(0.0, 1.0);
227
228 let mt_inf = Self::boltz(v, -52.0, 5.0);
230 let tau_mt = 1.0;
231 m_t = granule_exact_relax(m_t, mt_inf, tau_mt, dt_sub).clamp(0.0, 1.0);
232
233 let s_inf = Self::boltz(v, -60.0, -6.5);
235 let tau_s = 20.0 + 50.0 / (1.0 + ((v + 65.0) / 10.0).powi(2)).max(0.01);
236 s_gate = granule_exact_relax(s_gate, s_inf, tau_s, dt_sub).clamp(0.0, 1.0);
237
238 let r_inf = Self::boltz(v, -80.0, -10.0);
240 let tau_r = 50.0 + 200.0 / (1.0 + ((v + 80.0) / 20.0).powi(2)).max(0.01);
241 r = granule_exact_relax(r, r_inf, tau_r, dt_sub).clamp(0.0, 1.0);
242
243 let i_ca_t = self.g_t * m_t * m_t * s_gate * (v - self.e_ca);
245 let ca_entry = if i_ca_t < 0.0 { -i_ca_t * 0.001 } else { 0.0 }; ca = granule_exact_relax(ca, ca_entry * self.tau_ca, self.tau_ca, dt_sub).max(0.0);
247
248 let kca_inf = ca * ca / (ca * ca + self.kd_kca * self.kd_kca);
250
251 let g_na_eff = self.g_na * m.powi(3) * h;
253 let g_kdr_eff = self.g_kdr * n.powi(4);
254 let g_ka_eff = self.g_ka * a.powi(3) * b;
255 let g_t_eff = self.g_t * m_t * m_t * s_gate;
256 let g_kca_eff = self.g_kca * kca_inf;
257 let g_h_eff = self.g_h * r;
258 v = granule_exact_voltage_step(
259 v,
260 input,
261 self.c_m,
262 dt_sub,
263 &[
264 (g_na_eff, self.e_na),
265 (g_kdr_eff, self.e_k),
266 (g_ka_eff, self.e_k),
267 (g_t_eff, self.e_ca),
268 (g_kca_eff, self.e_k),
269 (g_h_eff, self.e_h),
270 (self.g_l, self.e_l),
271 (self.g_tonic, self.e_gaba),
272 ],
273 )
274 .clamp(-100.0, 60.0);
275
276 if ![v, m, h, n, a, b, m_t, s_gate, ca, r]
277 .iter()
278 .all(|value| value.is_finite())
279 {
280 return 0;
281 }
282 }
283
284 self.v = v;
285 self.m = m;
286 self.h = h;
287 self.n = n;
288 self.a = a;
289 self.b = b;
290 self.m_t = m_t;
291 self.s = s_gate;
292 self.ca = ca;
293 self.r = r;
294
295 if self.v >= 0.0 && v_prev < 0.0 {
297 1
298 } else {
299 0
300 }
301 }
302
303 pub fn reset(&mut self) {
304 *self = Self::new();
305 }
306}
307
308fn granule_exact_relax(value: f64, target: f64, tau: f64, dt: f64) -> f64 {
309 target + (value - target) * (-dt / tau).exp()
310}
311
312fn granule_exact_voltage_step(
313 v: f64,
314 input_current: f64,
315 c_m: f64,
316 dt: f64,
317 conductances: &[(f64, f64)],
318) -> f64 {
319 let g_total: f64 = conductances.iter().map(|(g, _)| *g).sum();
320 if g_total <= 0.0 {
321 return v + dt * input_current / c_m;
322 }
323 let reversal_drive: f64 = conductances.iter().map(|(g, e_rev)| g * e_rev).sum();
324 let v_inf = (input_current + reversal_drive) / g_total;
325 v_inf + (v - v_inf) * (-dt * g_total / c_m).exp()
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331
332 #[test]
335 fn granule_fires_with_strong_input() {
336 let mut n = GranuleCell::new();
337 let mut spikes = 0;
338 for _ in 0..10_000 {
339 spikes += n.step(15.0);
340 }
341 assert!(
342 spikes > 10,
343 "Granule cell must fire with strong excitatory input, got {spikes}"
344 );
345 }
346
347 #[test]
348 fn granule_silent_at_rest() {
349 let mut n = GranuleCell::new();
350 let mut spikes = 0;
351 for _ in 0..10_000 {
352 spikes += n.step(0.0);
353 }
354 assert_eq!(
355 spikes, 0,
356 "Granule cell must be silent without input (tonic GABA inhibition)"
357 );
358 }
359
360 #[test]
361 fn granule_no_fire_weak_input() {
362 let mut n = GranuleCell::new();
364 let mut spikes = 0;
365 for _ in 0..10_000 {
366 spikes += n.step(1.0);
367 }
368 assert!(
369 spikes == 0,
370 "Weak input should not overcome tonic GABA, got {spikes}"
371 );
372 }
373
374 #[test]
375 fn granule_tonic_gaba_raises_threshold() {
376 let mut with_gaba = GranuleCell::new();
378 let mut no_gaba = GranuleCell::new();
379 no_gaba.g_tonic = 0.0;
380
381 let input = 8.0;
382 let mut spikes_gaba = 0;
383 let mut spikes_no_gaba = 0;
384 for _ in 0..10_000 {
385 spikes_gaba += with_gaba.step(input);
386 spikes_no_gaba += no_gaba.step(input);
387 }
388 assert!(
389 spikes_no_gaba > spikes_gaba,
390 "Removing tonic GABA must increase firing: no_gaba={spikes_no_gaba} vs gaba={spikes_gaba}"
391 );
392 }
393
394 #[test]
395 fn granule_has_seven_currents() {
396 let n = GranuleCell::new();
398 assert!(n.g_na > 0.0, "Must have INa");
399 assert!(n.g_kdr > 0.0, "Must have IK_dr");
400 assert!(n.g_ka > 0.0, "Must have IK_A");
401 assert!(n.g_t > 0.0, "Must have ICa_T");
402 assert!(n.g_kca > 0.0, "Must have IK_Ca");
403 assert!(n.g_h > 0.0, "Must have Ih");
404 assert!(n.g_l > 0.0, "Must have IL");
405 }
406
407 #[test]
408 fn granule_t_type_deinactivates_at_rest() {
409 let mut n = GranuleCell::new();
411 for _ in 0..5000 {
412 n.step(0.0);
413 }
414 assert!(
415 n.s > 0.5,
416 "T-type must be partially de-inactivated at rest, s={}",
417 n.s
418 );
419 }
420
421 #[test]
422 fn granule_gate_and_calcium_kinetics_use_closed_form_relaxation() {
423 let mut n = GranuleCell::new();
424 n.g_na = 0.0;
425 n.g_kdr = 0.0;
426 n.g_ka = 0.0;
427 n.g_t = 0.0;
428 n.g_kca = 0.0;
429 n.g_h = 0.0;
430 n.g_l = 0.0;
431 n.g_tonic = 0.0;
432 n.gain = 0.0;
433 n.sub_steps = 1;
434 let (v0, m0, h0, n0, a0, b0, mt0, s0, ca0, r0) =
435 (n.v, n.m, n.h, n.n, n.a, n.b, n.m_t, n.s, n.ca, n.r);
436 let m_inf = GranuleCell::boltz(v0, -30.0, 7.0);
437 let tau_m = 0.1 + 0.3 / (1.0 + ((v0 + 30.0) / 10.0).powi(2)).max(0.01);
438 let h_inf = GranuleCell::boltz(v0, -52.0, -6.0);
439 let tau_h = 0.5 + 5.0 / (1.0 + ((v0 + 50.0) / 15.0).powi(2)).max(0.01);
440 let n_inf = GranuleCell::boltz(v0, -35.0, 8.0);
441 let tau_n = 1.0 + 5.0 / (1.0 + ((v0 + 35.0) / 15.0).powi(2)).max(0.01);
442 let a_inf = GranuleCell::boltz(v0, -50.0, 20.0);
443 let b_inf = GranuleCell::boltz(v0, -70.0, -6.0);
444 let mt_inf = GranuleCell::boltz(v0, -52.0, 5.0);
445 let s_inf = GranuleCell::boltz(v0, -60.0, -6.5);
446 let tau_s = 20.0 + 50.0 / (1.0 + ((v0 + 65.0) / 10.0).powi(2)).max(0.01);
447 let r_inf = GranuleCell::boltz(v0, -80.0, -10.0);
448 let tau_r = 50.0 + 200.0 / (1.0 + ((v0 + 80.0) / 20.0).powi(2)).max(0.01);
449
450 n.step(0.0);
451
452 assert_close_granule(n.v, v0);
453 assert_close_granule(n.m, granule_exact_relax(m0, m_inf, tau_m, n.dt));
454 assert_close_granule(n.h, granule_exact_relax(h0, h_inf, tau_h, n.dt));
455 assert_close_granule(n.n, granule_exact_relax(n0, n_inf, tau_n, n.dt));
456 assert_close_granule(n.a, granule_exact_relax(a0, a_inf, 2.0, n.dt));
457 assert_close_granule(n.b, granule_exact_relax(b0, b_inf, 50.0, n.dt));
458 assert_close_granule(n.m_t, granule_exact_relax(mt0, mt_inf, 1.0, n.dt));
459 assert_close_granule(n.s, granule_exact_relax(s0, s_inf, tau_s, n.dt));
460 assert_close_granule(n.ca, granule_exact_relax(ca0, 0.0, n.tau_ca, n.dt));
461 assert_close_granule(n.r, granule_exact_relax(r0, r_inf, tau_r, n.dt));
462 }
463
464 #[test]
465 fn granule_ca_rises_with_spiking() {
466 let mut n = GranuleCell::new();
468 let ca0 = n.ca;
469 for _ in 0..5000 {
470 n.step(8.0);
471 }
472 assert!(
473 n.ca > ca0,
474 "Ca²⁺ should rise in the T-current firing regime: ca0={ca0}, ca_now={}",
475 n.ca
476 );
477 }
478
479 #[test]
480 fn granule_negative_input_no_crash() {
481 let mut n = GranuleCell::new();
482 for _ in 0..10_000 {
483 n.step(-100.0);
484 }
485 assert!(n.v.is_finite(), "Must stay finite with negative input");
486 assert!(n.v >= -100.0, "Must be bounded");
487 }
488
489 #[test]
490 fn granule_nan_input_stays_finite() {
491 let mut n = GranuleCell::new();
492 let before = n.clone();
493 n.step(f64::NAN);
494 assert!(n.v.is_finite(), "NaN input must not corrupt state");
495 assert_eq!(n.v, before.v);
496 assert_eq!(n.ca, before.ca);
497 assert_eq!(n.s, before.s);
498 }
499
500 #[test]
501 fn granule_corrupted_state_preserved_on_step() {
502 let mut n = GranuleCell::new();
503 n.m = -0.1;
504 let before = n.clone();
505 assert_eq!(n.step(10.0), 0);
506 assert_eq!(n.v, before.v);
507 assert_eq!(n.m, before.m);
508 assert_eq!(n.ca, before.ca);
509 }
510
511 #[test]
512 fn granule_extreme_input_bounded() {
513 let mut n = GranuleCell::new();
514 for _ in 0..1000 {
515 n.step(1e6);
516 }
517 assert!(
518 n.v.is_finite() && n.v <= 60.0,
519 "Extreme input must stay bounded"
520 );
521 }
522
523 #[test]
524 fn granule_reset_clears_state() {
525 let mut n = GranuleCell::new();
526 for _ in 0..1000 {
527 n.step(20.0);
528 }
529 n.reset();
530 assert_eq!(n.v, -70.0);
531 assert_eq!(n.s, 0.95);
532 assert_eq!(n.m, 0.02);
533 }
534
535 #[test]
536 fn granule_high_input_resistance() {
537 let mut n = GranuleCell::new();
539 let v_before = n.v;
540 n.step(5.0);
542 let dv = n.v - v_before;
543 assert!(
544 dv > 0.5,
545 "High Rin should give large voltage change, got dv={dv}"
546 );
547 }
548
549 #[test]
550 fn granule_performance_10k_steps() {
551 let start = std::time::Instant::now();
552 let mut n = GranuleCell::new();
553 for _ in 0..10_000 {
554 std::hint::black_box(n.step(10.0));
555 }
556 let elapsed = start.elapsed();
557 assert!(
558 elapsed.as_millis() < 100,
559 "10k exact-integrator steps must complete in <100ms, took {}ms",
560 elapsed.as_millis()
561 );
562 }
563
564 fn assert_close_granule(observed: f64, expected: f64) {
565 assert!(
566 (observed - expected).abs() <= 1.0e-12,
567 "observed {:.17e}, expected {:.17e}",
568 observed,
569 expected,
570 );
571 }
572
573 #[test]
574 fn granule_default_matches_constructor_contract() {
575 let default = GranuleCell::default();
576 let constructed = GranuleCell::new();
577 assert_eq!(default.v, constructed.v);
578 assert_eq!(default.ca, constructed.ca);
579 assert_eq!(default.dt, constructed.dt);
580 assert_eq!(default.sub_steps, constructed.sub_steps);
581 }
582}