diff --git a/cue-ios/CueIOS/AppState.swift b/cue-ios/CueIOS/AppState.swift index 3cac002..07b0d97 100644 --- a/cue-ios/CueIOS/AppState.swift +++ b/cue-ios/CueIOS/AppState.swift @@ -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: diff --git a/cue-ios/CueIOS/Models/DataTypes.swift b/cue-ios/CueIOS/Models/DataTypes.swift index 64a729a..23c305b 100644 --- a/cue-ios/CueIOS/Models/DataTypes.swift +++ b/cue-ios/CueIOS/Models/DataTypes.swift @@ -66,6 +66,11 @@ struct PhResult: Codable { var tempC: Float } +struct PhCalCell { + let ocpMv: Float + let tempC: Float +} + // MARK: - Config struct EisConfig: Codable { diff --git a/cue-ios/CueIOS/Models/Protocol.swift b/cue-ios/CueIOS/Models/Protocol.swift index 6dd6718..b0e703d 100644 --- a/cue-ios/CueIOS/Models/Protocol.swift +++ b/cue-ios/CueIOS/Models/Protocol.swift @@ -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 diff --git a/cue-ios/CueIOS/Transport/UDPManager.swift b/cue-ios/CueIOS/Transport/UDPManager.swift index 467ad86..1c46cfa 100644 --- a/cue-ios/CueIOS/Transport/UDPManager.swift +++ b/cue-ios/CueIOS/Transport/UDPManager.swift @@ -108,6 +108,7 @@ final class UDPManager: @unchecked Sendable { send(buildSysexGetCellK()) send(buildSysexGetClFactor()) send(buildSysexGetPhCal()) + send(buildSysexPhCalStatus()) startTimers() receiveLoop() diff --git a/cue-ios/CueIOS/Views/CalibrateView.swift b/cue-ios/CueIOS/Views/CalibrateView.swift index 3b75f3f..5830a41 100644 --- a/cue-ios/CueIOS/Views/CalibrateView.swift +++ b/cue-ios/CueIOS/Views/CalibrateView.swift @@ -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)) - .font(.caption) + Spacer() + + Button("Clear Point") { + state.phCalClearPoint(buf: UInt8(state.phCalSelectedBuf), tslot: UInt8(state.phCalSelectedTslot)) + } + + Button("Clear All") { + state.phCalClearAll() + } } - Button("Clear Points") { - state.phCalPoints.removeAll() - state.status = "pH cal points cleared" + if state.phCalMeasuring { + ProgressView() + .padding(.vertical, 4) } - .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 + 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) } - 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) + .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) + .foregroundStyle(.quaternary) } }