update iOS app for 9-point pH calibration protocol

This commit is contained in:
jess 2026-04-09 15:34:15 -07:00
parent c5f2dcfedb
commit e1dd4ec436
5 changed files with 192 additions and 57 deletions

View File

@ -116,8 +116,15 @@ final class AppState {
var clCalKnownPpm: String = "5"
var phSlope: Double? = nil
var phOffset: Double? = nil
var phCalPoints: [(ph: Double, mV: Double)] = []
var phCalKnown: String = "7.00"
var phCalGrid: [[PhCalCell?]] = Array(repeating: Array(repeating: nil, count: 3), count: 3)
var phCalValidMask: UInt16 = 0
var phCalTempSlopeCold: Float? = nil
var phCalTempSlopeHot: Float? = nil
var phCalBaselineCount: UInt8 = 0
var phCalSelectedBuf: Int = 0
var phCalSelectedTslot: Int = 1
var phCalMeasuring: Bool = false
var phCalStabilize: String = "120"
// Clean
var cleanV: String = "1200"
@ -339,6 +346,36 @@ final class AppState {
phOffset = Double(offset)
status = String(format: "pH cal: slope=%.4f offset=%.4f", slope, offset)
case .phCalPoint(let buf, let tslot, let ocpMv, let tempC, _, let baselineCount):
let b = Int(buf)
let t = Int(tslot)
if b < 3 && t < 3 {
phCalGrid[b][t] = PhCalCell(ocpMv: ocpMv, tempC: tempC)
}
phCalBaselineCount = baselineCount
phCalMeasuring = false
status = String(format: "pH cal: buf %d tslot %d = %.1f mV @ %.1f\u{00B0}C", buf, tslot, ocpMv, tempC)
case .phCalStatus(let validMask, let slope, let offset, let tempSlopeCold, let tempSlopeHot):
phCalValidMask = validMask
phSlope = Double(slope)
phOffset = Double(offset)
phCalTempSlopeCold = tempSlopeCold
phCalTempSlopeHot = tempSlopeHot
for buf in 0..<3 {
for tslot in 0..<3 {
let bit = buf * 3 + tslot
if validMask & (1 << bit) != 0 {
if phCalGrid[buf][tslot] == nil {
phCalGrid[buf][tslot] = PhCalCell(ocpMv: 0, tempC: 0)
}
} else {
phCalGrid[buf][tslot] = nil
}
}
}
status = String(format: "pH cal: slope=%.4f offset=%.1f (%d baseline pts)", slope, offset, phCalBaselineCount)
case .sessionCreated(let fwId, let name):
handleSessionCreated(fwId: fwId, name: name)
@ -508,6 +545,23 @@ final class AppState {
send(buildSysexStartPh(stabilizeS: stab))
}
func phCalStartMeasurement() {
guard let stabilize = Float(phCalStabilize) else { return }
send(buildSysexPhCalPoint(bufferId: UInt8(phCalSelectedBuf), tempSlot: UInt8(phCalSelectedTslot), stabilizeS: stabilize))
phCalMeasuring = true
status = "pH cal: measuring..."
}
func phCalClearPoint(buf: UInt8, tslot: UInt8) {
send(buildSysexPhCalClear(bufferId: buf, tempSlot: tslot))
}
func phCalClearAll() {
send(buildSysexPhCalClear(bufferId: 0x7F, tempSlot: 0x7F))
phCalGrid = Array(repeating: Array(repeating: nil, count: 3), count: 3)
phCalValidMask = 0
}
func setReference() {
switch tab {
case .eis where !eisPoints.isEmpty:

View File

@ -66,6 +66,11 @@ struct PhResult: Codable {
var tempC: Float
}
struct PhCalCell {
let ocpMv: Float
let tempC: Float
}
// MARK: - Config
struct EisConfig: Codable {

View File

@ -31,6 +31,8 @@ let RSP_REFS_DONE: UInt8 = 0x22
let RSP_REF_STATUS: UInt8 = 0x23
let RSP_CL_FACTOR: UInt8 = 0x24
let RSP_PH_CAL: UInt8 = 0x25
let RSP_PH_CAL_POINT: UInt8 = 0x26
let RSP_PH_CAL_STATUS: UInt8 = 0x27
let RSP_SESSION_CREATED: UInt8 = 0x40
let RSP_SESSION_SWITCHED: UInt8 = 0x41
let RSP_SESSION_LIST: UInt8 = 0x42
@ -55,8 +57,10 @@ 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_PH_CAL_POINT: UInt8 = 0x37
let CMD_PH_CAL_CLEAR: UInt8 = 0x38
let CMD_PH_CAL_STATUS: UInt8 = 0x39
let CMD_START_REFS: UInt8 = 0x30
let CMD_GET_REFS: UInt8 = 0x31
let CMD_CLEAR_REFS: UInt8 = 0x32
@ -165,6 +169,8 @@ enum EisMessage {
case cellK(Float)
case clFactor(Float)
case phCal(slope: Float, offset: Float)
case phCalPoint(buf: UInt8, tslot: UInt8, ocpMv: Float, tempC: Float, bufferPh: Float, baselineCount: UInt8)
case phCalStatus(validMask: UInt16, slope: Float, offset: Float, tempSlopeCold: Float, tempSlopeHot: Float)
case sessionCreated(id: UInt8, name: String)
case sessionSwitched(id: UInt8)
case sessionList(count: UInt8, currentId: UInt8, sessions: [(id: UInt8, name: String)])
@ -326,6 +332,25 @@ func parseSysex(_ data: [UInt8]) -> EisMessage? {
offset: decodeFloat(p, at: 5)
)
case RSP_PH_CAL_POINT where p.count >= 18:
return .phCalPoint(
buf: p[0],
tslot: p[1],
ocpMv: decodeFloat(p, at: 2),
tempC: decodeFloat(p, at: 7),
bufferPh: decodeFloat(p, at: 12),
baselineCount: p[17]
)
case RSP_PH_CAL_STATUS where p.count >= 22:
return .phCalStatus(
validMask: UInt16(p[0]) | (UInt16(p[1]) << 7),
slope: decodeFloat(p, at: 2),
offset: decodeFloat(p, at: 7),
tempSlopeCold: decodeFloat(p, at: 12),
tempSlopeHot: decodeFloat(p, at: 17)
)
case RSP_SESSION_CREATED where p.count >= 2:
let sid = p[0]
let nameLen = Int(p[1])
@ -499,16 +524,25 @@ 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))
func buildSysexGetPhCal() -> [UInt8] {
[0xF0, sysexMfr, CMD_GET_PH_CAL, 0xF7]
}
func buildSysexPhCalPoint(bufferId: UInt8, tempSlot: UInt8, stabilizeS: Float) -> [UInt8] {
var sx: [UInt8] = [0xF0, sysexMfr, CMD_PH_CAL_POINT]
sx.append(bufferId & 0x7F)
sx.append(tempSlot & 0x7F)
sx.append(contentsOf: encodeFloat(stabilizeS))
sx.append(0xF7)
return sx
}
func buildSysexGetPhCal() -> [UInt8] {
[0xF0, sysexMfr, CMD_GET_PH_CAL, 0xF7]
func buildSysexPhCalClear(bufferId: UInt8, tempSlot: UInt8) -> [UInt8] {
[0xF0, sysexMfr, CMD_PH_CAL_CLEAR, bufferId & 0x7F, tempSlot & 0x7F, 0xF7]
}
func buildSysexPhCalStatus() -> [UInt8] {
[0xF0, sysexMfr, CMD_PH_CAL_STATUS, 0xF7]
}
// MARK: - Session commands

View File

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

View File

@ -161,22 +161,41 @@ struct CalibrateView: View {
// MARK: - pH calibration
private let bufferLabels = ["pH 4.0", "pH 6.86", "pH 9.0"]
private let tslotLabels = ["Below 25\u{00B0}C", "At 25\u{00B0}C", "Above 25\u{00B0}C"]
private var phCalibrationSection: some View {
Section("pH Calibration (Q/HQ peak-shift)") {
Section("pH Calibration (9-point)") {
if let s = state.phSlope, let o = state.phOffset {
Text(String(format: "slope: %.4f mV/pH offset: %.4f mV", s, o))
if let peak = detectQhqPeak(state.lsvPoints) {
if abs(s) > 1e-6 {
let ph = (Double(peak) - o) / s
Text(String(format: "Computed pH: %.2f (peak at %.1f mV)", ph, peak))
Text(String(format: "slope: %.4f mV/pH offset: %.1f mV", s, o))
}
if let tc = state.phCalTempSlopeCold {
Text(String(format: "temp slope cold: %.6f", tc))
}
if let th = state.phCalTempSlopeHot {
Text(String(format: "temp slope hot: %.6f", th))
}
phCalGridView
Picker("Buffer", selection: $state.phCalSelectedBuf) {
ForEach(0..<3, id: \.self) { i in
Text(bufferLabels[i]).tag(i)
}
}
.pickerStyle(.segmented)
Picker("Temp Slot", selection: $state.phCalSelectedTslot) {
ForEach(0..<3, id: \.self) { i in
Text(tslotLabels[i]).tag(i)
}
}
.pickerStyle(.segmented)
HStack {
Text("Known pH")
Text("Stabilize (s)")
Spacer()
TextField("7.00", text: $state.phCalKnown)
TextField("120", text: $state.phCalStabilize)
.multilineTextAlignment(.trailing)
.frame(width: 80)
#if os(iOS)
@ -184,52 +203,74 @@ struct CalibrateView: View {
#endif
}
Button("Add Calibration Point") {
guard let peak = detectQhqPeak(state.lsvPoints) else {
state.status = "No Q/HQ peak found in LSV data"
return
HStack {
Button("Measure") {
state.phCalStartMeasurement()
}
guard let ph = Double(state.phCalKnown) else { return }
state.phCalPoints.append((ph: ph, mV: Double(peak)))
state.status = String(format: "pH cal point: pH=%.2f peak=%.1f mV (%d pts)",
ph, peak, state.phCalPoints.count)
}
.disabled(state.lsvPoints.isEmpty)
.disabled(state.phCalMeasuring)
ForEach(Array(state.phCalPoints.enumerated()), id: \.offset) { i, pt in
Text(String(format: "%d. pH=%.2f peak=%.1f mV", i + 1, pt.ph, pt.mV))
Spacer()
Button("Clear Point") {
state.phCalClearPoint(buf: UInt8(state.phCalSelectedBuf), tslot: UInt8(state.phCalSelectedTslot))
}
Button("Clear All") {
state.phCalClearAll()
}
}
if state.phCalMeasuring {
ProgressView()
.padding(.vertical, 4)
}
}
}
private var phCalGridView: some View {
VStack(spacing: 0) {
HStack(spacing: 0) {
Text("")
.frame(width: 80, alignment: .leading)
ForEach(0..<3, id: \.self) { buf in
Text(bufferLabels[buf])
.font(.caption.bold())
.frame(maxWidth: .infinity)
}
}
.padding(.bottom, 4)
ForEach(0..<3, id: \.self) { tslot in
HStack(spacing: 0) {
Text(tslotLabels[tslot])
.font(.caption2)
.frame(width: 80, alignment: .leading)
ForEach(0..<3, id: \.self) { buf in
phCalCellView(buf: buf, tslot: tslot)
.frame(maxWidth: .infinity)
}
}
.background(tslot == 1 ? Color.accentColor.opacity(0.08) : Color.clear)
}
}
}
@ViewBuilder
private func phCalCellView(buf: Int, tslot: Int) -> some View {
if let cell = state.phCalGrid[buf][tslot] {
VStack(spacing: 1) {
Text(String(format: "%.1f mV", cell.ocpMv))
.font(.caption.monospacedDigit())
if cell.tempC != 0 {
Text(String(format: "%.1f\u{00B0}C", cell.tempC))
.font(.caption2)
.foregroundStyle(.secondary)
}
}
} else {
Text("\u{2014}")
.font(.caption)
}
Button("Clear Points") {
state.phCalPoints.removeAll()
state.status = "pH cal points cleared"
}
.disabled(state.phCalPoints.isEmpty)
Button("Compute & Set pH Cal") {
let pts = state.phCalPoints
guard pts.count >= 2 else {
state.status = "Need at least 2 calibration points"
return
}
let n = Double(pts.count)
let meanPh = pts.map(\.ph).reduce(0, +) / n
let meanV = pts.map(\.mV).reduce(0, +) / n
let num = pts.map { ($0.ph - meanPh) * ($0.mV - meanV) }.reduce(0, +)
let den = pts.map { ($0.ph - meanPh) * ($0.ph - meanPh) }.reduce(0, +)
guard abs(den) > 1e-12 else {
state.status = "Degenerate calibration data"
return
}
let slope = num / den
let offset = meanV - slope * meanPh
state.phSlope = slope
state.phOffset = offset
state.send(buildSysexSetPhCal(Float(slope), Float(offset)))
state.status = String(format: "pH cal set: slope=%.4f offset=%.4f", slope, offset)
}
.disabled(state.phCalPoints.count < 2)
.foregroundStyle(.quaternary)
}
}