Skip to main content

sc_neurocore_engine/analysis/
temporal.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 — Temporal pattern detection: bursts, latency, onset, change points
8
9use super::basic::{bin_spike_train, spike_times};
10
11/// Detect bursts: consecutive spikes with ISI < max_isi_ms.
12/// Returns Vec of (start_time_s, end_time_s, spike_count).
13pub fn burst_detection(
14    binary_train: &[i32],
15    dt: f64,
16    max_isi_ms: f64,
17    min_spikes: usize,
18) -> Vec<(f64, f64, usize)> {
19    let times = spike_times(binary_train, dt);
20    if times.len() < min_spikes {
21        return vec![];
22    }
23    let max_isi = max_isi_ms / 1000.0;
24    let intervals: Vec<f64> = times.windows(2).map(|w| w[1] - w[0]).collect();
25    let mut bursts = Vec::new();
26    let mut i = 0;
27    while i < intervals.len() {
28        if intervals[i] < max_isi {
29            let start = i;
30            while i < intervals.len() && intervals[i] < max_isi {
31                i += 1;
32            }
33            let n_spikes = i - start + 1;
34            if n_spikes >= min_spikes {
35                bursts.push((times[start], times[i], n_spikes));
36            }
37        } else {
38            i += 1;
39        }
40    }
41    bursts
42}
43
44/// Time to first spike (seconds). Returns NaN if no spike.
45pub fn first_spike_latency(binary_train: &[i32], dt: f64) -> f64 {
46    for (i, &v) in binary_train.iter().enumerate() {
47        if v > 0 {
48            return i as f64 * dt;
49        }
50    }
51    f64::NAN
52}
53
54/// Detect response onset as first bin exceeding baseline + threshold_sigma * std.
55/// Returns onset time (seconds), or NaN.
56pub fn response_onset(
57    binary_train: &[i32],
58    baseline_steps: usize,
59    dt: f64,
60    threshold_sigma: f64,
61) -> f64 {
62    if binary_train.len() <= baseline_steps {
63        return f64::NAN;
64    }
65    let baseline = &binary_train[..baseline_steps];
66    let mean: f64 = baseline.iter().map(|&v| v as f64).sum::<f64>() / baseline_steps as f64;
67    let std: f64 = (baseline
68        .iter()
69        .map(|&v| (v as f64 - mean).powi(2))
70        .sum::<f64>()
71        / baseline_steps as f64)
72        .sqrt()
73        .max(1e-10);
74    let threshold = mean + threshold_sigma * std;
75    for (i, &v) in binary_train[baseline_steps..].iter().enumerate() {
76        if v as f64 > threshold {
77            return (baseline_steps + i) as f64 * dt;
78        }
79    }
80    f64::NAN
81}
82
83/// CUSUM-based change point detection (Page 1954).
84/// Returns list of bin indices where significant rate changes occur.
85pub fn change_point_detection(binary_train: &[i32], bin_size: usize, threshold: f64) -> Vec<usize> {
86    let counts: Vec<f64> = bin_spike_train(binary_train, bin_size)
87        .iter()
88        .map(|&v| v as f64)
89        .collect();
90    let n = counts.len();
91    if n < 5 {
92        return vec![];
93    }
94    let mean_rate: f64 = counts.iter().sum::<f64>() / n as f64;
95    let std_rate: f64 =
96        (counts.iter().map(|v| (v - mean_rate).powi(2)).sum::<f64>() / n as f64).sqrt();
97    if std_rate < 1e-10 {
98        return vec![];
99    }
100    let mut cusum_pos = 0.0_f64;
101    let mut cusum_neg = 0.0_f64;
102    let mut change_points = Vec::new();
103    for i in 1..n {
104        let z = (counts[i] - mean_rate) / std_rate;
105        cusum_pos = (cusum_pos + z).max(0.0);
106        cusum_neg = (cusum_neg - z).max(0.0);
107        if cusum_pos > threshold || cusum_neg > threshold {
108            change_points.push(i);
109            cusum_pos = 0.0;
110            cusum_neg = 0.0;
111        }
112    }
113    change_points
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    fn make_train(spikes: &[usize], len: usize) -> Vec<i32> {
121        let mut t = vec![0i32; len];
122        for &s in spikes {
123            t[s] = 1;
124        }
125        t
126    }
127
128    #[test]
129    fn test_burst_detection_regular() {
130        // Spikes every 5ms — ISI = 5ms, max_isi = 10ms → single burst
131        let train = make_train(&[10, 15, 20, 25, 30], 100);
132        let bursts = burst_detection(&train, 0.001, 10.0, 3);
133        assert_eq!(bursts.len(), 1);
134        assert_eq!(bursts[0].2, 5, "5 spikes in burst");
135    }
136
137    #[test]
138    fn test_burst_detection_no_burst() {
139        // Spikes every 50ms — ISI = 50ms > 10ms
140        let train = make_train(&[10, 60, 110, 160], 200);
141        let bursts = burst_detection(&train, 0.001, 10.0, 3);
142        assert!(bursts.is_empty());
143    }
144
145    #[test]
146    fn test_burst_detection_too_few_spikes() {
147        let train = make_train(&[10], 100);
148        assert!(burst_detection(&train, 0.001, 10.0, 3).is_empty());
149    }
150
151    #[test]
152    fn test_first_spike_latency() {
153        let train = make_train(&[50], 100);
154        assert!((first_spike_latency(&train, 0.001) - 0.050).abs() < 1e-10);
155    }
156
157    #[test]
158    fn test_first_spike_latency_no_spike() {
159        let train = vec![0i32; 100];
160        assert!(first_spike_latency(&train, 0.001).is_nan());
161    }
162
163    #[test]
164    fn test_response_onset_detected() {
165        // Baseline: 100 zeros. Response: spike at step 100
166        let mut train = vec![0i32; 200];
167        train[100] = 1;
168        let onset = response_onset(&train, 100, 0.001, 1.0);
169        assert!(!onset.is_nan(), "should detect onset");
170        assert!((onset - 0.100).abs() < 1e-10);
171    }
172
173    #[test]
174    fn test_response_onset_no_response() {
175        let train = vec![0i32; 200];
176        assert!(response_onset(&train, 100, 0.001, 3.0).is_nan());
177    }
178
179    #[test]
180    fn test_response_onset_too_short() {
181        let train = vec![0i32; 50];
182        assert!(response_onset(&train, 100, 0.001, 3.0).is_nan());
183    }
184
185    #[test]
186    fn test_change_point_step() {
187        // Low rate then high rate
188        let mut train = vec![0i32; 500];
189        for i in 250..500 {
190            if i % 2 == 0 {
191                train[i] = 1;
192            }
193        }
194        let cps = change_point_detection(&train, 50, 3.0);
195        assert!(!cps.is_empty(), "should detect rate change");
196    }
197
198    #[test]
199    fn test_change_point_constant() {
200        let train = vec![0i32; 500];
201        assert!(change_point_detection(&train, 50, 3.0).is_empty());
202    }
203
204    #[test]
205    fn test_change_point_too_short() {
206        let train = vec![1i32; 10];
207        assert!(change_point_detection(&train, 50, 3.0).is_empty());
208    }
209}