update iOS app for 9-point pH calibration protocol
This commit is contained in:
parent
c5f2dcfedb
commit
e1dd4ec436
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -66,6 +66,11 @@ struct PhResult: Codable {
|
|||
var tempC: Float
|
||||
}
|
||||
|
||||
struct PhCalCell {
|
||||
let ocpMv: Float
|
||||
let tempC: Float
|
||||
}
|
||||
|
||||
// MARK: - Config
|
||||
|
||||
struct EisConfig: Codable {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ final class UDPManager: @unchecked Sendable {
|
|||
send(buildSysexGetCellK())
|
||||
send(buildSysexGetClFactor())
|
||||
send(buildSysexGetPhCal())
|
||||
send(buildSysexPhCalStatus())
|
||||
startTimers()
|
||||
receiveLoop()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue