add LSV peak detection module with smoothing, extrema finding, and classification

This commit is contained in:
jess 2026-04-02 18:12:49 -07:00
parent 1abf46f0c3
commit e5fe1c9229
2 changed files with 129 additions and 0 deletions

128
cue/src/lsv_analysis.rs Normal file
View File

@ -0,0 +1,128 @@
use crate::protocol::LsvPoint;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum PeakKind {
FreeCl,
TotalCl,
Crossover,
}
#[derive(Debug, Clone)]
pub struct LsvPeak {
pub v_mv: f32,
pub i_ua: f32,
pub kind: PeakKind,
}
pub fn smooth(data: &[f32], window: usize) -> Vec<f32> {
let n = data.len();
if n == 0 || window < 2 {
return data.to_vec();
}
let half = window / 2;
let mut out = Vec::with_capacity(n);
for i in 0..n {
let lo = i.saturating_sub(half);
let hi = (i + half).min(n - 1);
let sum: f32 = data[lo..=hi].iter().sum();
out.push(sum / (hi - lo + 1) as f32);
}
out
}
pub fn find_extrema(v: &[f32], i_smooth: &[f32], min_prominence: f32) -> Vec<(usize, bool)> {
let n = i_smooth.len();
if n < 3 {
return Vec::new();
}
let mut candidates: Vec<(usize, bool)> = Vec::new();
for i in 1..n - 1 {
let prev = i_smooth[i - 1];
let curr = i_smooth[i];
let next = i_smooth[i + 1];
if curr > prev && curr > next {
candidates.push((i, true));
} else if curr < prev && curr < next {
candidates.push((i, false));
}
}
candidates.retain(|&(idx, is_max)| {
let val = i_smooth[idx];
let left_bound = i_smooth[..idx].iter().copied()
.reduce(if is_max { f32::min } else { f32::max })
.unwrap_or(val);
let right_bound = i_smooth[idx + 1..].iter().copied()
.reduce(if is_max { f32::min } else { f32::max })
.unwrap_or(val);
let prom = if is_max {
val - left_bound.max(right_bound)
} else {
left_bound.min(right_bound) - val
};
prom >= min_prominence
});
let _ = v; // voltage array available for future use
candidates
}
pub fn detect_peaks(points: &[LsvPoint]) -> Vec<LsvPeak> {
if points.len() < 5 {
return Vec::new();
}
let i_vals: Vec<f32> = points.iter().map(|p| p.i_ua).collect();
let v_vals: Vec<f32> = points.iter().map(|p| p.v_mv).collect();
let window = 5.max(points.len() / 50);
let smoothed = smooth(&i_vals, window);
let i_min = smoothed.iter().copied().fold(f32::INFINITY, f32::min);
let i_max = smoothed.iter().copied().fold(f32::NEG_INFINITY, f32::max);
let prominence = (i_max - i_min) * 0.05;
let extrema = find_extrema(&v_vals, &smoothed, prominence);
let mut peaks: Vec<LsvPeak> = Vec::new();
// find crossover: where current changes sign
for i in 1..smoothed.len() {
if smoothed[i - 1].signum() != smoothed[i].signum() && smoothed[i - 1].signum() != 0.0 {
let frac = smoothed[i - 1].abs() / (smoothed[i - 1].abs() + smoothed[i].abs());
let v_cross = v_vals[i - 1] + frac * (v_vals[i] - v_vals[i - 1]);
peaks.push(LsvPeak {
v_mv: v_cross,
i_ua: 0.0,
kind: PeakKind::Crossover,
});
break;
}
}
// largest peak in positive voltage region -> FreeCl
let free_cl = extrema.iter()
.filter(|&&(idx, is_max)| is_max && v_vals[idx] >= 0.0)
.max_by(|a, b| smoothed[a.0].partial_cmp(&smoothed[b.0]).unwrap_or(std::cmp::Ordering::Equal));
if let Some(&(idx, _)) = free_cl {
peaks.push(LsvPeak {
v_mv: v_vals[idx],
i_ua: smoothed[idx],
kind: PeakKind::FreeCl,
});
}
// largest peak in negative voltage region -> TotalCl
let total_cl = extrema.iter()
.filter(|&&(idx, is_max)| is_max && v_vals[idx] < 0.0)
.max_by(|a, b| smoothed[a.0].partial_cmp(&smoothed[b.0]).unwrap_or(std::cmp::Ordering::Equal));
if let Some(&(idx, _)) = total_cl {
peaks.push(LsvPeak {
v_mv: v_vals[idx],
i_ua: smoothed[idx],
kind: PeakKind::TotalCl,
});
}
peaks
}

View File

@ -1,4 +1,5 @@
mod app;
mod lsv_analysis;
mod native_menu;
mod plot;
mod protocol;