use cubecl::prelude::Client;
use dspk_ops::linalg::{FastIcaModel, IcaContrast};
use serde::{Deserialize, Serialize};
pub const ICA_TOLERANCE: f32 = 1e-4;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MotorUnitPulseTrain {
pub unit_id: usize,
pub spike_samples: Vec<u64>,
pub ipt: Vec<f32>,
pub cov_isi: f32,
pub pnr_db: f32,
}
#[derive(Debug, Clone)]
pub struct ConvolutiveBssDecomposer {
pub extension_factor: usize,
pub num_sources: usize,
pub refractory_ms: f64,
pub peak_threshold_sigma: f32,
pub max_iterations: usize,
}
impl Default for ConvolutiveBssDecomposer {
fn default() -> Self {
Self {
extension_factor: 6,
num_sources: 8,
refractory_ms: 20.0,
peak_threshold_sigma: 3.5,
max_iterations: 80,
}
}
}
impl ConvolutiveBssDecomposer {
pub fn new(extension_factor: usize, num_sources: usize, refractory_ms: f64) -> Self {
Self {
extension_factor: extension_factor.max(1),
num_sources: num_sources.max(1),
refractory_ms: refractory_ms.max(2.0),
..Self::default()
}
}
pub fn decompose(
&self,
client: &Client,
data: &[f32],
channels: usize,
samples: usize,
sample_rate_hz: f64,
) -> Vec<MotorUnitPulseTrain> {
assert_eq!(data.len(), channels * samples);
if channels == 0 || samples < 16 {
return Vec::new();
}
let l_ext = self.extension_factor.clamp(1, samples / 4);
let ext_channels = channels * l_ext;
let k_sources = self.num_sources.clamp(1, ext_channels);
let mut extended = vec![0.0f32; ext_channels * samples];
for c in 0..channels {
let src_row = &data[c * samples..(c + 1) * samples];
for lag in 0..l_ext {
let dst_row = &mut extended[(c * l_ext + lag) * samples..(c * l_ext + lag + 1) * samples];
dst_row[lag..].copy_from_slice(&src_row[..samples - lag]);
}
}
let ica = FastIcaModel::fit::<f32>(
client,
&extended,
ext_channels,
samples,
k_sources,
IcaContrast::Cube,
self.max_iterations,
ICA_TOLERANCE,
);
let sources = ica.transform_cpu(&extended, ext_channels, samples);
let ref_samples = ((sample_rate_hz * self.refractory_ms * 1e-3).round() as usize).max(2);
let mut candidates: Vec<MotorUnitPulseTrain> = Vec::new();
for s_idx in 0..ica.num_components {
let mut ipt = sources[s_idx * samples..(s_idx + 1) * samples].to_vec();
let max_pos = ipt.iter().copied().fold(0.0f32, f32::max);
let max_neg = ipt.iter().copied().fold(0.0f32, f32::min).abs();
if max_neg > max_pos {
for v in &mut ipt {
*v = -*v;
}
}
let rms = (ipt.iter().map(|v| v * v).sum::<f32>() / (samples as f32)).sqrt().max(1e-8);
let thresh = self.peak_threshold_sigma * rms;
let mut spikes = Vec::new();
let mut last_t: Option<usize> = None;
for t in 1..(samples - 1) {
let v = ipt[t];
if v > thresh && v >= ipt[t - 1] && v > ipt[t + 1] {
if last_t.map_or(true, |prev| t > prev + ref_samples) {
spikes.push(t as u64);
last_t = Some(t);
} else if let Some(prev) = last_t {
if v > ipt[prev] {
*spikes.last_mut().unwrap() = t as u64;
last_t = Some(t);
}
}
}
}
if spikes.len() < 3 {
continue;
}
let cov_isi = compute_cov_isi(&spikes);
let pnr_db = compute_pnr_db(&ipt, &spikes);
candidates.push(MotorUnitPulseTrain {
unit_id: candidates.len(),
spike_samples: spikes,
ipt,
cov_isi,
pnr_db,
});
}
candidates.sort_by(|a, b| b.pnr_db.partial_cmp(&a.pnr_db).unwrap_or(std::cmp::Ordering::Equal));
let mut unique_units: Vec<MotorUnitPulseTrain> = Vec::new();
let tol_samples = (l_ext as u64) + 2;
for mut cand in candidates {
let is_dup = unique_units.iter().any(|u| {
spike_train_coincidence(&u.spike_samples, &cand.spike_samples, tol_samples) > 0.5
});
if !is_dup {
cand.unit_id = unique_units.len();
unique_units.push(cand);
}
}
unique_units
}
}
fn compute_cov_isi(spikes: &[u64]) -> f32 {
if spikes.len() < 3 {
return f32::NAN;
}
let isis: Vec<f32> = spikes.windows(2).map(|w| (w[1] - w[0]) as f32).collect();
let n = isis.len() as f32;
let mean = isis.iter().sum::<f32>() / n;
if mean <= 1e-6 {
return f32::NAN;
}
let var = isis.iter().map(|&d| (d - mean) * (d - mean)).sum::<f32>() / n;
var.sqrt() / mean
}
fn compute_pnr_db(ipt: &[f32], spikes: &[u64]) -> f32 {
if ipt.is_empty() || spikes.is_empty() {
return 0.0;
}
let mut is_peak = vec![false; ipt.len()];
let mut peak_sum = 0.0f32;
for &t in spikes {
let idx = t as usize;
if idx < ipt.len() {
peak_sum += ipt[idx] * ipt[idx];
for w in idx.saturating_sub(2)..=(idx + 2).min(ipt.len() - 1) {
is_peak[w] = true;
}
}
}
let mean_peak_sq = peak_sum / (spikes.len() as f32);
let mut noise_sum = 0.0f32;
let mut noise_count = 0usize;
for (i, &v) in ipt.iter().enumerate() {
if !is_peak[i] {
noise_sum += v * v;
noise_count += 1;
}
}
let mean_noise_sq = (noise_sum / (noise_count.max(1) as f32)).max(1e-12);
10.0 * (mean_peak_sq / mean_noise_sq).max(1e-6).log10()
}
fn spike_train_coincidence(a: &[u64], b: &[u64], tol: u64) -> f32 {
if a.is_empty() || b.is_empty() {
return 0.0;
}
let max_shift = tol as i64;
let mut best_matches = 0usize;
for shift in -max_shift..=max_shift {
let mut matches = 0usize;
let mut j = 0usize;
for &ta in a {
let shifted_a = (ta as i64 + shift).max(0) as u64;
while j < b.len() && b[j] + 2 < shifted_a {
j += 1;
}
if j < b.len() && b[j].abs_diff(shifted_a) <= 2 {
matches += 1;
}
}
if matches > best_matches {
best_matches = matches;
}
}
(best_matches as f32) / (a.len().min(b.len()) as f32)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cbss_decomposer_recovers_motor_unit_pulse_trains() {
let channels = 4;
let samples = 2000;
let fs = 2000.0;
let mu1_times: Vec<usize> = (60..samples - 40).step_by(80).collect();
let mu2_times: Vec<usize> = (90..samples - 40).step_by(115).collect();
let mut data = vec![0.0f32; channels * samples];
let w1 = [1.0f32, 0.7, 0.2, 0.05];
let w2 = [0.05f32, 0.25, 0.8, 1.0];
for &t0 in &mu1_times {
for c in 0..channels {
let center = t0 + c;
data[c * samples + center] += 25.0 * w1[c];
data[c * samples + center + 1] -= 12.0 * w1[c];
}
}
for &t0 in &mu2_times {
for c in 0..channels {
let center = t0 + (channels - 1 - c);
data[c * samples + center] += 25.0 * w2[c];
data[c * samples + center + 1] -= 12.0 * w2[c];
}
}
struct Decompose<'a>(&'a [f32], usize, usize, f64);
impl dspk_core::compute::ComputeTask for Decompose<'_> {
type Output = Vec<MotorUnitPulseTrain>;
fn run(self, client: Client) -> Self::Output {
ConvolutiveBssDecomposer::new(3, 4, 20.0).decompose(&client, self.0, self.1, self.2, self.3)
}
}
for target in dspk_core::compute::ComputeTarget::for_tests() {
let units = target.run(Decompose(&data, channels, samples, fs)).unwrap();
assert!(units.len() >= 2, "{}: expected at least 2 motor units, got {}", target.name(), units.len());
assert!(units[0].cov_isi < 0.15, "cov_isi={}", units[0].cov_isi);
assert!(units[0].pnr_db > 15.0, "pnr_db={}", units[0].pnr_db);
}
}
}