Skip to main content

sc_neurocore_engine/neurons/channels/
ih.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Commercial license available
3// © Concepts 1996–2026 Miroslav Šotek. All rights reserved.
4// © Code 2020–2026 Miroslav Šotek. All rights reserved.
5// ORCID: 0009-0009-3560-0851
6// Contact: www.anulum.li | protoscience@anulum.li
7// SC-NeuroCore — Hyperpolarisation-activated channel neuron
8
9use crate::neurons::biophysical::safe_rate;
10
11/// Ih (hyperpolarisation-activated cation current) neuron — WB base + HCN.
12///
13/// Ih activates upon hyperpolarisation (opposite to most voltage-gated
14/// channels) and conducts a mixed Na+/K+ current with reversal ~-40 mV.
15/// Key mechanism for:
16/// - Voltage sag: during hyperpolarisation, Ih activates and depolarises
17///   the cell back towards rest (sag potential)
18/// - Rebound excitation: Ih accumulated during inhibition depolarises
19///   the cell after inhibition ends, triggering rebound spikes
20/// - Pacemaker oscillations: interplay of Ih and T-type Ca2+ in thalamic
21///   relay neurons drives rhythmic bursting
22///
23/// Biological context: Robinson & Siegelbaum, Annu Rev Physiol 65:453, 2003;
24/// Pape, Annu Rev Physiol 58:299, 1996. The repository-specific WB+HCN
25/// recurrence is an experimental composite, not a publication-exact model.
26#[derive(Clone, Debug)]
27pub struct IhNeuron {
28    pub v: f64,
29    pub h: f64, // Na+ inactivation
30    pub n: f64, // Kdr activation
31    pub r: f64, // Ih activation (activates on hyperpolarisation)
32    // Conductances (mS/cm²)
33    pub g_na: f64,
34    pub g_k: f64,
35    pub g_h: f64, // Ih conductance
36    pub g_l: f64,
37    // Reversal potentials (mV)
38    pub e_na: f64,
39    pub e_k: f64,
40    pub e_h: f64, // Ih reversal (~-40 mV, mixed cation)
41    pub e_l: f64,
42    pub c_m: f64,
43    pub phi: f64,
44    pub dt: f64,
45    pub v_threshold: f64,
46    pub gain: f64,
47}
48
49impl Default for IhNeuron {
50    fn default() -> Self {
51        Self::new()
52    }
53}
54
55impl IhNeuron {
56    pub fn new() -> Self {
57        Self {
58            v: -65.0,
59            h: 0.6,
60            n: 0.32,
61            r: 0.1,
62            g_na: 35.0,
63            g_k: 9.0,
64            g_h: 0.15,
65            g_l: 0.2,
66            e_na: 55.0,
67            e_k: -90.0,
68            e_h: -40.0,
69            e_l: -65.0,
70            c_m: 1.0,
71            phi: 5.0,
72            dt: 0.5,
73            v_threshold: -20.0,
74            gain: 1.0,
75        }
76    }
77
78    fn valid(&self) -> bool {
79        let finite = [
80            self.v,
81            self.h,
82            self.n,
83            self.r,
84            self.g_na,
85            self.g_k,
86            self.g_h,
87            self.g_l,
88            self.e_na,
89            self.e_k,
90            self.e_h,
91            self.e_l,
92            self.c_m,
93            self.phi,
94            self.dt,
95            self.v_threshold,
96            self.gain,
97        ]
98        .into_iter()
99        .all(f64::is_finite);
100        finite
101            && (-100.0..=60.0).contains(&self.v)
102            && [self.h, self.n, self.r]
103                .into_iter()
104                .all(|gate| (0.0..=1.0).contains(&gate))
105            && (0.0..=200.0).contains(&self.g_na)
106            && (0.0..=100.0).contains(&self.g_k)
107            && (0.0..=5.0).contains(&self.g_h)
108            && (0.0..=5.0).contains(&self.g_l)
109            && (30.0..=70.0).contains(&self.e_na)
110            && (-100.0..=-70.0).contains(&self.e_k)
111            && (-50.0..=0.0).contains(&self.e_h)
112            && (-80.0..=-40.0).contains(&self.e_l)
113            && (0.5..=2.0).contains(&self.c_m)
114            && (0.5..=10.0).contains(&self.phi)
115            && self.dt > 0.0
116            && self.dt <= 1.0
117            && (-20.0..=20.0).contains(&self.v_threshold)
118            && (0.0..=10.0).contains(&self.gain)
119    }
120
121    pub fn try_step(&mut self, current: f64) -> Result<i32, &'static str> {
122        if !current.is_finite() {
123            return Err("current must be finite");
124        }
125        if !self.valid() {
126            return Err("Ih state and parameters must satisfy the public bounds");
127        }
128
129        let mut candidate = self.clone();
130        let input = candidate.gain * current;
131        let sub_steps = 50;
132        let sub_dt = candidate.dt / sub_steps as f64;
133        let mut fired = 0i32;
134
135        for _ in 0..sub_steps {
136            let v = candidate.v;
137
138            // WB alpha/beta rates
139            let alpha_m = safe_rate(0.1, 35.0, v, 10.0, 1.0);
140            let beta_m = 4.0 * (-(v + 60.0) / 18.0).exp();
141            let m_inf = alpha_m / (alpha_m + beta_m);
142
143            let alpha_h = 0.07 * (-(v + 58.0) / 20.0).exp();
144            let beta_h = 1.0 / (1.0 + (-(v + 28.0) / 10.0).exp());
145
146            let alpha_n = safe_rate(0.01, 34.0, v, 10.0, 0.1);
147            let beta_n = 0.125 * (-(v + 44.0) / 80.0).exp();
148
149            // Ih gating: activates on hyperpolarisation
150            // Half-activation ~-80 mV, slow kinetics (100-300 ms)
151            let r_inf = 1.0 / (1.0 + ((v + 80.0) / 10.0).exp());
152            let tau_r = 100.0 + 200.0 / (1.0 + ((v + 70.0) / 10.0).exp());
153
154            // Gate updates
155            candidate.h +=
156                sub_dt * candidate.phi * (alpha_h * (1.0 - candidate.h) - beta_h * candidate.h);
157            candidate.n +=
158                sub_dt * candidate.phi * (alpha_n * (1.0 - candidate.n) - beta_n * candidate.n);
159            candidate.r += sub_dt * (r_inf - candidate.r) / tau_r;
160
161            // Currents
162            let i_na = candidate.g_na * m_inf.powi(3) * candidate.h * (v - candidate.e_na);
163            let i_k = candidate.g_k * candidate.n.powi(4) * (v - candidate.e_k);
164            let i_h = candidate.g_h * candidate.r * (v - candidate.e_h);
165            let i_l = candidate.g_l * (v - candidate.e_l);
166
167            let dv = (-i_na - i_k - i_h - i_l + input) / candidate.c_m;
168            candidate.v += sub_dt * dv;
169            if ![candidate.v, candidate.h, candidate.n, candidate.r]
170                .into_iter()
171                .all(f64::is_finite)
172            {
173                return Err("Ih candidate state became non-finite");
174            }
175
176            if candidate.v >= candidate.v_threshold {
177                fired = 1;
178                candidate.v = -65.0;
179            }
180        }
181
182        candidate.v = candidate.v.clamp(-100.0, 60.0);
183        candidate.h = candidate.h.clamp(0.0, 1.0);
184        candidate.n = candidate.n.clamp(0.0, 1.0);
185        candidate.r = candidate.r.clamp(0.0, 1.0);
186        *self = candidate;
187
188        Ok(fired)
189    }
190
191    pub fn step(&mut self, current: f64) -> i32 {
192        self.try_step(current).unwrap_or(0)
193    }
194
195    pub fn reset(&mut self) {
196        self.v = -65.0;
197        self.h = 0.6;
198        self.n = 0.32;
199        self.r = 0.1;
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    // -- Ih Neuron tests --
208
209    #[test]
210    fn ih_fires_with_input() {
211        let mut n = IhNeuron::new();
212        let mut spikes = 0;
213        for _ in 0..2_000 {
214            spikes += n.step(2.0);
215        }
216        assert!(spikes > 5, "Ih neuron must fire with input, got {spikes}");
217    }
218
219    #[test]
220    fn ih_silent_without_input() {
221        let mut n = IhNeuron::new();
222        let mut spikes = 0;
223        for _ in 0..10_000 {
224            spikes += n.step(0.0);
225        }
226        assert_eq!(
227            spikes, 0,
228            "Ih neuron must be silent without input, got {spikes}"
229        );
230    }
231
232    #[test]
233    fn ih_sag_potential() {
234        // Hyperpolarising input should produce sag (voltage rebounds towards rest)
235        let mut with_ih = IhNeuron::new();
236        let mut no_ih = IhNeuron::new();
237        no_ih.g_h = 0.0;
238
239        // Apply hyperpolarising step
240        for _ in 0..4000 {
241            with_ih.step(-3.0);
242            no_ih.step(-3.0);
243        }
244        // With Ih, voltage should be less hyperpolarised (sag back)
245        assert!(
246            with_ih.v > no_ih.v,
247            "Ih sag must depolarise from hyperpolarisation: Ih={:.1} vs no_Ih={:.1}",
248            with_ih.v,
249            no_ih.v
250        );
251    }
252
253    #[test]
254    fn ih_r_gate_activates_on_hyperpolarisation() {
255        let mut n = IhNeuron::new();
256        let r_before = n.r;
257        // Hyperpolarise
258        for _ in 0..4000 {
259            n.step(-5.0);
260        }
261        assert!(
262            n.r > r_before,
263            "r gate must increase during hyperpolarisation, r={}",
264            n.r
265        );
266    }
267
268    #[test]
269    fn ih_rebound_excitation() {
270        // After hyperpolarisation, Ih should help reach threshold
271        let mut n = IhNeuron::new();
272        // Hyperpolarise to build up Ih
273        for _ in 0..4000 {
274            n.step(-3.0);
275        }
276        let r_after_hyp = n.r;
277        assert!(
278            r_after_hyp > 0.2,
279            "r must build up during hyperpolarisation, r={r_after_hyp}"
280        );
281
282        // Release — count spikes during rebound period
283        let mut rebound_spikes = 0;
284        for _ in 0..500 {
285            rebound_spikes += n.step(1.5);
286        }
287
288        // Compare with neuron that was not hyperpolarised
289        let mut n2 = IhNeuron::new();
290        let mut direct_spikes = 0;
291        for _ in 0..500 {
292            direct_spikes += n2.step(1.5);
293        }
294
295        assert!(
296            rebound_spikes >= direct_spikes,
297            "Rebound should facilitate firing: rebound={rebound_spikes} vs direct={direct_spikes}"
298        );
299    }
300
301    #[test]
302    fn ih_negative_input_no_crash() {
303        let mut n = IhNeuron::new();
304        for _ in 0..10_000 {
305            n.step(-100.0);
306        }
307        assert!(n.v.is_finite());
308        assert!(n.v >= -100.0);
309    }
310
311    #[test]
312    fn ih_nan_input_is_rejected_atomically() {
313        let mut n = IhNeuron::new();
314        let before = n.clone();
315        assert!(n.try_step(f64::NAN).is_err());
316        assert_eq!(n.v, before.v);
317        assert_eq!(n.h, before.h);
318        assert_eq!(n.n, before.n);
319        assert_eq!(n.r, before.r);
320    }
321
322    #[test]
323    fn ih_invalid_configuration_is_rejected_atomically() {
324        let mut n = IhNeuron::new();
325        n.c_m = 0.0;
326        let before = n.clone();
327        assert!(n.try_step(1.0).is_err());
328        assert_eq!(n.v, before.v);
329        assert_eq!(n.c_m, before.c_m);
330    }
331
332    #[test]
333    fn ih_extreme_input_bounded() {
334        let mut n = IhNeuron::new();
335        for _ in 0..1000 {
336            n.step(1e6);
337        }
338        assert!(n.v.is_finite() && n.v <= 60.0);
339    }
340
341    #[test]
342    fn ih_reset_clears_state() {
343        let mut n = IhNeuron::new();
344        n.g_h = 0.3;
345        for _ in 0..1000 {
346            n.step(10.0);
347        }
348        n.reset();
349        assert_eq!(n.v, -65.0);
350        assert_eq!(n.r, 0.1);
351        assert_eq!(n.g_h, 0.3);
352    }
353
354    #[test]
355    fn ih_gates_bounded() {
356        let mut n = IhNeuron::new();
357        for _ in 0..10_000 {
358            n.step(10.0);
359        }
360        assert!(n.h >= 0.0 && n.h <= 1.0);
361        assert!(n.n >= 0.0 && n.n <= 1.0);
362        assert!(n.r >= 0.0 && n.r <= 1.0);
363    }
364
365    #[test]
366    fn ih_performance_1k_steps() {
367        let start = std::time::Instant::now();
368        let mut n = IhNeuron::new();
369        for _ in 0..1_000 {
370            std::hint::black_box(n.step(2.0));
371        }
372        let elapsed = start.elapsed();
373        assert!(
374            elapsed.as_millis() < 200,
375            "1k steps must complete in <200ms"
376        );
377    }
378}