Skip to main content

sc_neurocore_engine/neurons/biophysical/
hill_tononi.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 — Hill and Tononi 2005 hybrid thalamocortical neuron
8
9//! Source cortical-excitatory waking recurrence with optional Ih and IT.
10
11#[derive(Clone, Debug)]
12/// Hill-Tononi cortical-waking state and complete scalar-cell configuration.
13pub struct HillTononiNeuron {
14    pub v: f64,
15    pub theta: f64,
16    pub d_k: f64,
17    pub m_h: f64,
18    pub m_t: f64,
19    pub h_t: f64,
20    pub spike_timer: f64,
21    pub g_na_l: f64,
22    pub g_k_l: f64,
23    pub g_na_p: f64,
24    pub g_dk: f64,
25    pub g_h: f64,
26    pub g_t: f64,
27    pub e_na: f64,
28    pub e_k: f64,
29    pub e_na_p: f64,
30    pub e_dk: f64,
31    pub e_h: f64,
32    pub e_t: f64,
33    pub n_na_p: f64,
34    pub n_t: f64,
35    pub tau_m: f64,
36    pub theta_eq: f64,
37    pub tau_theta: f64,
38    pub g_spike: f64,
39    pub t_spike: f64,
40    pub tau_spike: f64,
41    pub tau_d: f64,
42    pub d_influx_peak: f64,
43    pub d_threshold: f64,
44    pub d_slope: f64,
45    pub d_eq: f64,
46    pub d_half: f64,
47    pub dt: f64,
48}
49
50impl HillTononiNeuron {
51    /// Return the publication's cortical-excitatory waking profile.
52    pub fn new() -> Self {
53        Self {
54            v: -70.0,
55            theta: -51.0,
56            d_k: 0.001,
57            m_h: 0.2871859013825026,
58            m_t: 0.1450215950687922,
59            h_t: 0.03732688734412946,
60            spike_timer: 0.0,
61            g_na_l: 0.2,
62            g_k_l: 1.0,
63            g_na_p: 0.5,
64            g_dk: 0.5,
65            g_h: 0.0,
66            g_t: 0.0,
67            e_na: 30.0,
68            e_k: -90.0,
69            e_na_p: 30.0,
70            e_dk: -90.0,
71            e_h: -40.0,
72            e_t: 0.0,
73            n_na_p: 3.0,
74            n_t: 2.0,
75            tau_m: 16.0,
76            theta_eq: -51.0,
77            tau_theta: 2.0,
78            g_spike: 1.0,
79            t_spike: 2.0,
80            tau_spike: 1.75,
81            tau_d: 1250.0,
82            d_influx_peak: 0.025,
83            d_threshold: -10.0,
84            d_slope: 5.0,
85            d_eq: 0.001,
86            d_half: 0.25,
87            dt: 0.25,
88        }
89    }
90
91    fn m_h_inf(v: f64) -> f64 {
92        1.0 / (1.0 + ((v + 75.0) / 5.5).exp())
93    }
94
95    fn tau_m_h(v: f64) -> f64 {
96        1.0 / ((-14.59 - 0.086 * v).exp() + (-1.87 + 0.0701 * v).exp())
97    }
98
99    fn m_t_inf(v: f64) -> f64 {
100        1.0 / (1.0 + (-(v + 59.0) / 6.2).exp())
101    }
102
103    fn tau_m_t(v: f64) -> f64 {
104        0.22 / ((-(v + 132.0) / 16.7).exp() + ((v + 16.8) / 18.2).exp()) + 0.13
105    }
106
107    fn h_t_inf(v: f64) -> f64 {
108        1.0 / (1.0 + ((v + 83.0) / 4.0).exp())
109    }
110
111    fn tau_h_t(v: f64) -> f64 {
112        8.2 + (56.6 + 0.27 * ((v + 115.2) / 5.0).exp()) / (1.0 + ((v + 86.0) / 3.2).exp())
113    }
114
115    fn d_k_inf(&self, v: f64) -> f64 {
116        let influx = self.d_influx_peak / (1.0 + (-(v - self.d_threshold) / self.d_slope).exp());
117        self.tau_d * influx + self.d_eq
118    }
119
120    fn derivatives(&self, state: [f64; 6], current: f64, spike_active: bool) -> [f64; 6] {
121        let [v, theta, d_k, m_h, m_t, h_t] = state;
122        let m_na_p = 1.0 / (1.0 + (-(v + 55.7) / 7.7).exp());
123        let d_activation = 1.0 / (1.0 + (self.d_half / d_k.max(1e-15)).powf(3.5));
124        let i_na_l = -self.g_na_l * (v - self.e_na);
125        let i_k_l = -self.g_k_l * (v - self.e_k);
126        let i_na_p = -self.g_na_p * m_na_p.powf(self.n_na_p) * (v - self.e_na_p);
127        let i_dk = -self.g_dk * d_activation * (v - self.e_dk);
128        let i_h = -self.g_h * m_h * (v - self.e_h);
129        let i_t = -self.g_t * m_t.powf(self.n_t) * h_t * (v - self.e_t);
130        let i_spike = if spike_active {
131            -self.g_spike * (v - self.e_k) / self.tau_spike
132        } else {
133            0.0
134        };
135        [
136            (i_na_l + i_k_l + i_na_p + i_dk + i_h + i_t + current) / self.tau_m + i_spike,
137            -(theta - self.theta_eq) / self.tau_theta,
138            (self.d_k_inf(v) - d_k) / self.tau_d,
139            (Self::m_h_inf(v) - m_h) / Self::tau_m_h(v),
140            (Self::m_t_inf(v) - m_t) / Self::tau_m_t(v),
141            (Self::h_t_inf(v) - h_t) / Self::tau_h_t(v),
142        ]
143    }
144
145    fn shifted(state: [f64; 6], slope: [f64; 6], scale: f64) -> [f64; 6] {
146        std::array::from_fn(|index| state[index] + scale * slope[index])
147    }
148
149    fn candidate(&self, state: [f64; 6], current: f64, spike_active: bool) -> [f64; 6] {
150        let k1 = self.derivatives(state, current, spike_active);
151        let k2 = self.derivatives(
152            Self::shifted(state, k1, 0.5 * self.dt),
153            current,
154            spike_active,
155        );
156        let k3 = self.derivatives(
157            Self::shifted(state, k2, 0.5 * self.dt),
158            current,
159            spike_active,
160        );
161        let k4 = self.derivatives(Self::shifted(state, k3, self.dt), current, spike_active);
162        std::array::from_fn(|index| {
163            state[index]
164                + self.dt * (k1[index] + 2.0 * k2[index] + 2.0 * k3[index] + k4[index]) / 6.0
165        })
166    }
167
168    fn configuration_is_valid(&self) -> bool {
169        let values = [
170            self.v,
171            self.theta,
172            self.d_k,
173            self.m_h,
174            self.m_t,
175            self.h_t,
176            self.spike_timer,
177            self.g_na_l,
178            self.g_k_l,
179            self.g_na_p,
180            self.g_dk,
181            self.g_h,
182            self.g_t,
183            self.e_na,
184            self.e_k,
185            self.e_na_p,
186            self.e_dk,
187            self.e_h,
188            self.e_t,
189            self.n_na_p,
190            self.n_t,
191            self.tau_m,
192            self.theta_eq,
193            self.tau_theta,
194            self.g_spike,
195            self.t_spike,
196            self.tau_spike,
197            self.tau_d,
198            self.d_influx_peak,
199            self.d_threshold,
200            self.d_slope,
201            self.d_eq,
202            self.d_half,
203            self.dt,
204        ];
205        values.iter().all(|value| value.is_finite())
206            && self.d_k >= 0.0
207            && self.spike_timer >= 0.0
208            && [
209                self.g_na_l,
210                self.g_k_l,
211                self.g_na_p,
212                self.g_dk,
213                self.g_h,
214                self.g_t,
215                self.g_spike,
216                self.d_influx_peak,
217                self.d_eq,
218            ]
219            .iter()
220            .all(|value| *value >= 0.0)
221            && [
222                self.n_na_p,
223                self.n_t,
224                self.tau_m,
225                self.tau_theta,
226                self.t_spike,
227                self.tau_spike,
228                self.tau_d,
229                self.d_slope,
230                self.d_half,
231                self.dt,
232            ]
233            .iter()
234            .all(|value| *value > 0.0)
235    }
236
237    /// Advance one source RK4 step, committing state only after validation.
238    pub fn try_step(&mut self, current: f64) -> Result<i32, &'static str> {
239        if !current.is_finite() || !self.configuration_is_valid() {
240            return Err("Hill-Tononi configuration and current must be finite and physical");
241        }
242        let refractory = self.spike_timer > 0.0;
243        let state = [self.v, self.theta, self.d_k, self.m_h, self.m_t, self.h_t];
244        let mut next = self.candidate(state, current, refractory);
245        if !next.iter().all(|value| value.is_finite()) || next[2] < 0.0 {
246            return Err("Hill-Tononi candidate must be finite and physical");
247        }
248        let mut timer = (self.spike_timer - self.dt).max(0.0);
249        let spike = !refractory && next[0] >= next[1];
250        if spike {
251            next[0] = self.e_na;
252            next[1] = self.e_na;
253            timer = self.t_spike;
254        }
255        [self.v, self.theta, self.d_k, self.m_h, self.m_t, self.h_t] = next;
256        self.spike_timer = timer;
257        Ok(i32::from(spike))
258    }
259
260    /// Advance one step through the historical integer-event API.
261    pub fn step(&mut self, current: f64) -> i32 {
262        self.try_step(current).unwrap_or(0)
263    }
264
265    /// Restore the source cortical-excitatory waking initial state.
266    pub fn reset(&mut self) {
267        self.v = -70.0;
268        self.theta = -51.0;
269        self.d_k = 0.001;
270        self.m_h = Self::m_h_inf(self.v);
271        self.m_t = Self::m_t_inf(self.v);
272        self.h_t = Self::h_t_inf(self.v);
273        self.spike_timer = 0.0;
274    }
275}
276
277impl Default for HillTononiNeuron {
278    fn default() -> Self {
279        Self::new()
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn source_defaults_and_python_anchor() {
289        let mut neuron = HillTononiNeuron::new();
290        assert_eq!(
291            [neuron.v, neuron.theta, neuron.d_k, neuron.dt],
292            [-70.0, -51.0, 0.001, 0.25]
293        );
294        assert_eq!(neuron.step(12.0), 0);
295        let expected = [
296            -69.81228106951788,
297            -51.0,
298            0.0010000391293823398,
299            0.2871847356365222,
300            0.1451785200593081,
301            0.037318086618308974,
302        ];
303        let observed = [
304            neuron.v,
305            neuron.theta,
306            neuron.d_k,
307            neuron.m_h,
308            neuron.m_t,
309            neuron.h_t,
310        ];
311        for (actual, target) in observed.into_iter().zip(expected) {
312            assert!((actual - target).abs() < 2e-12, "{actual} != {target}");
313        }
314    }
315
316    #[test]
317    fn spike_sets_dynamic_threshold_and_pulse() {
318        let mut neuron = HillTononiNeuron::new();
319        neuron.v = -50.0;
320        neuron.theta = -51.0;
321        assert_eq!(neuron.try_step(0.0), Ok(1));
322        assert_eq!(
323            [neuron.v, neuron.theta, neuron.spike_timer],
324            [30.0, 30.0, 2.0]
325        );
326        assert_eq!(neuron.try_step(0.0), Ok(0));
327        assert_eq!(neuron.spike_timer, 1.75);
328        assert!(neuron.v < 30.0);
329    }
330
331    #[test]
332    fn invalid_step_is_atomic() {
333        let mut neuron = HillTononiNeuron::new();
334        let before = [
335            neuron.v,
336            neuron.theta,
337            neuron.d_k,
338            neuron.m_h,
339            neuron.m_t,
340            neuron.h_t,
341        ];
342        assert!(neuron.try_step(f64::NAN).is_err());
343        assert_eq!(
344            [
345                neuron.v,
346                neuron.theta,
347                neuron.d_k,
348                neuron.m_h,
349                neuron.m_t,
350                neuron.h_t
351            ],
352            before
353        );
354    }
355}