use super::firing::{compute_amplitude_cutoff_with, compute_llobet_contamination, compute_presence_ratio, AMPLITUDE_CUTOFF_MIN_RATIO, AMPLITUDE_CUTOFF_SMOOTHING};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CompositeScoreOptions {
pub refractory_ms: f64,
pub censored_ms: f64,
pub presence_bin_s: f64,
pub presence_mean_fr_ratio: f64,
pub amplitude_bins: usize,
pub max_rate_hz: f64,
pub firing_range_bin_s: f64,
pub firing_range_percentiles: (f64, f64),
pub snr_midpoint: f64,
}
impl Default for CompositeScoreOptions {
fn default() -> Self {
Self {
refractory_ms: 1.0,
censored_ms: 0.0,
presence_bin_s: 20.0,
presence_mean_fr_ratio: 0.5,
amplitude_bins: 32,
max_rate_hz: 200.0,
firing_range_bin_s: 0.5,
firing_range_percentiles: (5.0, 95.0),
snr_midpoint: 4.0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CompositeScore {
pub type_1: f64,
pub type_2: f64,
pub firing_rate: f64,
pub snr: f64,
pub score: f64,
}
pub fn composite_score(
spike_samples: &[u64],
amplitudes: &[f32],
snr: f64,
total_samples: u64,
sample_rate_hz: f64,
opts: &CompositeScoreOptions,
) -> CompositeScore {
let unit = |v: f64| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
let logistic_below = |x: f64, mid: f64| 1.0 / (1.0 + (x - mid).exp());
let contamination = compute_llobet_contamination(spike_samples, total_samples, sample_rate_hz, opts.refractory_ms, opts.censored_ms);
let type_1 = unit(1.0 - contamination);
let presence = compute_presence_ratio(spike_samples, total_samples, sample_rate_hz, opts.presence_bin_s, opts.presence_mean_fr_ratio);
let cutoff = compute_amplitude_cutoff_with(amplitudes, opts.amplitude_bins, AMPLITUDE_CUTOFF_SMOOTHING, AMPLITUDE_CUTOFF_MIN_RATIO);
let type_2 = unit(unit(presence) * unit(1.0 - cutoff));
let duration_s = total_samples as f64 / sample_rate_hz;
let rate = if duration_s > 0.0 { spike_samples.len() as f64 / duration_s } else { f64::NAN };
let range = firing_range(spike_samples, total_samples, sample_rate_hz, opts.firing_range_bin_s, opts.firing_range_percentiles);
let firing_rate = if rate.is_finite() && range.is_finite() {
unit(logistic_below(rate, opts.max_rate_hz) * logistic_below(range, opts.max_rate_hz))
} else {
0.0
};
let snr = if snr.is_finite() { unit(1.0 - logistic_below(snr, opts.snr_midpoint)) } else { 0.0 };
CompositeScore { type_1, type_2, firing_rate, snr, score: type_1 * type_2 * firing_rate * snr }
}
pub fn sort_composite_score(scores: &[CompositeScore]) -> f64 {
scores.iter().map(|s| s.score).sum::<f64>() / scores.len() as f64
}
pub fn firing_range(spike_samples: &[u64], total_samples: u64, sample_rate_hz: f64, bin_s: f64, (low, high): (f64, f64)) -> f64 {
let bin = (bin_s * sample_rate_hz).round() as u64;
if bin == 0 || total_samples < bin {
return f64::NAN;
}
let bins = (total_samples / bin) as usize;
let mut counts = vec![0u64; bins];
for &s in spike_samples {
if let Some(c) = counts.get_mut((s / bin) as usize) {
*c += 1;
}
}
let mut rates: Vec<f64> = counts.iter().map(|&c| c as f64 / bin_s).collect();
rates.sort_by(f64::total_cmp);
percentile_linear(&rates, high) - percentile_linear(&rates, low)
}
fn percentile_linear(sorted: &[f64], q: f64) -> f64 {
let pos = (q / 100.0).clamp(0.0, 1.0) * (sorted.len() - 1) as f64;
let (i, frac) = (pos.floor() as usize, pos.fract());
match sorted.get(i + 1) {
Some(&next) => sorted[i] + frac * (next - sorted[i]),
None => sorted[i],
}
}
#[cfg(test)]
mod tests {
use super::*;
const FS: f64 = 30_000.0;
#[test]
fn percentiles_interpolate_like_numpy() {
let v = [1.0, 2.0, 3.0, 4.0];
assert_eq!(percentile_linear(&v, 50.0), 2.5);
assert!((percentile_linear(&v, 5.0) - 1.15).abs() < 1e-12);
assert_eq!(percentile_linear(&v, 100.0), 4.0);
}
#[test]
fn components_follow_the_paper() {
let total = 120 * 30_000u64;
let spikes: Vec<u64> = (0..1200).map(|i| i * 3_000 + 100).collect();
let amps: Vec<f32> = (0..1200).map(|i| 50.0 + ((i * 37 % 41) as f32 - 20.0) * 0.5).collect();
let opts = CompositeScoreOptions::default();
let good = composite_score(&spikes, &s, 10.0, total, FS, &opts);
assert_eq!(good.type_1, 1.0, "no refractory violations");
assert!(good.score > 0.9, "{good:?}");
let noisy = composite_score(&spikes, &s, 2.0, total, FS, &opts);
assert!((noisy.snr - 1.0 / (1.0 + 2f64.exp())).abs() < 1e-12);
let fast: Vec<u64> = (0..36_000).map(|i| i * 100).collect();
let fast_amps = vec![50.0f32; fast.len()];
let fast = composite_score(&fast, &fast_amps, 10.0, total, FS, &opts);
assert!(fast.firing_rate < 1e-6, "{fast:?}");
}
#[test]
fn too_few_spikes_score_zero() {
let s = composite_score(&[100], &[50.0], 10.0, 30 * 30_000, FS, &CompositeScoreOptions::default());
assert_eq!(s.score, 0.0);
}
}