199 lines
6.0 KiB
Swift
199 lines
6.0 KiB
Swift
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..<n {
|
|
let lo = max(0, i - half)
|
|
let hi = min(n - 1, i + half)
|
|
var sum: Float = 0
|
|
for j in lo...hi { sum += data[j] }
|
|
out[i] = sum / Float(hi - lo + 1)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func findExtrema(_ v: [Float], _ iSmooth: [Float], minProminence: Float) -> [(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[..<idx]
|
|
let rightSlice = iSmooth[(idx + 1)...]
|
|
let leftBound: Float
|
|
let rightBound: Float
|
|
if isMax {
|
|
leftBound = leftSlice.min() ?? val
|
|
rightBound = rightSlice.min() ?? val
|
|
return val - max(leftBound, rightBound) >= 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
|
|
}
|
|
|
|
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
|
|
let freePeak = extrema
|
|
.filter { !$0.1 && vVals[$0.0] >= -300 && vVals[$0.0] <= 300 }
|
|
.min(by: { smoothed[$0.0] < smoothed[$1.0] })
|
|
|
|
let vFree: Float
|
|
let vFreeDetected: Bool
|
|
let freeIdx: Int?
|
|
if let (idx, _) = freePeak {
|
|
vFree = vVals[idx]
|
|
vFreeDetected = true
|
|
freeIdx = idx
|
|
} else {
|
|
vFree = 100
|
|
vFreeDetected = false
|
|
freeIdx = nil
|
|
}
|
|
|
|
// v_total: secondary cathodic peak between (vFree-100) and -500, excluding free peak
|
|
let totalHi = vFree - 100
|
|
let totalLo: Float = -500
|
|
let totalPeak = extrema
|
|
.filter { !$0.1 && vVals[$0.0] >= totalLo && vVals[$0.0] <= totalHi && $0.0 != freeIdx }
|
|
.min(by: { smoothed[$0.0] < smoothed[$1.0] })
|
|
|
|
var vTotal: Float
|
|
let vTotalDetected: Bool
|
|
if let (idx, _) = totalPeak {
|
|
vTotal = vVals[idx]
|
|
vTotalDetected = true
|
|
} else {
|
|
vTotal = vFree - 300
|
|
vTotalDetected = false
|
|
}
|
|
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..<smoothed.count {
|
|
let prev = smoothed[i - 1]
|
|
let curr = smoothed[i]
|
|
let crossed = (prev > 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
|
|
}
|