diff --git a/cue-ios/CueIOS/AppState.swift b/cue-ios/CueIOS/AppState.swift index ba5f712..b8f88aa 100644 --- a/cue-ios/CueIOS/AppState.swift +++ b/cue-ios/CueIOS/AppState.swift @@ -97,6 +97,10 @@ final class AppState { var calRs: Double? = nil var clFactor: Double? = nil var clCalKnownPpm: String = "5" + var phSlope: Double? = nil + var phOffset: Double? = nil + var phCalPoints: [(ph: Double, mV: Double)] = [] + var phCalKnown: String = "7.00" // Clean var cleanV: String = "1200" @@ -262,6 +266,11 @@ final class AppState { case .clFactor(let f): clFactor = Double(f) status = String(format: "Device Cl factor: %.6f", f) + + case .phCal(let slope, let offset): + phSlope = Double(slope) + phOffset = Double(offset) + status = String(format: "pH cal: slope=%.4f offset=%.4f", slope, offset) } } diff --git a/cue-ios/CueIOS/Models/LsvAnalysis.swift b/cue-ios/CueIOS/Models/LsvAnalysis.swift index 357ab5f..745b055 100644 --- a/cue-ios/CueIOS/Models/LsvAnalysis.swift +++ b/cue-ios/CueIOS/Models/LsvAnalysis.swift @@ -59,6 +59,32 @@ func findExtrema(_ v: [Float], _ iSmooth: [Float], minProminence: Float) -> [(In } } +/// 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 [] } diff --git a/cue-ios/CueIOS/Models/Protocol.swift b/cue-ios/CueIOS/Models/Protocol.swift index 61f9958..8a97608 100644 --- a/cue-ios/CueIOS/Models/Protocol.swift +++ b/cue-ios/CueIOS/Models/Protocol.swift @@ -30,6 +30,7 @@ let RSP_REF_LP_RANGE: UInt8 = 0x21 let RSP_REFS_DONE: UInt8 = 0x22 let RSP_REF_STATUS: UInt8 = 0x23 let RSP_CL_FACTOR: UInt8 = 0x24 +let RSP_PH_CAL: UInt8 = 0x25 // Cue -> ESP32 let CMD_SET_SWEEP: UInt8 = 0x10 @@ -49,6 +50,8 @@ let CMD_SET_CELL_K: UInt8 = 0x28 let CMD_GET_CELL_K: UInt8 = 0x29 let CMD_SET_CL_FACTOR: UInt8 = 0x33 let CMD_GET_CL_FACTOR: UInt8 = 0x34 +let CMD_SET_PH_CAL: UInt8 = 0x35 +let CMD_GET_PH_CAL: UInt8 = 0x36 let CMD_START_REFS: UInt8 = 0x30 let CMD_GET_REFS: UInt8 = 0x31 let CMD_CLEAR_REFS: UInt8 = 0x32 @@ -127,6 +130,7 @@ enum EisMessage { case refStatus(hasRefs: Bool) case cellK(Float) case clFactor(Float) + case phCal(slope: Float, offset: Float) } // MARK: - Response parser @@ -261,6 +265,12 @@ func parseSysex(_ data: [UInt8]) -> EisMessage? { case RSP_CL_FACTOR where p.count >= 5: return .clFactor(decodeFloat(p, at: 0)) + case RSP_PH_CAL where p.count >= 10: + return .phCal( + slope: decodeFloat(p, at: 0), + offset: decodeFloat(p, at: 5) + ) + default: return nil } @@ -389,3 +399,15 @@ func buildSysexSetClFactor(_ f: Float) -> [UInt8] { func buildSysexGetClFactor() -> [UInt8] { [0xF0, sysexMfr, CMD_GET_CL_FACTOR, 0xF7] } + +func buildSysexSetPhCal(_ slope: Float, _ offset: Float) -> [UInt8] { + var sx: [UInt8] = [0xF0, sysexMfr, CMD_SET_PH_CAL] + sx.append(contentsOf: encodeFloat(slope)) + sx.append(contentsOf: encodeFloat(offset)) + sx.append(0xF7) + return sx +} + +func buildSysexGetPhCal() -> [UInt8] { + [0xF0, sysexMfr, CMD_GET_PH_CAL, 0xF7] +} diff --git a/cue-ios/CueIOS/Transport/UDPManager.swift b/cue-ios/CueIOS/Transport/UDPManager.swift index 726792f..a228c02 100644 --- a/cue-ios/CueIOS/Transport/UDPManager.swift +++ b/cue-ios/CueIOS/Transport/UDPManager.swift @@ -96,6 +96,7 @@ final class UDPManager: @unchecked Sendable { send(buildSysexGetConfig()) send(buildSysexGetCellK()) send(buildSysexGetClFactor()) + send(buildSysexGetPhCal()) startTimers() receiveLoop()