EIS-BLE-S3/cue-ios/CueIOS/Models/LsvAnalysis.swift

209 lines
6.4 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))
}
}
var result: [(Int, Bool)] = []
for (idx, isMax) in candidates {
let val = iSmooth[idx]
let leftSlice = iSmooth[..<idx]
let rightSlice = iSmooth[(idx + 1)...]
if isMax {
let lb = leftSlice.min() ?? val
let rb = rightSlice.min() ?? val
if val - max(lb, rb) >= 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..<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
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
}