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)) } } var result: [(Int, Bool)] = [] for (idx, isMax) in candidates { let val = iSmooth[idx] let leftSlice = iSmooth[..= minProminence { result.append((idx, isMax)) } } else { let lb = leftSlice.max() ?? val let rb = rightSlice.max() ?? val if min(lb, rb) - val >= minProminence { result.append((idx, isMax)) } } } return result } /// 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) var bestIdx: Int? = nil var bestVal: Float = -.infinity for (idx, isMax) in extrema { guard isMax, vVals[idx] >= -100, vVals[idx] <= 600 else { continue } if smoothed[idx] > bestVal { bestVal = smoothed[idx] bestIdx = idx } } if let idx = bestIdx { return vVals[idx] } return nil } struct ClPotentials: Equatable { var vFree: Float var vFreeDetected: Bool var vTotal: Float var vTotalDetected: Bool } func deriveClPotentials(_ points: [LsvPoint]) -> ClPotentials { let dflt = ClPotentials(vFree: 100, vFreeDetected: false, vTotal: -200, vTotalDetected: false) guard points.count >= 5 else { return dflt } 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 dflt } let prominence = (iMax - iMin) * 0.05 let extrema = findExtrema(vVals, smoothed, minProminence: prominence) // v_free: most prominent cathodic peak (isMax==false) in +300 to -300 mV var vFree: Float = 100 var vFreeDetected = false var freeIdx: Int? = nil var freeBest: Float = .infinity for (idx, isMax) in extrema { guard !isMax, vVals[idx] >= -300, vVals[idx] <= 300 else { continue } if smoothed[idx] < freeBest { freeBest = smoothed[idx] vFree = vVals[idx] vFreeDetected = true freeIdx = idx } } // v_total: secondary cathodic peak between (vFree-100) and -500, excluding free peak let totalHi = vFree - 100 let totalLo: Float = -500 var vTotal: Float = vFree - 300 var vTotalDetected = false var totalBest: Float = .infinity for (idx, isMax) in extrema { guard !isMax, vVals[idx] >= totalLo, vVals[idx] <= totalHi, idx != freeIdx else { continue } if smoothed[idx] < totalBest { totalBest = smoothed[idx] vTotal = vVals[idx] vTotalDetected = true } } vTotal = max(vTotal, -400) return ClPotentials(vFree: vFree, vFreeDetected: vFreeDetected, vTotal: vTotal, vTotalDetected: vTotalDetected) } 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 var freeClIdx: Int? = nil var freeClVal: Float = -.infinity for (idx, isMax) in extrema { guard isMax, vVals[idx] >= 0 else { continue } if smoothed[idx] > freeClVal { freeClVal = smoothed[idx] freeClIdx = idx } } if let idx = freeClIdx { peaks.append(LsvPeak(vMv: vVals[idx], iUa: smoothed[idx], kind: .freeCl)) } // largest peak in negative voltage region -> totalCl var totalClIdx: Int? = nil var totalClVal: Float = -.infinity for (idx, isMax) in extrema { guard isMax, vVals[idx] < 0 else { continue } if smoothed[idx] > totalClVal { totalClVal = smoothed[idx] totalClIdx = idx } } if let idx = totalClIdx { peaks.append(LsvPeak(vMv: vVals[idx], iUa: smoothed[idx], kind: .totalCl)) } return peaks }