import Foundation enum PeakKind { case freeCl, totalCl, crossover } struct LsvPeak { var vMv: Float var iUa: Float var kind: PeakKind } func smoothLsv(_ data: [Float], window: Int) -> [Float] { let n = data.count if n == 0 || window < 2 { return data } let half = window / 2 var out = [Float](repeating: 0, count: n) for i in 0.. [(Int, Bool)] { let n = iSmooth.count if n < 3 { return [] } var candidates: [(Int, Bool)] = [] for i in 1..<(n - 1) { let prev = iSmooth[i - 1] let curr = iSmooth[i] let next = iSmooth[i + 1] if curr > prev && curr > next { candidates.append((i, true)) } else if curr < prev && curr < next { candidates.append((i, false)) } } return candidates.filter { (idx, isMax) in let val = iSmooth[idx] let leftSlice = iSmooth[..= minProminence } else { leftBound = leftSlice.max() ?? val rightBound = rightSlice.max() ?? val return min(leftBound, rightBound) - val >= minProminence } } } /// Detect Q/HQ redox peak in the -100 to +600 mV window. /// Returns peak voltage in mV if found. func detectQhqPeak(_ points: [LsvPoint]) -> Float? { if points.count < 5 { return nil } let iVals = points.map { $0.iUa } let vVals = points.map { $0.vMv } let window = max(5, points.count / 50) let smoothed = smoothLsv(iVals, window: window) guard let iMin = smoothed.min(), let iMax = smoothed.max() else { return nil } let prominence = (iMax - iMin) * 0.05 let extrema = findExtrema(vVals, smoothed, minProminence: prominence) let candidates = extrema .filter { $0.1 && vVals[$0.0] >= -100 && vVals[$0.0] <= 600 } .max(by: { smoothed[$0.0] < smoothed[$1.0] }) if let (idx, _) = candidates { return vVals[idx] } return nil } func detectLsvPeaks(_ points: [LsvPoint]) -> [LsvPeak] { if points.count < 5 { return [] } let iVals = points.map { $0.iUa } let vVals = points.map { $0.vMv } let window = max(5, points.count / 50) let smoothed = smoothLsv(iVals, window: window) guard let iMin = smoothed.min(), let iMax = smoothed.max() else { return [] } let prominence = (iMax - iMin) * 0.05 let extrema = findExtrema(vVals, smoothed, minProminence: prominence) var peaks: [LsvPeak] = [] // crossover: where current changes sign for i in 1.. 0 && curr < 0) || (prev < 0 && curr > 0) if crossed { let aPrev = abs(prev) let aCurr = abs(curr) let frac = aPrev / (aPrev + aCurr) let dv = vVals[i] - vVals[i - 1] let vCross = vVals[i - 1] + frac * dv peaks.append(LsvPeak(vMv: vCross, iUa: 0, kind: .crossover)) break } } // largest peak in positive voltage region -> freeCl let freeCl = extrema .filter { $0.1 && vVals[$0.0] >= 0 } .max(by: { smoothed[$0.0] < smoothed[$1.0] }) if let (idx, _) = freeCl { peaks.append(LsvPeak(vMv: vVals[idx], iUa: smoothed[idx], kind: .freeCl)) } // largest peak in negative voltage region -> totalCl let totalCl = extrema .filter { $0.1 && vVals[$0.0] < 0 } .max(by: { smoothed[$0.0] < smoothed[$1.0] }) if let (idx, _) = totalCl { peaks.append(LsvPeak(vMv: vVals[idx], iUa: smoothed[idx], kind: .totalCl)) } return peaks }