diff --git a/cue/src/lsv_analysis.rs b/cue/src/lsv_analysis.rs new file mode 100644 index 0000000..e9cea2f --- /dev/null +++ b/cue/src/lsv_analysis.rs @@ -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 { + 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 { + if points.len() < 5 { + return Vec::new(); + } + + let i_vals: Vec = points.iter().map(|p| p.i_ua).collect(); + let v_vals: Vec = 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 = 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 +} diff --git a/cue/src/main.rs b/cue/src/main.rs index 95fb1da..669bcce 100644 --- a/cue/src/main.rs +++ b/cue/src/main.rs @@ -1,4 +1,5 @@ mod app; +mod lsv_analysis; mod native_menu; mod plot; mod protocol;