iOS: add pH cal protocol, Q/HQ peak detection, and state

This commit is contained in:
jess 2026-04-02 19:34:02 -07:00
parent d5e1a7dd0f
commit 818c4ff7a2
4 changed files with 58 additions and 0 deletions

View File

@ -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)
}
}

View File

@ -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 [] }

View File

@ -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]
}

View File

@ -96,6 +96,7 @@ final class UDPManager: @unchecked Sendable {
send(buildSysexGetConfig())
send(buildSysexGetCellK())
send(buildSysexGetClFactor())
send(buildSysexGetPhCal())
startTimers()
receiveLoop()