use crate::error::GeomError;
use crate::linalg::Matrix;
use crate::monte_carlo::Rng;
use crate::numerical::ode::rk4_step_vec;
const SPIKE_LEVEL: f64 = 0.0;
fn upward_crossings(trace: &[(f64, f64)], level: f64) -> Vec<f64> {
let mut out = Vec::new();
for pair in trace.windows(2) {
let (t0, v0) = pair[0];
let (t1, v1) = pair[1];
if v0 < level && v1 >= level {
let fraction = (level - v0) / (v1 - v0);
out.push(t0 + fraction * (t1 - t0));
}
}
out
}
#[must_use]
pub fn spike_times(trace: &[(f64, f64)], level: f64) -> Vec<f64> {
upward_crossings(trace, level)
}
fn check_run(t_end: f64, dt: f64, largest: f64) -> Result<usize, GeomError> {
if !(t_end > 0.0) || !(dt > 0.0) || dt > largest || dt >= t_end {
return Err(GeomError::InvalidArgument("the run length or step size is out of range"));
}
let steps = (t_end / dt).ceil();
if steps > 2e7 {
return Err(GeomError::InvalidArgument("that many steps would not finish"));
}
Ok(steps as usize)
}
pub const HH_C_M: f64 = 1.0;
pub const HH_G_NA: f64 = 120.0;
pub const HH_G_K: f64 = 36.0;
pub const HH_G_L: f64 = 0.3;
pub const HH_E_NA: f64 = 50.0;
pub const HH_E_K: f64 = -77.0;
pub const HH_E_L: f64 = -54.387;
pub const HH_V_REST: f64 = -65.0;
fn exprel(x: f64, y: f64) -> f64 {
if (x / y).abs() < 1e-6 {
y - 0.5 * x
} else {
x / ((x / y).exp() - 1.0)
}
}
fn hh_rates(v: f64) -> [f64; 6] {
let alpha_m = 0.1 * exprel(-(v + 40.0), 10.0);
let beta_m = 4.0 * (-(v + 65.0) / 18.0).exp();
let alpha_h = 0.07 * (-(v + 65.0) / 20.0).exp();
let beta_h = 1.0 / (1.0 + (-(v + 35.0) / 10.0).exp());
let alpha_n = 0.01 * exprel(-(v + 55.0), 10.0);
let beta_n = 0.125 * (-(v + 65.0) / 80.0).exp();
[alpha_m, beta_m, alpha_h, beta_h, alpha_n, beta_n]
}
#[must_use]
pub fn hh_steady_state(v: f64) -> (f64, f64, f64) {
let [am, bm, ah, bh, an, bn] = hh_rates(v);
(am / (am + bm), ah / (ah + bh), an / (an + bn))
}
pub fn hodgkin_huxley(
i_ext: &dyn Fn(f64) -> f64,
t_end: f64,
dt: f64,
) -> Result<Vec<(f64, f64, f64, f64, f64)>, GeomError> {
let steps = check_run(t_end, dt, 0.05)?;
let (m0, h0, n0) = hh_steady_state(HH_V_REST);
let derivative = |t: f64, y: &[f64]| -> Vec<f64> {
let (v, m, h, n) = (y[0], y[1], y[2], y[3]);
let [am, bm, ah, bh, an, bn] = hh_rates(v);
let i_na = HH_G_NA * m * m * m * h * (v - HH_E_NA);
let i_k = HH_G_K * n * n * n * n * (v - HH_E_K);
let i_l = HH_G_L * (v - HH_E_L);
vec![
(i_ext(t) - i_na - i_k - i_l) / HH_C_M,
am * (1.0 - m) - bm * m,
ah * (1.0 - h) - bh * h,
an * (1.0 - n) - bn * n,
]
};
let mut state = vec![HH_V_REST, m0, h0, n0];
let mut out = Vec::with_capacity(steps + 1);
out.push((0.0, state[0], state[1], state[2], state[3]));
for step in 0..steps {
let t = step as f64 * dt;
state = rk4_step_vec(&derivative, t, &state, dt);
if !state.iter().all(|x| x.is_finite()) {
return Err(GeomError::Degenerate("the Hodgkin-Huxley integration diverged"));
}
out.push((t + dt, state[0], state[1], state[2], state[3]));
}
Ok(out)
}
#[must_use]
pub fn hh_spike_times(trace: &[(f64, f64, f64, f64, f64)]) -> Vec<f64> {
let voltage: Vec<(f64, f64)> = trace.iter().map(|row| (row.0, row.1)).collect();
upward_crossings(&voltage, SPIKE_LEVEL)
}
#[must_use]
pub fn hh_spike_threshold_estimate() -> f64 {
let fires = |current: f64| -> bool {
let trace = hodgkin_huxley(&|_| current, 120.0, 0.01).expect("fixed valid parameters");
!hh_spike_times(&trace).is_empty()
};
let (mut low, mut high) = (0.0, 20.0);
for _ in 0..24 {
let mid = 0.5 * (low + high);
if fires(mid) {
high = mid;
} else {
low = mid;
}
}
0.5 * (low + high)
}
pub fn hh_fi_curve(currents: &[f64]) -> Result<Vec<(f64, f64)>, GeomError> {
if currents.is_empty() || currents.iter().any(|c| !c.is_finite()) {
return Err(GeomError::InvalidArgument("hh_fi_curve: bad currents"));
}
let settle = 30.0;
let t_end = 230.0;
currents
.iter()
.map(|current| {
let trace = hodgkin_huxley(&|_| *current, t_end, 0.01)?;
let counted = hh_spike_times(&trace).into_iter().filter(|t| *t >= settle).count();
Ok((*current, 1000.0 * counted as f64 / (t_end - settle)))
})
.collect()
}
pub fn fitzhugh_nagumo_neuron(
a: f64,
b: f64,
tau: f64,
current: f64,
v0: f64,
w0: f64,
t_end: f64,
dt: f64,
) -> Result<Vec<(f64, f64, f64)>, GeomError> {
if !(tau > 0.0) || ![a, b, current, v0, w0].iter().all(|x| x.is_finite()) {
return Err(GeomError::InvalidArgument("fitzhugh_nagumo_neuron: bad parameters"));
}
let steps = check_run(t_end, dt, 0.5)?;
let derivative = |_: f64, y: &[f64]| -> Vec<f64> {
vec![y[0] - y[0].powi(3) / 3.0 - y[1] + current, (y[0] + a - b * y[1]) / tau]
};
let mut state = vec![v0, w0];
let mut out = vec![(0.0, v0, w0)];
for step in 0..steps {
let t = step as f64 * dt;
state = rk4_step_vec(&derivative, t, &state, dt);
if !state.iter().all(|x| x.is_finite()) {
return Err(GeomError::Degenerate("the FitzHugh-Nagumo integration diverged"));
}
out.push((t + dt, state[0], state[1]));
}
Ok(out)
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MorrisLecar {
pub c_m: f64,
pub g_l: f64,
pub g_ca: f64,
pub g_k: f64,
pub v_l: f64,
pub v_ca: f64,
pub v_k: f64,
pub v1: f64,
pub v2: f64,
pub v3: f64,
pub v4: f64,
pub phi: f64,
}
impl MorrisLecar {
#[must_use]
pub fn hopf() -> Self {
Self {
c_m: 20.0,
g_l: 2.0,
g_ca: 4.4,
g_k: 8.0,
v_l: -60.0,
v_ca: 120.0,
v_k: -84.0,
v1: -1.2,
v2: 18.0,
v3: 2.0,
v4: 30.0,
phi: 0.04,
}
}
#[must_use]
pub fn saddle_node() -> Self {
Self { g_ca: 4.0, v3: 12.0, v4: 17.4, phi: 0.0667, ..Self::hopf() }
}
}
pub fn morris_lecar(
params: &MorrisLecar,
current: f64,
v0: f64,
w0: f64,
t_end: f64,
dt: f64,
) -> Result<Vec<(f64, f64, f64)>, GeomError> {
let p = *params;
if !(p.c_m > 0.0) || !(p.v2 > 0.0) || !(p.v4 > 0.0) || !(p.phi > 0.0) {
return Err(GeomError::InvalidArgument("morris_lecar: bad parameters"));
}
let steps = check_run(t_end, dt, 1.0)?;
let derivative = |_: f64, y: &[f64]| -> Vec<f64> {
let (v, w) = (y[0], y[1]);
let m_inf = 0.5 * (1.0 + ((v - p.v1) / p.v2).tanh());
let w_inf = 0.5 * (1.0 + ((v - p.v3) / p.v4).tanh());
let tau_w = 1.0 / ((v - p.v3) / (2.0 * p.v4)).cosh();
let ionic = p.g_l * (v - p.v_l)
+ p.g_ca * m_inf * (v - p.v_ca)
+ p.g_k * w * (v - p.v_k);
vec![(current - ionic) / p.c_m, p.phi * (w_inf - w) * tau_w]
};
let mut state = vec![v0, w0];
let mut out = vec![(0.0, v0, w0)];
for step in 0..steps {
let t = step as f64 * dt;
state = rk4_step_vec(&derivative, t, &state, dt);
if !state.iter().all(|x| x.is_finite()) {
return Err(GeomError::Degenerate("the Morris-Lecar integration diverged"));
}
out.push((t + dt, state[0], state[1]));
}
Ok(out)
}
pub fn izhikevich(
a: f64,
b: f64,
c: f64,
d: f64,
current: f64,
t_end: f64,
dt: f64,
) -> Result<Vec<(f64, f64)>, GeomError> {
if !(a > 0.0) || ![b, c, d, current].iter().all(|x| x.is_finite()) {
return Err(GeomError::InvalidArgument("izhikevich: bad parameters"));
}
let steps = check_run(t_end, dt, 1.0)?;
let mut v = c;
let mut u = b * v;
let mut out = vec![(0.0, v)];
for step in 0..steps {
let t = (step + 1) as f64 * dt;
let mut peaked = false;
for _ in 0..2 {
v += 0.5 * dt * (0.04 * v * v + 5.0 * v + 140.0 - u + current);
if v >= 30.0 {
peaked = true;
break;
}
}
u += dt * a * (b * v - u);
if peaked {
out.push((t, 30.0));
v = c;
u += d;
} else {
out.push((t, v));
}
if !v.is_finite() || !u.is_finite() {
return Err(GeomError::Degenerate("the Izhikevich integration diverged"));
}
}
Ok(out)
}
#[must_use]
pub fn izhikevich_presets() -> Vec<(&'static str, [f64; 4])> {
vec![
("RS", [0.02, 0.2, -65.0, 8.0]),
("IB", [0.02, 0.2, -55.0, 4.0]),
("CH", [0.02, 0.2, -50.0, 2.0]),
("FS", [0.1, 0.2, -65.0, 2.0]),
("LTS", [0.02, 0.25, -65.0, 2.0]),
]
}
pub fn adex(
c_m: f64,
g_l: f64,
e_l: f64,
slope: f64,
v_t: f64,
tau_w: f64,
a: f64,
b: f64,
v_reset: f64,
current: f64,
t_end: f64,
dt: f64,
) -> Result<Vec<(f64, f64, f64)>, GeomError> {
if !(c_m > 0.0) || !(g_l > 0.0) || !(slope > 0.0) || !(tau_w > 0.0) {
return Err(GeomError::InvalidArgument("adex: bad parameters"));
}
let steps = check_run(t_end, dt, 0.5)?;
let peak = v_t + 10.0 * slope;
let mut v = e_l;
let mut w = 0.0;
let mut out = vec![(0.0, v, w)];
for step in 0..steps {
let t = (step + 1) as f64 * dt;
let exponential = (((v - v_t) / slope).min(50.0)).exp();
let dv = (-g_l * (v - e_l) + g_l * slope * exponential - w + current) / c_m;
let dw = (a * (v - e_l) - w) / tau_w;
v += dt * dv;
w += dt * dw;
if v >= peak {
out.push((t, peak, w + b));
v = v_reset;
w += b;
} else {
out.push((t, v, w));
}
if !v.is_finite() || !w.is_finite() {
return Err(GeomError::Degenerate("the AdEx integration diverged"));
}
}
Ok(out)
}
pub fn lif_neuron(
current: f64,
tau: f64,
v_th: f64,
v_reset: f64,
refractory: f64,
noise: f64,
t_end: f64,
dt: f64,
rng: &mut Rng,
) -> Result<Vec<f64>, GeomError> {
if !(tau > 0.0) || refractory < 0.0 || noise < 0.0 || v_th <= v_reset {
return Err(GeomError::InvalidArgument("lif_neuron: bad parameters"));
}
let steps = check_run(t_end, dt, tau)?;
let mut v = v_reset;
let mut blocked_until = f64::NEG_INFINITY;
let mut spikes = Vec::new();
for step in 0..steps {
let t = (step + 1) as f64 * dt;
if t < blocked_until {
v = v_reset;
continue;
}
let kick = noise * dt.sqrt() * rng.next_gaussian();
v += dt * (-v + current) / tau + kick / tau;
if v >= v_th {
spikes.push(t);
v = v_reset;
blocked_until = t + refractory;
}
}
Ok(spikes)
}
pub fn lif_fi_exact(
current: f64,
tau: f64,
v_th: f64,
v_reset: f64,
refractory: f64,
) -> Result<f64, GeomError> {
if !(tau > 0.0) || refractory < 0.0 || v_th <= v_reset {
return Err(GeomError::InvalidArgument("lif_fi_exact: bad parameters"));
}
if current <= v_th {
return Ok(0.0);
}
let interval = refractory + tau * ((current - v_reset) / (current - v_th)).ln();
Ok(1.0 / interval)
}
pub fn interspike_intervals(spikes: &[f64]) -> Result<Vec<f64>, GeomError> {
if spikes.windows(2).any(|p| p[1] < p[0]) {
return Err(GeomError::InvalidArgument("the spike times are not in order"));
}
Ok(spikes.windows(2).map(|p| p[1] - p[0]).collect())
}
pub fn cv_isi(spikes: &[f64]) -> Result<f64, GeomError> {
let intervals = interspike_intervals(spikes)?;
if intervals.len() < 2 {
return Err(GeomError::InvalidArgument("the coefficient needs at least three spikes"));
}
let n = intervals.len() as f64;
let mean = intervals.iter().sum::<f64>() / n;
if !(mean > 0.0) {
return Err(GeomError::Degenerate("every spike arrived at the same instant"));
}
let variance = intervals.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (n - 1.0);
Ok(variance.sqrt() / mean)
}
pub fn fano_factor(counts: &[u64]) -> Result<f64, GeomError> {
if counts.len() < 2 {
return Err(GeomError::InvalidArgument("the Fano factor needs at least two windows"));
}
let n = counts.len() as f64;
let mean = counts.iter().map(|c| *c as f64).sum::<f64>() / n;
if !(mean > 0.0) {
return Err(GeomError::Degenerate("no spikes were counted"));
}
let variance = counts.iter().map(|c| (*c as f64 - mean).powi(2)).sum::<f64>() / (n - 1.0);
Ok(variance / mean)
}
pub fn poisson_spike_train(rate: f64, t_end: f64, rng: &mut Rng) -> Result<Vec<f64>, GeomError> {
if !(rate > 0.0) || !(t_end > 0.0) {
return Err(GeomError::InvalidArgument("poisson_spike_train: bad parameters"));
}
if rate * t_end > 1e7 {
return Err(GeomError::InvalidArgument("that many spikes would not fit in memory"));
}
let mut out = Vec::new();
let mut t = 0.0;
loop {
t += -(1.0 - rng.next_f64()).ln() / rate;
if t >= t_end {
return Ok(out);
}
out.push(t);
}
}
pub fn psth(trains: &[Vec<f64>], bin: f64, t_end: f64) -> Result<Vec<f64>, GeomError> {
if trains.is_empty() || !(bin > 0.0) || !(t_end > 0.0) || bin > t_end {
return Err(GeomError::InvalidArgument("psth: bad parameters"));
}
let bins = (t_end / bin).ceil() as usize;
let mut counts = vec![0.0f64; bins];
for train in trains {
for spike in train {
if !(0.0..t_end).contains(spike) {
return Err(GeomError::InvalidArgument("a spike falls outside the window"));
}
counts[((spike / bin) as usize).min(bins - 1)] += 1.0;
}
}
let scale = bin * trains.len() as f64;
Ok(counts.into_iter().map(|c| c / scale).collect())
}
#[must_use]
pub fn raster_data(trains: &[Vec<f64>]) -> Vec<(f64, usize)> {
let mut out: Vec<(f64, usize)> = trains
.iter()
.enumerate()
.flat_map(|(trial, train)| train.iter().map(move |t| (*t, trial)))
.collect();
out.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
out
}
pub fn spike_triggered_average(
stimulus: &[f64],
dt: f64,
spikes: &[f64],
window: usize,
) -> Result<Vec<f64>, GeomError> {
if stimulus.is_empty() || !(dt > 0.0) || window == 0 || window > stimulus.len() {
return Err(GeomError::InvalidArgument("spike_triggered_average: bad parameters"));
}
let mut sum = vec![0.0f64; window];
let mut used = 0usize;
for spike in spikes {
if !spike.is_finite() || *spike < 0.0 {
return Err(GeomError::InvalidArgument("a spike time is negative or not finite"));
}
let index = (spike / dt) as usize;
if index + 1 < window || index >= stimulus.len() {
continue;
}
used += 1;
for (slot, offset) in sum.iter_mut().zip((0..window).rev()) {
*slot += stimulus[index - offset];
}
}
if used == 0 {
return Err(GeomError::Degenerate("no spike had a full window of stimulus before it"));
}
Ok(sum.into_iter().map(|s| s / used as f64).collect())
}
pub fn tuning_curve_fit_von_mises(
angles: &[f64],
rates: &[f64],
) -> Result<(f64, f64, f64), GeomError> {
if angles.len() < 3 || angles.len() != rates.len() {
return Err(GeomError::InvalidArgument("the fit needs at least three matched points"));
}
if rates.iter().any(|r| !(*r > 0.0)) || angles.iter().any(|a| !a.is_finite()) {
return Err(GeomError::InvalidArgument("a rate is not positive or an angle is not finite"));
}
let n = angles.len() as f64;
let (mut sc, mut ss, mut scc, mut sss, mut scs) = (0.0, 0.0, 0.0, 0.0, 0.0);
let (mut sy, mut syc, mut sys) = (0.0, 0.0, 0.0);
for (angle, rate) in angles.iter().zip(rates.iter()) {
let (s, c) = angle.sin_cos();
let y = rate.ln();
sc += c;
ss += s;
scc += c * c;
sss += s * s;
scs += c * s;
sy += y;
syc += y * c;
sys += y * s;
}
let matrix = [[n, sc, ss], [sc, scc, scs], [ss, scs, sss]];
let rhs = [sy, syc, sys];
let solved = solve3(&matrix, &rhs)
.ok_or(GeomError::Degenerate("the angles do not determine a tuning curve"))?;
let (log_amplitude, x, y) = (solved[0], solved[1], solved[2]);
let kappa = x.hypot(y);
let preferred = y.atan2(x);
Ok((preferred, kappa, log_amplitude.exp()))
}
fn solve3(matrix: &[[f64; 3]; 3], rhs: &[f64; 3]) -> Option<[f64; 3]> {
let mut a = [
[matrix[0][0], matrix[0][1], matrix[0][2], rhs[0]],
[matrix[1][0], matrix[1][1], matrix[1][2], rhs[1]],
[matrix[2][0], matrix[2][1], matrix[2][2], rhs[2]],
];
let scale = a.iter().flatten().fold(0.0f64, |m, v| m.max(v.abs())).max(1.0);
for column in 0..3 {
let pivot = (column..3).max_by(|i, j| {
a[*i][column].abs().partial_cmp(&a[*j][column].abs()).unwrap_or(std::cmp::Ordering::Equal)
})?;
a.swap(column, pivot);
if a[column][column].abs() < 1e-12 * scale {
return None;
}
for row in 0..3 {
if row == column {
continue;
}
let factor = a[row][column] / a[column][column];
for k in column..4 {
a[row][k] -= factor * a[column][k];
}
}
}
Some([a[0][3] / a[0][0], a[1][3] / a[1][1], a[2][3] / a[2][2]])
}
pub fn synapse_exp(g_max: f64, tau: f64, spikes: &[f64], t: f64) -> Result<f64, GeomError> {
if !(tau > 0.0) || spikes.windows(2).any(|p| p[1] < p[0]) {
return Err(GeomError::InvalidArgument("synapse_exp: bad time constant or train"));
}
Ok(spikes
.iter()
.filter(|s| **s <= t)
.map(|s| g_max * (-(t - s) / tau).exp())
.sum())
}
pub fn alpha_synapse(g_max: f64, tau: f64, spikes: &[f64], t: f64) -> Result<f64, GeomError> {
if !(tau > 0.0) || spikes.windows(2).any(|p| p[1] < p[0]) {
return Err(GeomError::InvalidArgument("alpha_synapse: bad time constant or train"));
}
Ok(spikes
.iter()
.filter(|s| **s <= t)
.map(|s| {
let x = (t - s) / tau;
g_max * x * (1.0 - x).exp()
})
.sum())
}
pub fn stdp_window(
delta: f64,
a_plus: f64,
a_minus: f64,
tau_plus: f64,
tau_minus: f64,
) -> Result<f64, GeomError> {
if !(tau_plus > 0.0) || !(tau_minus > 0.0) || a_plus < 0.0 || a_minus < 0.0 {
return Err(GeomError::InvalidArgument("stdp_window: bad parameters"));
}
if delta > 0.0 {
Ok(a_plus * (-delta / tau_plus).exp())
} else if delta < 0.0 {
Ok(-a_minus * (delta / tau_minus).exp())
} else {
Ok(0.0)
}
}
pub fn stdp_train(
pre: &[f64],
post: &[f64],
a_plus: f64,
a_minus: f64,
tau_plus: f64,
tau_minus: f64,
) -> Result<f64, GeomError> {
if pre.windows(2).any(|p| p[1] < p[0]) || post.windows(2).any(|p| p[1] < p[0]) {
return Err(GeomError::InvalidArgument("a spike train is not in order"));
}
if pre.len().saturating_mul(post.len()) > 10_000_000 {
return Err(GeomError::InvalidArgument("that many pairings would not finish"));
}
let mut total = 0.0;
for before in pre {
for after in post {
total += stdp_window(after - before, a_plus, a_minus, tau_plus, tau_minus)?;
}
}
Ok(total)
}
pub fn izhikevich_network(
n_exc: usize,
n_inh: usize,
t_end: f64,
rng: &mut Rng,
) -> Result<Vec<(f64, usize)>, GeomError> {
if n_exc == 0 || n_inh == 0 || n_exc + n_inh > 4000 || !(t_end > 0.0) {
return Err(GeomError::InvalidArgument("izhikevich_network: bad parameters"));
}
let n = n_exc + n_inh;
let steps = (t_end.ceil()) as usize;
let mut a = vec![0.0; n];
let mut b = vec![0.0; n];
let mut c = vec![0.0; n];
let mut d = vec![0.0; n];
for i in 0..n {
let r = rng.next_f64();
if i < n_exc {
a[i] = 0.02;
b[i] = 0.2;
c[i] = -65.0 + 15.0 * r * r;
d[i] = 8.0 - 6.0 * r * r;
} else {
a[i] = 0.02 + 0.08 * r;
b[i] = 0.25 - 0.05 * r;
c[i] = -65.0;
d[i] = 2.0;
}
}
let mut weight = vec![0.0f64; n * n];
for target in 0..n {
for source in 0..n {
weight[target * n + source] =
if source < n_exc { 0.5 * rng.next_f64() } else { -rng.next_f64() };
}
}
let mut v: Vec<f64> = (0..n).map(|i| c[i]).collect();
let mut u: Vec<f64> = (0..n).map(|i| b[i] * v[i]).collect();
let mut out = Vec::new();
for step in 0..steps {
let t = step as f64;
let mut input: Vec<f64> = (0..n)
.map(|i| {
let drive = if i < n_exc { 5.0 } else { 2.0 };
drive * rng.next_gaussian()
})
.collect();
let fired: Vec<usize> = (0..n).filter(|i| v[*i] >= 30.0).collect();
for i in &fired {
out.push((t, *i));
v[*i] = c[*i];
u[*i] += d[*i];
}
for target in 0..n {
for source in &fired {
input[target] += weight[target * n + source];
}
}
for i in 0..n {
for _ in 0..2 {
v[i] += 0.5 * (0.04 * v[i] * v[i] + 5.0 * v[i] + 140.0 - u[i] + input[i]);
}
v[i] = v[i].min(30.0);
u[i] += a[i] * (b[i] * v[i] - u[i]);
}
}
Ok(out)
}
pub fn hopfield_store(patterns: &[Vec<i8>]) -> Result<Matrix, GeomError> {
let n = patterns.first().map_or(0, Vec::len);
if patterns.is_empty() || n == 0 || patterns.iter().any(|p| p.len() != n) {
return Err(GeomError::InvalidArgument("hopfield_store: bad patterns"));
}
if patterns.iter().flatten().any(|s| *s != 1 && *s != -1) {
return Err(GeomError::InvalidArgument("a pattern entry is not plus or minus one"));
}
let mut w = Matrix::zeros(n, n);
for pattern in patterns {
for i in 0..n {
for j in 0..n {
if i != j {
let value = w.get(i, j) + f64::from(pattern[i]) * f64::from(pattern[j]) / n as f64;
w.set(i, j, value);
}
}
}
}
Ok(w)
}
pub fn hopfield_recall(w: &Matrix, probe: &[i8], steps: usize) -> Result<Vec<i8>, GeomError> {
let n = w.rows;
if w.cols != n || probe.len() != n {
return Err(GeomError::InvalidArgument("hopfield_recall: mismatched shapes"));
}
if probe.iter().any(|s| *s != 1 && *s != -1) {
return Err(GeomError::InvalidArgument("a probe entry is not plus or minus one"));
}
let mut state = probe.to_vec();
for _ in 0..steps {
let mut changed = false;
for i in 0..n {
let field: f64 = (0..n).map(|j| w.get(i, j) * f64::from(state[j])).sum();
let next = if field > 0.0 {
1
} else if field < 0.0 {
-1
} else {
state[i]
};
if next != state[i] {
state[i] = next;
changed = true;
}
}
if !changed {
return Ok(state);
}
}
Ok(state)
}
pub fn hopfield_energy(w: &Matrix, state: &[i8]) -> Result<f64, GeomError> {
let n = w.rows;
if w.cols != n || state.len() != n {
return Err(GeomError::InvalidArgument("hopfield_energy: mismatched shapes"));
}
let mut total = 0.0;
for i in 0..n {
for j in 0..n {
total -= 0.5 * w.get(i, j) * f64::from(state[i]) * f64::from(state[j]);
}
}
Ok(total)
}
pub fn hopfield_capacity_check(
n: usize,
stored: usize,
trials: usize,
rng: &mut Rng,
) -> Result<f64, GeomError> {
if n == 0 || n > 500 || stored == 0 || trials == 0 {
return Err(GeomError::InvalidArgument("hopfield_capacity_check: bad parameters"));
}
let mut recalled = 0usize;
for _ in 0..trials {
let patterns: Vec<Vec<i8>> = (0..stored)
.map(|_| (0..n).map(|_| if rng.next_f64() < 0.5 { -1i8 } else { 1 }).collect())
.collect();
let w = hopfield_store(&patterns)?;
for pattern in &patterns {
if hopfield_recall(&w, pattern, 1)? == *pattern {
recalled += 1;
}
}
}
Ok(recalled as f64 / (trials * stored) as f64)
}
pub fn wilson_cowan(
c_ee: f64,
c_ei: f64,
c_ie: f64,
c_ii: f64,
p_e: f64,
p_i: f64,
tau_e: f64,
tau_i: f64,
slope: f64,
threshold: f64,
e0: f64,
i0: f64,
t_end: f64,
dt: f64,
) -> Result<Vec<(f64, f64, f64)>, GeomError> {
if !(tau_e > 0.0) || !(tau_i > 0.0) || !(slope > 0.0) {
return Err(GeomError::InvalidArgument("wilson_cowan: bad parameters"));
}
if !(0.0..=1.0).contains(&e0) || !(0.0..=1.0).contains(&i0) {
return Err(GeomError::InvalidArgument("the activities must start as fractions"));
}
let steps = check_run(t_end, dt, tau_e.min(tau_i))?;
let response = |x: f64| 1.0 / (1.0 + (-slope * (x - threshold)).exp());
let derivative = |_: f64, y: &[f64]| -> Vec<f64> {
let (e, i) = (y[0], y[1]);
vec![
(-e + response(c_ee * e - c_ei * i + p_e)) / tau_e,
(-i + response(c_ie * e - c_ii * i + p_i)) / tau_i,
]
};
let mut state = vec![e0, i0];
let mut out = vec![(0.0, e0, i0)];
for step in 0..steps {
let t = step as f64 * dt;
state = rk4_step_vec(&derivative, t, &state, dt);
if !state.iter().all(|x| x.is_finite()) {
return Err(GeomError::Degenerate("the Wilson-Cowan integration diverged"));
}
out.push((t + dt, state[0], state[1]));
}
Ok(out)
}
pub fn length_constant(r_m: f64, r_i: f64, diameter: f64) -> Result<f64, GeomError> {
if !(r_m > 0.0) || !(r_i > 0.0) || !(diameter > 0.0) {
return Err(GeomError::InvalidArgument("length_constant: bad parameters"));
}
Ok((diameter * r_m / (4.0 * r_i)).sqrt())
}
pub fn cable_equation_1d(
length: f64,
lambda: f64,
v_injected: f64,
points: usize,
) -> Result<Vec<f64>, GeomError> {
if !(length > 0.0) || !(lambda > 0.0) || points < 3 {
return Err(GeomError::InvalidArgument("cable_equation_1d: bad parameters"));
}
let h = length / (points - 1) as f64;
let k = (h / lambda).powi(2);
let mut sub = vec![0.0; points - 1];
let mut diag = vec![0.0; points];
let mut sup = vec![0.0; points - 1];
let mut rhs = vec![0.0; points];
diag[0] = 1.0;
sup[0] = 0.0;
rhs[0] = v_injected;
for i in 1..points - 1 {
sub[i - 1] = 1.0;
diag[i] = -(2.0 + k);
sup[i] = 1.0;
}
sub[points - 2] = 2.0;
diag[points - 1] = -(2.0 + k);
crate::linalg::thomas_solve(&sub, &diag, &sup, &rhs)
.map_err(|_| GeomError::Degenerate("the cable system is singular"))
}
pub fn reaction_time_ddm(
drift: f64,
threshold: f64,
noise: f64,
dt: f64,
trials: usize,
rng: &mut Rng,
) -> Result<Vec<(f64, bool)>, GeomError> {
if !(threshold > 0.0) || !(noise > 0.0) || !(dt > 0.0) || trials == 0 {
return Err(GeomError::InvalidArgument("reaction_time_ddm: bad parameters"));
}
let mut budget = 50_000_000usize;
let mut out = Vec::with_capacity(trials);
for _ in 0..trials {
let mut evidence = 0.0;
let mut steps = 0usize;
loop {
evidence += drift * dt + noise * dt.sqrt() * rng.next_gaussian();
steps += 1;
if evidence >= threshold {
out.push((steps as f64 * dt, true));
break;
}
if evidence <= -threshold {
out.push((steps as f64 * dt, false));
break;
}
if steps > budget {
return Err(GeomError::Degenerate(
"the evidence never reached a bound within the step budget",
));
}
}
budget = budget.saturating_sub(steps);
}
Ok(out)
}
pub fn ddm_analytic_accuracy(drift: f64, threshold: f64, noise: f64) -> Result<f64, GeomError> {
if !(threshold > 0.0) || !(noise > 0.0) {
return Err(GeomError::InvalidArgument("ddm_analytic_accuracy: bad parameters"));
}
Ok(1.0 / (1.0 + (-2.0 * drift * threshold / (noise * noise)).exp()))
}
#[cfg(test)]
mod tests {
use super::*;
fn pulse(amplitude: f64, start: f64, width: f64) -> impl Fn(f64) -> f64 {
move |t: f64| if t >= start && t < start + width { amplitude } else { 0.0 }
}
fn peak(trace: &[(f64, f64, f64, f64, f64)]) -> f64 {
trace.iter().map(|r| r.1).fold(f64::NEG_INFINITY, f64::max)
}
#[test]
fn the_rate_constants_are_finite_where_their_formulas_are_not() {
for v in [-40.0, -55.0] {
let rates = hh_rates(v);
assert!(rates.iter().all(|r| r.is_finite() && *r >= 0.0), "rates at {v}: {rates:?}");
}
for (v, index) in [(-40.0, 0usize), (-55.0, 4)] {
let here = hh_rates(v)[index];
let below = hh_rates(v - 1e-4)[index];
let above = hh_rates(v + 1e-4)[index];
assert!(
(here - 0.5 * (below + above)).abs() < 1e-8,
"at {v} the rate {here} does not match its neighbours {below} and {above}"
);
}
assert!((hh_rates(-40.0)[0] - 1.0).abs() < 1e-12);
assert!((hh_rates(-55.0)[4] - 0.1).abs() < 1e-12);
}
#[test]
fn an_unstimulated_axon_stays_where_it_started() {
let trace = hodgkin_huxley(&|_| 0.0, 50.0, 0.01).unwrap();
assert!(hh_spike_times(&trace).is_empty());
for row in &trace {
assert!((row.1 - HH_V_REST).abs() < 0.02, "the resting voltage drifted to {}", row.1);
}
let (m, h, n) = hh_steady_state(HH_V_REST);
assert!((trace[0].2 - m).abs() < 1e-15);
assert!((trace[0].3 - h).abs() < 1e-15);
assert!((trace[0].4 - n).abs() < 1e-15);
}
#[test]
fn the_gating_variables_are_probabilities_throughout_a_spike() {
let trace = hodgkin_huxley(&|_| 15.0, 120.0, 0.01).unwrap();
assert!(hh_spike_times(&trace).len() > 5);
for row in &trace {
for gate in [row.2, row.3, row.4] {
assert!((0.0..=1.0).contains(&gate), "a gate reached {gate}");
}
assert!(row.1 > HH_E_K - 5.0 && row.1 < HH_E_NA + 5.0, "voltage left the reversals");
}
}
#[test]
fn the_action_potential_is_all_or_none() {
let small = hodgkin_huxley(&pulse(10.0, 5.0, 0.5), 40.0, 0.01).unwrap();
assert!(hh_spike_times(&small).is_empty());
assert!(peak(&small) < -55.0, "a subthreshold pulse reached {}", peak(&small));
let once = hodgkin_huxley(&pulse(20.0, 5.0, 0.5), 40.0, 0.01).unwrap();
let twice = hodgkin_huxley(&pulse(40.0, 5.0, 0.5), 40.0, 0.01).unwrap();
assert_eq!(hh_spike_times(&once).len(), 1);
assert_eq!(hh_spike_times(&twice).len(), 1);
assert!(peak(&once) > 30.0 && peak(&twice) > 30.0);
assert!(
(peak(&twice) - peak(&once)).abs() < 3.0,
"doubling the stimulus moved the peak from {} to {}",
peak(&once),
peak(&twice)
);
}
#[test]
fn a_second_pulse_too_soon_after_the_first_produces_nothing() {
let count = |gap: f64| {
let stimulus = move |t: f64| {
if (5.0..5.5).contains(&t) || (5.0 + gap..5.5 + gap).contains(&t) {
20.0
} else {
0.0
}
};
hh_spike_times(&hodgkin_huxley(&stimulus, 60.0, 0.01).unwrap()).len()
};
assert_eq!(count(3.0), 1, "an early second pulse should be refused");
assert_eq!(count(8.0), 1);
assert_eq!(count(20.0), 2, "a late second pulse should succeed");
}
#[test]
fn the_firing_threshold_and_the_repetitive_firing_threshold_are_different_numbers() {
let threshold = hh_spike_threshold_estimate();
assert!((2.0..2.6).contains(&threshold), "the estimate came out at {threshold}");
let below = hodgkin_huxley(&|_| threshold * 0.98, 120.0, 0.01).unwrap();
let above = hodgkin_huxley(&|_| threshold * 1.02, 120.0, 0.01).unwrap();
assert!(hh_spike_times(&below).is_empty(), "it fired below its own estimate");
assert!(!hh_spike_times(&above).is_empty(), "it did not fire above its own estimate");
let sustained = hh_fi_curve(&[threshold * 1.5]).unwrap();
assert!(sustained[0].1 < 1.0, "it fired repeatedly at {}", sustained[0].0);
}
#[test]
fn the_f_i_curve_starts_abruptly_and_then_rises() {
let currents = [0.0, 3.0, 6.0, 6.3, 7.0, 10.0, 20.0];
let curve = hh_fi_curve(¤ts).unwrap();
assert_eq!(curve.len(), currents.len());
for (index, (current, rate)) in curve.iter().enumerate() {
assert!((current - currents[index]).abs() < 1e-15);
assert!(*rate >= 0.0);
}
assert_eq!(curve[0].1, 0.0);
assert_eq!(curve[1].1, 0.0);
assert_eq!(curve[2].1, 0.0, "6.0 uA/cm^2 should not fire repetitively");
assert!(curve[3].1 > 40.0, "the onset rate was only {}", curve[3].1);
for pair in curve.windows(2) {
assert!(pair[1].1 >= pair[0].1 - 1e-9, "the curve went down");
}
assert!(curve.last().unwrap().1 < 200.0, "the rate is beyond what the model can do");
}
#[test]
fn the_integrator_refuses_a_step_that_would_lose_the_upstroke() {
assert!(hodgkin_huxley(&|_| 0.0, 10.0, 0.1).is_err());
assert!(hodgkin_huxley(&|_| 0.0, 10.0, 0.0).is_err());
assert!(hodgkin_huxley(&|_| 0.0, 10.0, -0.01).is_err());
assert!(hodgkin_huxley(&|_| 0.0, 0.0, 0.01).is_err());
assert!(hodgkin_huxley(&|_| 0.0, 0.005, 0.01).is_err());
assert!(hh_fi_curve(&[]).is_err());
assert!(hh_fi_curve(&[f64::NAN]).is_err());
}
#[test]
fn fitzhugh_nagumo_decays_a_small_push_and_takes_an_excursion_from_a_larger_one() {
let rest = fitzhugh_nagumo_neuron(0.7, 0.8, 12.5, 0.0, -1.2, -0.62, 400.0, 0.05).unwrap();
let (v_rest, w_rest) = (rest.last().unwrap().1, rest.last().unwrap().2);
assert!((v_rest - -1.1994).abs() < 1e-3, "the rest point moved to {v_rest}");
let response = |kick: f64| {
let run =
fitzhugh_nagumo_neuron(0.7, 0.8, 12.5, 0.0, v_rest + kick, w_rest, 200.0, 0.05)
.unwrap();
run.iter().map(|r| r.1).fold(f64::NEG_INFINITY, f64::max)
};
assert!((response(0.1) - (v_rest + 0.1)).abs() < 1e-6);
assert!((response(0.5) - (v_rest + 0.5)).abs() < 0.2);
assert!(response(0.8) > 1.5, "the large kick only reached {}", response(0.8));
}
#[test]
fn a_current_above_the_bifurcation_makes_fitzhugh_nagumo_oscillate_forever() {
let run = fitzhugh_nagumo_neuron(0.7, 0.8, 12.5, 0.5, -1.2, -0.62, 400.0, 0.05).unwrap();
let tail: Vec<f64> = run.iter().filter(|r| r.0 > 200.0).map(|r| r.1).collect();
let swing = tail.iter().fold(f64::NEG_INFINITY, |a, b| a.max(*b))
- tail.iter().fold(f64::INFINITY, |a, b| a.min(*b));
assert!(swing > 3.0, "the oscillation died back to a swing of {swing}");
let quiet = fitzhugh_nagumo_neuron(0.7, 0.8, 12.5, 0.0, -1.2, -0.62, 400.0, 0.05).unwrap();
let settled: Vec<f64> = quiet.iter().filter(|r| r.0 > 200.0).map(|r| r.1).collect();
let residue = settled.iter().fold(f64::NEG_INFINITY, |a, b| a.max(*b))
- settled.iter().fold(f64::INFINITY, |a, b| a.min(*b));
assert!(residue < 1e-3, "the unstimulated model still swings by {residue}");
assert!(fitzhugh_nagumo_neuron(0.7, 0.8, 0.0, 0.0, 0.0, 0.0, 10.0, 0.05).is_err());
}
#[test]
fn the_two_bifurcations_give_firing_rates_that_begin_differently() {
let rate = |params: &MorrisLecar, current: f64, span: f64| -> f64 {
let run = morris_lecar(params, current, -60.0, 0.0, span, 0.05).unwrap();
let trace: Vec<(f64, f64)> = run.iter().map(|r| (r.0, r.1)).collect();
let counted =
spike_times(&trace, 0.0).into_iter().filter(|t| *t > span * 0.3).count();
1000.0 * counted as f64 / (span * 0.7)
};
let two = MorrisLecar::hopf();
assert_eq!(rate(&two, 88.0, 3000.0), 0.0, "type II fired below its threshold");
let onset_two = rate(&two, 90.0, 3000.0);
assert!(onset_two > 5.0, "type II started at only {onset_two} Hz");
let one = MorrisLecar::saddle_node();
assert_eq!(rate(&one, 39.0, 6000.0), 0.0, "type I fired below its threshold");
let onset_one = rate(&one, 40.0, 6000.0);
assert!(onset_one > 0.0, "type I did not start firing");
assert!(
onset_one < 0.5 * onset_two,
"type I began at {onset_one} Hz against type II's {onset_two} Hz"
);
assert!(rate(&one, 60.0, 6000.0) > 3.0 * onset_one);
assert!(rate(&two, 120.0, 3000.0) < 2.0 * onset_two);
}
#[test]
fn morris_lecar_refuses_parameters_that_are_not_conductances() {
let mut bad = MorrisLecar::hopf();
bad.c_m = 0.0;
assert!(morris_lecar(&bad, 50.0, -60.0, 0.0, 100.0, 0.05).is_err());
bad = MorrisLecar::hopf();
bad.v2 = 0.0;
assert!(morris_lecar(&bad, 50.0, -60.0, 0.0, 100.0, 0.05).is_err());
bad = MorrisLecar::hopf();
bad.phi = -1.0;
assert!(morris_lecar(&bad, 50.0, -60.0, 0.0, 100.0, 0.05).is_err());
assert!(morris_lecar(&MorrisLecar::hopf(), 50.0, -60.0, 0.0, 100.0, 2.0).is_err());
}
#[test]
fn each_izhikevich_preset_produces_the_pattern_it_is_named_for() {
let presets = izhikevich_presets();
assert_eq!(presets.len(), 5);
let mut rates = std::collections::HashMap::new();
let mut irregularity = std::collections::HashMap::new();
for (name, p) in &presets {
let run = izhikevich(p[0], p[1], p[2], p[3], 10.0, 400.0, 0.25).unwrap();
assert!(run.iter().all(|r| r.1 <= 30.0 + 1e-9), "{name} overshot the peak");
let spikes = spike_times(&run, 20.0);
assert!(spikes.len() > 3, "{name} barely fired");
rates.insert(*name, spikes.len());
irregularity.insert(*name, cv_isi(&spikes).unwrap());
}
assert!(rates["FS"] > rates["RS"], "FS did not outpace RS");
assert!(rates["FS"] > rates["IB"]);
assert!(irregularity["FS"] < 0.15, "FS was irregular at {}", irregularity["FS"]);
assert!(
irregularity["CH"] > 1.0,
"the chattering preset was regular at {}",
irregularity["CH"]
);
assert!(irregularity["CH"] > 4.0 * irregularity["FS"]);
}
#[test]
fn izhikevich_starts_at_rest_and_needs_a_current_to_fire() {
let quiet = izhikevich(0.02, 0.2, -65.0, 8.0, 0.0, 200.0, 0.25).unwrap();
assert!(spike_times(&quiet, 20.0).is_empty(), "it fired with no input");
assert!((quiet[0].1 - -65.0).abs() < 1e-12);
assert!(quiet.iter().all(|r| r.1 < -50.0));
assert!(izhikevich(0.0, 0.2, -65.0, 8.0, 10.0, 100.0, 0.25).is_err());
assert!(izhikevich(0.02, 0.2, -65.0, 8.0, 10.0, 100.0, 2.0).is_err());
}
#[test]
fn adaptation_lengthens_an_adex_spike_train_and_its_absence_does_not() {
let train = |a: f64, b: f64| -> Vec<f64> {
let run =
adex(200.0, 10.0, -70.0, 2.0, -50.0, 100.0, a, b, -58.0, 500.0, 400.0, 0.05)
.unwrap();
let trace: Vec<(f64, f64)> = run.iter().map(|r| (r.0, r.1)).collect();
interspike_intervals(&spike_times(&trace, -32.0)).unwrap()
};
let steady = train(0.0, 0.0);
assert!(steady.len() > 20);
let spread = steady.last().unwrap() - steady.first().unwrap();
assert!(spread.abs() < 0.1, "an unadapting neuron drifted by {spread} ms");
let spike_triggered = train(0.0, 60.0);
assert!(spike_triggered.len() > 5);
assert!(
spike_triggered.last().unwrap() > &(2.0 * spike_triggered.first().unwrap()),
"spike-triggered adaptation went from {:?} to {:?}",
spike_triggered.first(),
spike_triggered.last()
);
assert!(spike_triggered.len() < steady.len());
let subthreshold = train(4.0, 0.0);
assert!(subthreshold.last().unwrap() > subthreshold.first().unwrap());
assert!(adex(0.0, 10.0, -70.0, 2.0, -50.0, 100.0, 0.0, 0.0, -58.0, 500.0, 100.0, 0.05).is_err());
assert!(adex(200.0, 10.0, -70.0, 0.0, -50.0, 100.0, 0.0, 0.0, -58.0, 500.0, 100.0, 0.05).is_err());
}
#[test]
fn the_simulated_leaky_integrator_fires_at_the_rate_the_formula_gives() {
let mut rng = Rng::new(0x0E0E_1001);
for current in [1.05f64, 1.2, 2.0, 5.0, 20.0] {
let spikes =
lif_neuron(current, 10.0, 1.0, 0.0, 2.0, 0.0, 4000.0, 0.005, &mut rng).unwrap();
let simulated = spikes.len() as f64 / 4000.0;
let exact = lif_fi_exact(current, 10.0, 1.0, 0.0, 2.0).unwrap();
assert!(
(simulated - exact).abs() < 0.02 * exact,
"at I={current} simulation gave {simulated} against {exact}"
);
}
let silent = lif_neuron(0.99, 10.0, 1.0, 0.0, 2.0, 0.0, 2000.0, 0.01, &mut rng).unwrap();
assert!(silent.is_empty());
assert_eq!(lif_fi_exact(0.99, 10.0, 1.0, 0.0, 2.0).unwrap(), 0.0);
assert_eq!(lif_fi_exact(1.0, 10.0, 1.0, 0.0, 2.0).unwrap(), 0.0);
}
#[test]
fn the_leaky_integrator_saturates_at_the_refractory_period() {
let ceiling = 1.0 / 2.0;
let mut previous = 0.0;
for current in [1.5f64, 3.0, 10.0, 100.0, 1e6] {
let rate = lif_fi_exact(current, 10.0, 1.0, 0.0, 2.0).unwrap();
assert!(rate > previous, "the curve was not increasing at {current}");
assert!(rate < ceiling, "at {current} the rate {rate} beat the refractory limit");
previous = rate;
}
assert!((lif_fi_exact(1e12, 10.0, 1.0, 0.0, 2.0).unwrap() - ceiling).abs() < 1e-6);
assert!(lif_fi_exact(1e6, 10.0, 1.0, 0.0, 0.0).unwrap() > 100.0);
assert!(lif_fi_exact(2.0, 0.0, 1.0, 0.0, 1.0).is_err());
assert!(lif_fi_exact(2.0, 10.0, 1.0, 1.0, 1.0).is_err());
}
#[test]
fn noise_makes_a_subthreshold_leaky_integrator_fire_anyway() {
let mut rng = Rng::new(0x0E0E_1002);
let quiet = lif_neuron(0.9, 10.0, 1.0, 0.0, 2.0, 0.0, 5000.0, 0.01, &mut rng).unwrap();
assert!(quiet.is_empty());
let noisy = lif_neuron(0.9, 10.0, 1.0, 0.0, 2.0, 0.5, 5000.0, 0.01, &mut rng).unwrap();
assert!(!noisy.is_empty(), "noise produced no spikes at all");
assert!(cv_isi(&noisy).unwrap() > 0.3, "the noisy train was suspiciously regular");
assert!(lif_neuron(1.0, -1.0, 1.0, 0.0, 0.0, 0.0, 10.0, 0.01, &mut rng).is_err());
assert!(lif_neuron(1.0, 10.0, 0.0, 1.0, 0.0, 0.0, 10.0, 0.01, &mut rng).is_err());
assert!(lif_neuron(1.0, 10.0, 1.0, 0.0, -1.0, 0.0, 10.0, 0.01, &mut rng).is_err());
}
#[test]
fn a_poisson_train_has_the_rate_and_the_irregularity_it_should() {
let mut rng = Rng::new(0x0E0E_1003);
let rate = 0.05;
let span = 200_000.0;
let train = poisson_spike_train(rate, span, &mut rng).unwrap();
let observed = train.len() as f64 / span;
assert!((observed - rate).abs() < 0.05 * rate, "the rate came out at {observed}");
assert!(train.windows(2).all(|p| p[1] > p[0]), "the train is not ordered");
assert!(train.iter().all(|t| (0.0..span).contains(t)));
let cv = cv_isi(&train).unwrap();
assert!((cv - 1.0).abs() < 0.05, "the coefficient of variation was {cv}");
let window = 100.0;
let bins = (span / window) as usize;
let mut counts = vec![0u64; bins];
for spike in &train {
counts[((spike / window) as usize).min(bins - 1)] += 1;
}
let fano = fano_factor(&counts).unwrap();
assert!((fano - 1.0).abs() < 0.1, "the Fano factor was {fano}");
}
#[test]
fn the_two_irregularity_measures_answer_different_questions() {
let regular: Vec<f64> = (0..1000).map(|k| k as f64 * 10.0).collect();
assert!(cv_isi(®ular).unwrap() < 1e-12);
let counts: Vec<u64> = (0..100).map(|_| 10u64).collect();
assert!(fano_factor(&counts).unwrap() < 1e-12);
let mut drifting: Vec<f64> = Vec::new();
let mut t = 0.0f64;
for block in 0..40 {
let gap = if block % 2 == 0 { 4.0 } else { 40.0 };
for _ in 0..25 {
drifting.push(t);
t += gap;
}
}
let block_counts: Vec<u64> = (0..40).map(|_| 25u64).collect();
assert!(fano_factor(&block_counts).unwrap() < 1e-12);
let window = 200.0;
let bins = (t / window).ceil() as usize;
let mut windowed = vec![0u64; bins];
for spike in &drifting {
windowed[((spike / window) as usize).min(bins - 1)] += 1;
}
assert!(fano_factor(&windowed).unwrap() > 5.0, "the drifting train looked Poisson");
}
#[test]
fn the_irregularity_measures_reject_what_they_cannot_describe() {
assert!(interspike_intervals(&[1.0, 0.5]).is_err());
assert!(cv_isi(&[1.0, 2.0]).is_err());
assert!(cv_isi(&[1.0, 1.0, 1.0]).is_err());
assert!(fano_factor(&[3]).is_err());
assert!(fano_factor(&[0, 0, 0]).is_err());
assert!(interspike_intervals(&[]).unwrap().is_empty());
let mut rng = Rng::new(1);
assert!(poisson_spike_train(0.0, 10.0, &mut rng).is_err());
assert!(poisson_spike_train(1.0, 0.0, &mut rng).is_err());
assert!(poisson_spike_train(1e9, 1e9, &mut rng).is_err());
}
#[test]
fn a_histogram_of_poisson_trials_recovers_the_rate_it_was_drawn_from() {
let mut rng = Rng::new(0x0E0E_1004);
let rate = 0.08;
let span = 500.0;
let trains: Vec<Vec<f64>> =
(0..400).map(|_| poisson_spike_train(rate, span, &mut rng).unwrap()).collect();
for bin in [5.0, 25.0, 100.0] {
let histogram = psth(&trains, bin, span).unwrap();
assert_eq!(histogram.len(), (span / bin) as usize);
let mean = histogram.iter().sum::<f64>() / histogram.len() as f64;
assert!((mean - rate).abs() < 0.1 * rate, "bin {bin} gave a mean rate of {mean}");
}
let bin = 10.0;
let histogram = psth(&trains, bin, span).unwrap();
let integral: f64 = histogram.iter().map(|r| r * bin).sum();
let counted = trains.iter().map(Vec::len).sum::<usize>() as f64 / trains.len() as f64;
assert!((integral - counted).abs() < 1e-9, "{integral} against {counted}");
}
#[test]
fn the_raster_keeps_every_spike_and_the_trial_it_came_from() {
let trains = vec![vec![1.0, 4.0, 9.0], vec![2.0, 3.0], vec![], vec![0.5, 7.0]];
let raster = raster_data(&trains);
assert_eq!(raster.len(), 7);
assert!(raster.windows(2).all(|p| p[1].0 >= p[0].0), "the raster is not sorted");
for (trial, train) in trains.iter().enumerate() {
for spike in train {
assert!(raster.contains(&(*spike, trial)), "{spike} from trial {trial} was lost");
}
}
assert!(raster_data(&[]).is_empty());
assert!(psth(&[], 1.0, 10.0).is_err());
assert!(psth(&trains, 0.0, 10.0).is_err());
assert!(psth(&trains, 20.0, 10.0).is_err());
assert!(psth(&trains, 1.0, 5.0).is_err(), "a spike outside the window was accepted");
}
#[test]
fn the_spike_triggered_average_recovers_a_feature_the_spikes_were_locked_to() {
let mut rng = Rng::new(0x0E0E_1005);
let dt = 1.0;
let marker = [-1.0, 0.5, 2.0, 3.0];
let mut stimulus: Vec<f64> = (0..4000).map(|_| rng.next_gaussian()).collect();
let mut spikes = Vec::new();
let mut at = 50usize;
while at + marker.len() < stimulus.len() {
stimulus[at..at + marker.len()].copy_from_slice(&marker);
spikes.push((at + marker.len() - 1) as f64 * dt);
at += 40;
}
let average = spike_triggered_average(&stimulus, dt, &spikes, marker.len()).unwrap();
for (got, want) in average.iter().zip(marker.iter()) {
assert!((got - want).abs() < 1e-9, "recovered {average:?} not {marker:?}");
}
let noise: Vec<f64> = (0..40_000).map(|_| rng.next_gaussian()).collect();
let scattered: Vec<f64> = (0..8000).map(|k| (10 + 4 * k) as f64).collect();
let flat = spike_triggered_average(&noise, 1.0, &scattered, 6).unwrap();
for value in &flat {
assert!(value.abs() < 0.1, "an unrelated average came out at {value}");
}
}
#[test]
fn the_spike_triggered_average_refuses_what_it_cannot_average() {
let stimulus: Vec<f64> = (0..20).map(|k| k as f64).collect();
assert!(spike_triggered_average(&[], 1.0, &[5.0], 3).is_err());
assert!(spike_triggered_average(&stimulus, 0.0, &[5.0], 3).is_err());
assert!(spike_triggered_average(&stimulus, 1.0, &[5.0], 0).is_err());
assert!(spike_triggered_average(&stimulus, 1.0, &[5.0], 30).is_err());
assert!(spike_triggered_average(&stimulus, 1.0, &[-1.0], 3).is_err());
assert!(spike_triggered_average(&stimulus, 1.0, &[1.0], 5).is_err());
assert!(spike_triggered_average(&stimulus, 1.0, &[], 3).is_err());
let average = spike_triggered_average(&stimulus, 1.0, &[10.0], 3).unwrap();
assert_eq!(average, vec![8.0, 9.0, 10.0]);
}
#[test]
fn the_von_mises_fit_is_exact_on_data_that_came_from_a_von_mises_curve() {
let angles: Vec<f64> = (0..16)
.map(|k| -std::f64::consts::PI + k as f64 * std::f64::consts::TAU / 16.0)
.collect();
for (preferred, kappa, amplitude) in
[(0.7f64, 2.5f64, 3.0f64), (-2.0, 0.4, 12.0), (3.0, 8.0, 0.05)]
{
let rates: Vec<f64> =
angles.iter().map(|a| amplitude * (kappa * (a - preferred).cos()).exp()).collect();
let (mu, k, amp) = tuning_curve_fit_von_mises(&angles, &rates).unwrap();
let offset = (mu - preferred).sin().atan2((mu - preferred).cos()).abs();
assert!(offset < 1e-9, "preferred angle {mu} against {preferred}");
assert!((k - kappa).abs() < 1e-9, "concentration {k} against {kappa}");
assert!((amp - amplitude).abs() < 1e-9 * amplitude, "amplitude {amp}");
}
}
#[test]
fn the_von_mises_fit_is_blind_to_the_turn_of_the_circle_and_scales_with_the_rates() {
let angles: Vec<f64> = (0..12)
.map(|k| -std::f64::consts::PI + k as f64 * std::f64::consts::TAU / 12.0)
.collect();
let rates: Vec<f64> = angles.iter().map(|a| 4.0 * (1.8 * (a - 0.3).cos()).exp()).collect();
let (mu, kappa, amplitude) = tuning_curve_fit_von_mises(&angles, &rates).unwrap();
let turned: Vec<f64> = angles.iter().map(|a| a + std::f64::consts::TAU).collect();
let (mu2, kappa2, amplitude2) = tuning_curve_fit_von_mises(&turned, &rates).unwrap();
assert!((mu - mu2).abs() < 1e-8 && (kappa - kappa2).abs() < 1e-8);
assert!((amplitude - amplitude2).abs() < 1e-8);
let doubled: Vec<f64> = rates.iter().map(|r| 2.0 * r).collect();
let (mu3, kappa3, amplitude3) = tuning_curve_fit_von_mises(&angles, &doubled).unwrap();
assert!((mu - mu3).abs() < 1e-8 && (kappa - kappa3).abs() < 1e-8);
assert!((amplitude3 - 2.0 * amplitude).abs() < 1e-8);
let flat = vec![5.0; angles.len()];
let (_, kappa_flat, amplitude_flat) =
tuning_curve_fit_von_mises(&angles, &flat).unwrap();
assert!(kappa_flat < 1e-9, "a flat curve claimed a concentration of {kappa_flat}");
assert!((amplitude_flat - 5.0).abs() < 1e-9);
}
#[test]
fn the_von_mises_fit_refuses_data_that_does_not_determine_it() {
assert!(tuning_curve_fit_von_mises(&[0.0, 1.0], &[1.0, 2.0]).is_err());
assert!(tuning_curve_fit_von_mises(&[0.0, 1.0, 2.0], &[1.0, 2.0]).is_err());
assert!(tuning_curve_fit_von_mises(&[0.0, 1.0, 2.0], &[1.0, 0.0, 2.0]).is_err());
assert!(tuning_curve_fit_von_mises(&[0.0, 1.0, 2.0], &[1.0, -1.0, 2.0]).is_err());
assert!(tuning_curve_fit_von_mises(&[0.0, f64::NAN, 2.0], &[1.0, 1.0, 2.0]).is_err());
assert!(tuning_curve_fit_von_mises(&[0.5, 0.5, 0.5], &[1.0, 2.0, 3.0]).is_err());
}
#[test]
fn the_alpha_synapse_peaks_at_its_maximum_exactly_one_time_constant_late() {
let tau = 3.0;
let g_max = 0.7;
assert!((alpha_synapse(g_max, tau, &[0.0], tau).unwrap() - g_max).abs() < 1e-12);
for t in [0.5f64, 1.0, 2.0, 4.0, 8.0, 20.0] {
let value = alpha_synapse(g_max, tau, &[0.0], t).unwrap();
assert!(value <= g_max + 1e-12, "at {t} the conductance reached {value}");
assert!(value >= 0.0);
}
assert!(alpha_synapse(g_max, tau, &[0.0], 0.0).unwrap().abs() < 1e-15);
assert!((synapse_exp(g_max, tau, &[0.0], 0.0).unwrap() - g_max).abs() < 1e-15);
}
#[test]
fn the_exponential_synapse_decays_by_half_every_tau_ln_two() {
let tau = 5.0;
let half_life = tau * std::f64::consts::LN_2;
let mut expected = 1.0;
for k in 0..6 {
let value = synapse_exp(1.0, tau, &[0.0], k as f64 * half_life).unwrap();
assert!((value - expected).abs() < 1e-12, "at half-life {k} it was {value}");
expected *= 0.5;
}
assert_eq!(synapse_exp(1.0, tau, &[10.0], 9.99).unwrap(), 0.0);
}
#[test]
fn synaptic_conductances_add_so_a_burst_outweighs_a_single_spike() {
let tau = 10.0;
let burst = [0.0, 2.0, 4.0];
for t in [5.0f64, 12.0, 30.0] {
let together = synapse_exp(1.0, tau, &burst, t).unwrap();
let apart: f64 =
burst.iter().map(|s| synapse_exp(1.0, tau, &[*s], t).unwrap()).sum();
assert!((together - apart).abs() < 1e-12);
assert!(together > synapse_exp(1.0, tau, &[burst[0]], t).unwrap());
}
let alpha_together = alpha_synapse(1.0, tau, &burst, 8.0).unwrap();
let alpha_apart: f64 =
burst.iter().map(|s| alpha_synapse(1.0, tau, &[*s], 8.0).unwrap()).sum();
assert!((alpha_together - alpha_apart).abs() < 1e-12);
assert!(synapse_exp(1.0, 0.0, &[0.0], 1.0).is_err());
assert!(alpha_synapse(1.0, -1.0, &[0.0], 1.0).is_err());
assert!(synapse_exp(1.0, 1.0, &[3.0, 1.0], 5.0).is_err());
}
#[test]
fn the_plasticity_window_changes_sign_across_a_zero_millisecond_gap() {
let (a_plus, a_minus, tau_plus, tau_minus) = (0.01, 0.012, 20.0, 20.0);
let at = |d: f64| stdp_window(d, a_plus, a_minus, tau_plus, tau_minus).unwrap();
assert_eq!(at(0.0), 0.0);
assert!(at(1e-9) > 0.0 && at(-1e-9) < 0.0);
assert!((at(1e-9) - a_plus).abs() < 1e-9);
assert!((at(-1e-9) + a_minus).abs() < 1e-9);
let mut previous = a_plus;
for delta in [5.0f64, 10.0, 20.0, 60.0] {
assert!(at(delta) < previous && at(delta) > 0.0);
assert!(at(-delta) > -previous && at(-delta) < 0.0);
previous = at(delta);
}
let balance: f64 = (1..2000).map(|k| at(k as f64 * 0.1) + at(-(k as f64) * 0.1)).sum();
assert!(balance < 0.0, "the window integrates to {balance}, which would only potentiate");
assert!(stdp_window(1.0, 0.01, 0.01, 0.0, 20.0).is_err());
assert!(stdp_window(1.0, -0.01, 0.01, 20.0, 20.0).is_err());
}
#[test]
fn causal_pairing_potentiates_and_reversing_the_order_depresses() {
let pre: Vec<f64> = (0..20).map(|k| k as f64 * 50.0).collect();
let causal: Vec<f64> = pre.iter().map(|t| t + 5.0).collect();
let anticausal: Vec<f64> = pre.iter().map(|t| t - 5.0).collect();
let up = stdp_train(&pre, &causal, 0.01, 0.012, 20.0, 20.0).unwrap();
let down = stdp_train(&pre, &anticausal, 0.01, 0.012, 20.0, 20.0).unwrap();
assert!(up > 0.0, "causal pairing gave {up}");
assert!(down < 0.0, "anticausal pairing gave {down}");
let mut by_hand = 0.0;
for before in &pre {
for after in &causal {
by_hand += stdp_window(after - before, 0.01, 0.012, 20.0, 20.0).unwrap();
}
}
assert!((up - by_hand).abs() < 1e-12);
assert!(stdp_train(&[2.0, 1.0], &causal, 0.01, 0.012, 20.0, 20.0).is_err());
assert_eq!(stdp_train(&[], &causal, 0.01, 0.012, 20.0, 20.0).unwrap(), 0.0);
}
#[test]
fn a_stored_pattern_is_a_fixed_point_of_the_network_that_stored_it() {
let mut rng = Rng::new(0x0E0E_2001);
let n = 80;
let patterns: Vec<Vec<i8>> = (0..4)
.map(|_| (0..n).map(|_| if rng.next_f64() < 0.5 { -1i8 } else { 1 }).collect())
.collect();
let w = hopfield_store(&patterns).unwrap();
for i in 0..n {
assert!(w.get(i, i).abs() < 1e-15);
for j in 0..n {
assert!((w.get(i, j) - w.get(j, i)).abs() < 1e-15);
}
}
for pattern in &patterns {
assert_eq!(&hopfield_recall(&w, pattern, 10).unwrap(), pattern);
let mirrored: Vec<i8> = pattern.iter().map(|s| -s).collect();
assert_eq!(hopfield_recall(&w, &mirrored, 10).unwrap(), mirrored);
assert!(
(hopfield_energy(&w, pattern).unwrap()
- hopfield_energy(&w, &mirrored).unwrap())
.abs()
< 1e-12
);
}
}
#[test]
fn recall_lowers_the_energy_and_repairs_a_corrupted_probe_almost_always() {
let mut rng = Rng::new(0x0E0E_2002);
let n = 100;
let (mut repaired, mut attempts) = (0usize, 0usize);
for _ in 0..12 {
let patterns: Vec<Vec<i8>> = (0..5)
.map(|_| (0..n).map(|_| if rng.next_f64() < 0.5 { -1i8 } else { 1 }).collect())
.collect();
let w = hopfield_store(&patterns).unwrap();
for pattern in &patterns {
let mut probe = pattern.clone();
for slot in probe.iter_mut() {
if rng.next_f64() < 0.2 {
*slot = -*slot;
}
}
let before = hopfield_energy(&w, &probe).unwrap();
let recalled = hopfield_recall(&w, &probe, 50).unwrap();
let after = hopfield_energy(&w, &recalled).unwrap();
assert!(after <= before + 1e-9, "the energy rose from {before} to {after}");
assert_eq!(hopfield_recall(&w, &recalled, 50).unwrap(), recalled);
attempts += 1;
if recalled == *pattern {
repaired += 1;
}
}
}
let fraction = repaired as f64 / attempts as f64;
assert!(fraction > 0.95, "a fifth of the bits flipped was repaired only {fraction} of the time");
}
#[test]
fn recall_can_settle_in_a_spurious_state_deeper_than_the_pattern_it_came_from() {
let mut rng = Rng::new(0x0E0E_2007);
let n = 100;
let mut found = None;
'search: for _ in 0..40 {
let patterns: Vec<Vec<i8>> = (0..8)
.map(|_| (0..n).map(|_| if rng.next_f64() < 0.5 { -1i8 } else { 1 }).collect())
.collect();
let w = hopfield_store(&patterns).unwrap();
for pattern in &patterns {
let mut probe = pattern.clone();
for slot in probe.iter_mut() {
if rng.next_f64() < 0.25 {
*slot = -*slot;
}
}
let recalled = hopfield_recall(&w, &probe, 50).unwrap();
let stored = patterns
.iter()
.any(|p| recalled == *p || recalled.iter().zip(p).all(|(a, b)| *a == -*b));
let deeper = hopfield_energy(&w, &recalled).unwrap()
< hopfield_energy(&w, pattern).unwrap() - 1e-9;
if !stored && deeper {
found = Some((w, recalled, pattern.clone(), patterns.clone()));
break 'search;
}
}
}
let (w, recalled, pattern, patterns) =
found.expect("no spurious minimum turned up in forty attempts");
assert_eq!(hopfield_recall(&w, &recalled, 50).unwrap(), recalled);
for stored in &patterns {
assert_ne!(&recalled, stored);
}
assert!(hopfield_energy(&w, &recalled).unwrap() < hopfield_energy(&w, &pattern).unwrap());
}
#[test]
fn hopfield_capacity_collapses_near_fourteen_percent_of_the_units() {
let mut rng = Rng::new(0x0E0E_2003);
let n = 100;
let easy = hopfield_capacity_check(n, 5, 6, &mut rng).unwrap();
let critical = hopfield_capacity_check(n, 14, 3, &mut rng).unwrap();
let overloaded = hopfield_capacity_check(n, 30, 3, &mut rng).unwrap();
assert!(easy > 0.98, "a light load recalled only {easy}");
assert!(critical < easy, "the critical load did no worse than the light one");
assert!(overloaded < 0.1, "an overloaded network still recalled {overloaded}");
assert!((0.0..=1.0).contains(&critical));
assert!(hopfield_store(&[]).is_err());
assert!(hopfield_store(&[vec![1, -1], vec![1]]).is_err());
assert!(hopfield_store(&[vec![1, 0]]).is_err());
let w = hopfield_store(&[vec![1, -1, 1]]).unwrap();
assert!(hopfield_recall(&w, &[1, -1], 5).is_err());
assert!(hopfield_recall(&w, &[1, 0, 1], 5).is_err());
assert!(hopfield_energy(&w, &[1, -1]).is_err());
assert!(hopfield_capacity_check(0, 2, 2, &mut rng).is_err());
assert!(hopfield_capacity_check(600, 2, 2, &mut rng).is_err());
assert!(hopfield_capacity_check(10, 0, 2, &mut rng).is_err());
}
#[test]
fn the_izhikevich_network_fires_at_a_cortical_rate_without_running_away() {
let mut rng = Rng::new(0x0E0E_2004);
let (excitatory, inhibitory, span) = (80usize, 20usize, 400.0);
let spikes = izhikevich_network(excitatory, inhibitory, span, &mut rng).unwrap();
let neurons = excitatory + inhibitory;
let rate = 1000.0 * spikes.len() as f64 / (neurons as f64 * span);
assert!((0.5..60.0).contains(&rate), "the network fired at {rate} Hz per neuron");
assert!(spikes.iter().all(|s| s.1 < neurons && (0.0..span).contains(&s.0)));
assert!(spikes.iter().any(|s| s.1 < excitatory));
assert!(spikes.iter().any(|s| s.1 >= excitatory));
assert!(izhikevich_network(0, 20, 100.0, &mut rng).is_err());
assert!(izhikevich_network(80, 0, 100.0, &mut rng).is_err());
assert!(izhikevich_network(3000, 2000, 100.0, &mut rng).is_err());
assert!(izhikevich_network(80, 20, 0.0, &mut rng).is_err());
}
#[test]
fn wilson_cowan_activities_stay_fractions_and_settle_when_the_loop_is_weak() {
let run =
wilson_cowan(1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 1.0, 1.0, 1.0, 0.0, 0.9, 0.2, 50.0, 0.01)
.unwrap();
for row in &run {
assert!((0.0..=1.0).contains(&row.1), "E left the unit interval at {}", row.1);
assert!((0.0..=1.0).contains(&row.2), "I left the unit interval at {}", row.2);
}
let tail: Vec<f64> = run.iter().filter(|r| r.0 > 30.0).map(|r| r.1).collect();
let swing = tail.iter().fold(f64::NEG_INFINITY, |a, b| a.max(*b))
- tail.iter().fold(f64::INFINITY, |a, b| a.min(*b));
assert!(swing < 1e-6, "a weakly coupled pair still swings by {swing}");
assert!(wilson_cowan(1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.5, 0.5, 10.0, 0.01).is_err());
assert!(wilson_cowan(1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 1.0, 1.0, 1.0, 0.0, 1.5, 0.5, 10.0, 0.01).is_err());
}
#[test]
fn a_strong_excitatory_inhibitory_loop_oscillates_where_a_weak_one_does_not() {
let swing = |slope: f64| -> f64 {
let run = wilson_cowan(
16.0, 12.0, 15.0, 3.0, 1.25, 0.0, 1.0, 1.0, slope, 4.0, 0.2, 0.1, 300.0, 0.01,
)
.unwrap();
let tail: Vec<f64> = run.iter().filter(|r| r.0 > 200.0).map(|r| r.1).collect();
tail.iter().fold(f64::NEG_INFINITY, |a, b| a.max(*b))
- tail.iter().fold(f64::INFINITY, |a, b| a.min(*b))
};
assert!(swing(1.0) < 1e-6, "the shallow response oscillated by {}", swing(1.0));
assert!(swing(1.3) > 0.2, "the steep response only swung by {}", swing(1.3));
let run = wilson_cowan(
16.0, 0.0, 0.0, 3.0, 1.25, 0.0, 1.0, 1.0, 1.3, 4.0, 0.2, 0.1, 300.0, 0.01,
)
.unwrap();
let tail: Vec<f64> = run.iter().filter(|r| r.0 > 200.0).map(|r| r.1).collect();
let residue = tail.iter().fold(f64::NEG_INFINITY, |a, b| a.max(*b))
- tail.iter().fold(f64::INFINITY, |a, b| a.min(*b));
assert!(residue < 1e-6, "the excitatory population oscillated alone, by {residue}");
}
#[test]
fn the_cable_solution_matches_the_hyperbolic_cosine_and_converges_at_second_order() {
let (length, lambda) = (2.0, 0.5);
let analytic = |x: f64| ((length - x) / lambda).cosh() / (length / lambda).cosh();
let worst = |points: usize| -> f64 {
let v = cable_equation_1d(length, lambda, 1.0, points).unwrap();
(0..points)
.map(|i| {
let x = length * i as f64 / (points - 1) as f64;
(v[i] - analytic(x)).abs()
})
.fold(0.0, f64::max)
};
let coarse = worst(101);
let fine = worst(201);
let finer = worst(401);
assert!(coarse < 2e-3, "the coarse grid was off by {coarse}");
let ratio = coarse / fine;
assert!((3.5..4.5).contains(&ratio), "halving the spacing cut the error by {ratio}");
assert!((fine / finer > 3.5) && (fine / finer < 4.5));
}
#[test]
fn a_sealed_end_holds_the_voltage_up_where_a_long_cable_would_have_decayed() {
let short = cable_equation_1d(0.5, 0.5, 1.0, 201).unwrap();
let long = cable_equation_1d(4.0, 0.5, 1.0, 1601).unwrap();
assert!((short[0] - 1.0).abs() < 1e-12 && (long[0] - 1.0).abs() < 1e-12);
let infinite = (-1.0f64).exp();
assert!(short.last().unwrap() > &infinite, "the sealed end did not hold the voltage up");
let at_lambda = long[200];
assert!((at_lambda - infinite).abs() < 1e-3, "a long cable gave {at_lambda} not {infinite}");
assert!(short.windows(2).all(|p| p[1] <= p[0] + 1e-12));
assert!(long.windows(2).all(|p| p[1] <= p[0] + 1e-12));
assert!(cable_equation_1d(0.0, 0.5, 1.0, 10).is_err());
assert!(cable_equation_1d(1.0, 0.0, 1.0, 10).is_err());
assert!(cable_equation_1d(1.0, 0.5, 1.0, 2).is_err());
}
#[test]
fn the_length_constant_grows_with_the_square_root_of_the_diameter() {
let base = length_constant(20_000.0, 100.0, 1e-4).unwrap();
let wider = length_constant(20_000.0, 100.0, 4e-4).unwrap();
assert!((wider / base - 2.0).abs() < 1e-12);
assert!((length_constant(80_000.0, 100.0, 1e-4).unwrap() / base - 2.0).abs() < 1e-12);
assert!((length_constant(20_000.0, 400.0, 1e-4).unwrap() / base - 0.5).abs() < 1e-12);
assert!((base - 0.0707).abs() < 1e-4, "the length constant came out at {base} cm");
assert!(length_constant(0.0, 100.0, 1e-4).is_err());
assert!(length_constant(20_000.0, 0.0, 1e-4).is_err());
assert!(length_constant(20_000.0, 100.0, 0.0).is_err());
}
#[test]
fn simulated_decisions_are_as_accurate_as_the_gamblers_ruin_formula_says() {
let mut rng = Rng::new(0x0E0E_2005);
for (drift, threshold, noise) in [(0.5f64, 1.0f64, 1.0f64), (1.0, 0.8, 1.2), (0.0, 1.0, 1.0)]
{
let trials = 4000;
let runs = reaction_time_ddm(drift, threshold, noise, 0.001, trials, &mut rng).unwrap();
assert_eq!(runs.len(), trials);
assert!(runs.iter().all(|r| r.0 > 0.0));
let observed = runs.iter().filter(|r| r.1).count() as f64 / trials as f64;
let exact = ddm_analytic_accuracy(drift, threshold, noise).unwrap();
let error = (exact * (1.0 - exact) / trials as f64).sqrt();
assert!(
(observed - exact).abs() < 4.0 * error + 0.01,
"drift {drift} gave {observed} against {exact}"
);
}
}
#[test]
fn the_decision_depends_only_on_the_drift_scaled_by_the_noise_power() {
let reference = ddm_analytic_accuracy(0.5, 1.0, 1.0).unwrap();
assert!((ddm_analytic_accuracy(1.0, 1.0, 2.0f64.sqrt()).unwrap() - reference).abs() < 1e-12);
assert!((ddm_analytic_accuracy(0.25, 2.0, 1.0).unwrap() - reference).abs() < 1e-12);
assert!((ddm_analytic_accuracy(0.0, 1.0, 1.0).unwrap() - 0.5).abs() < 1e-15);
assert!(ddm_analytic_accuracy(50.0, 1.0, 1.0).unwrap() > 1.0 - 1e-12);
assert!(
(ddm_analytic_accuracy(0.5, 1.0, 1.0).unwrap()
+ ddm_analytic_accuracy(-0.5, 1.0, 1.0).unwrap()
- 1.0)
.abs()
< 1e-15
);
assert!(ddm_analytic_accuracy(1.0, 0.0, 1.0).is_err());
assert!(ddm_analytic_accuracy(1.0, 1.0, 0.0).is_err());
}
#[test]
fn stronger_evidence_is_decided_faster_and_a_higher_bound_more_slowly() {
let mut rng = Rng::new(0x0E0E_2006);
let mean = |drift: f64, threshold: f64, rng: &mut Rng| -> f64 {
let runs = reaction_time_ddm(drift, threshold, 1.0, 0.001, 1500, rng).unwrap();
runs.iter().map(|r| r.0).sum::<f64>() / runs.len() as f64
};
let weak = mean(0.3, 1.0, &mut rng);
let strong = mean(1.5, 1.0, &mut rng);
assert!(strong < weak, "strong evidence took {strong} against weak evidence's {weak}");
let cautious = mean(0.3, 2.0, &mut rng);
assert!(cautious > weak, "a higher bound was decided in {cautious} against {weak}");
let careful = ddm_analytic_accuracy(0.3, 2.0, 1.0).unwrap();
assert!(careful > ddm_analytic_accuracy(0.3, 1.0, 1.0).unwrap());
assert!(reaction_time_ddm(1.0, 0.0, 1.0, 0.001, 10, &mut rng).is_err());
assert!(reaction_time_ddm(1.0, 1.0, 0.0, 0.001, 10, &mut rng).is_err());
assert!(reaction_time_ddm(1.0, 1.0, 1.0, 0.0, 10, &mut rng).is_err());
assert!(reaction_time_ddm(1.0, 1.0, 1.0, 0.001, 0, &mut rng).is_err());
}
}