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 clCalKnownPpm: String = "5"
|
||||||
var phSlope: Double? = nil
|
var phSlope: Double? = nil
|
||||||
var phOffset: Double? = nil
|
var phOffset: Double? = nil
|
||||||
var phCalPoints: [(ph: Double, mV: Double)] = []
|
var phCalGrid: [[PhCalCell?]] = Array(repeating: Array(repeating: nil, count: 3), count: 3)
|
||||||
var phCalKnown: String = "7.00"
|
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
|
// Clean
|
||||||
var cleanV: String = "1200"
|
var cleanV: String = "1200"
|
||||||
|
|
@ -339,6 +346,36 @@ final class AppState {
|
||||||
phOffset = Double(offset)
|
phOffset = Double(offset)
|
||||||
status = String(format: "pH cal: slope=%.4f offset=%.4f", slope, 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):
|
case .sessionCreated(let fwId, let name):
|
||||||
handleSessionCreated(fwId: fwId, name: name)
|
handleSessionCreated(fwId: fwId, name: name)
|
||||||
|
|
||||||
|
|
@ -508,6 +545,23 @@ final class AppState {
|
||||||
send(buildSysexStartPh(stabilizeS: stab))
|
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() {
|
func setReference() {
|
||||||
switch tab {
|
switch tab {
|
||||||
case .eis where !eisPoints.isEmpty:
|
case .eis where !eisPoints.isEmpty:
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,11 @@ struct PhResult: Codable {
|
||||||
var tempC: Float
|
var tempC: Float
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct PhCalCell {
|
||||||
|
let ocpMv: Float
|
||||||
|
let tempC: Float
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Config
|
// MARK: - Config
|
||||||
|
|
||||||
struct EisConfig: Codable {
|
struct EisConfig: Codable {
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ let RSP_REFS_DONE: UInt8 = 0x22
|
||||||
let RSP_REF_STATUS: UInt8 = 0x23
|
let RSP_REF_STATUS: UInt8 = 0x23
|
||||||
let RSP_CL_FACTOR: UInt8 = 0x24
|
let RSP_CL_FACTOR: UInt8 = 0x24
|
||||||
let RSP_PH_CAL: UInt8 = 0x25
|
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_CREATED: UInt8 = 0x40
|
||||||
let RSP_SESSION_SWITCHED: UInt8 = 0x41
|
let RSP_SESSION_SWITCHED: UInt8 = 0x41
|
||||||
let RSP_SESSION_LIST: UInt8 = 0x42
|
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_GET_CELL_K: UInt8 = 0x29
|
||||||
let CMD_SET_CL_FACTOR: UInt8 = 0x33
|
let CMD_SET_CL_FACTOR: UInt8 = 0x33
|
||||||
let CMD_GET_CL_FACTOR: UInt8 = 0x34
|
let CMD_GET_CL_FACTOR: UInt8 = 0x34
|
||||||
let CMD_SET_PH_CAL: UInt8 = 0x35
|
|
||||||
let CMD_GET_PH_CAL: UInt8 = 0x36
|
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_START_REFS: UInt8 = 0x30
|
||||||
let CMD_GET_REFS: UInt8 = 0x31
|
let CMD_GET_REFS: UInt8 = 0x31
|
||||||
let CMD_CLEAR_REFS: UInt8 = 0x32
|
let CMD_CLEAR_REFS: UInt8 = 0x32
|
||||||
|
|
@ -165,6 +169,8 @@ enum EisMessage {
|
||||||
case cellK(Float)
|
case cellK(Float)
|
||||||
case clFactor(Float)
|
case clFactor(Float)
|
||||||
case phCal(slope: Float, offset: 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 sessionCreated(id: UInt8, name: String)
|
||||||
case sessionSwitched(id: UInt8)
|
case sessionSwitched(id: UInt8)
|
||||||
case sessionList(count: UInt8, currentId: UInt8, sessions: [(id: UInt8, name: String)])
|
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)
|
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:
|
case RSP_SESSION_CREATED where p.count >= 2:
|
||||||
let sid = p[0]
|
let sid = p[0]
|
||||||
let nameLen = Int(p[1])
|
let nameLen = Int(p[1])
|
||||||
|
|
@ -499,16 +524,25 @@ func buildSysexGetClFactor() -> [UInt8] {
|
||||||
[0xF0, sysexMfr, CMD_GET_CL_FACTOR, 0xF7]
|
[0xF0, sysexMfr, CMD_GET_CL_FACTOR, 0xF7]
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildSysexSetPhCal(_ slope: Float, _ offset: Float) -> [UInt8] {
|
func buildSysexGetPhCal() -> [UInt8] {
|
||||||
var sx: [UInt8] = [0xF0, sysexMfr, CMD_SET_PH_CAL]
|
[0xF0, sysexMfr, CMD_GET_PH_CAL, 0xF7]
|
||||||
sx.append(contentsOf: encodeFloat(slope))
|
}
|
||||||
sx.append(contentsOf: encodeFloat(offset))
|
|
||||||
|
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)
|
sx.append(0xF7)
|
||||||
return sx
|
return sx
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildSysexGetPhCal() -> [UInt8] {
|
func buildSysexPhCalClear(bufferId: UInt8, tempSlot: UInt8) -> [UInt8] {
|
||||||
[0xF0, sysexMfr, CMD_GET_PH_CAL, 0xF7]
|
[0xF0, sysexMfr, CMD_PH_CAL_CLEAR, bufferId & 0x7F, tempSlot & 0x7F, 0xF7]
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSysexPhCalStatus() -> [UInt8] {
|
||||||
|
[0xF0, sysexMfr, CMD_PH_CAL_STATUS, 0xF7]
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Session commands
|
// MARK: - Session commands
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,7 @@ final class UDPManager: @unchecked Sendable {
|
||||||
send(buildSysexGetCellK())
|
send(buildSysexGetCellK())
|
||||||
send(buildSysexGetClFactor())
|
send(buildSysexGetClFactor())
|
||||||
send(buildSysexGetPhCal())
|
send(buildSysexGetPhCal())
|
||||||
|
send(buildSysexPhCalStatus())
|
||||||
startTimers()
|
startTimers()
|
||||||
receiveLoop()
|
receiveLoop()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -161,22 +161,41 @@ struct CalibrateView: View {
|
||||||
|
|
||||||
// MARK: - pH calibration
|
// 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 {
|
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 {
|
if let s = state.phSlope, let o = state.phOffset {
|
||||||
Text(String(format: "slope: %.4f mV/pH offset: %.4f mV", s, o))
|
Text(String(format: "slope: %.4f mV/pH offset: %.1f 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))
|
|
||||||
}
|
}
|
||||||
|
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 {
|
HStack {
|
||||||
Text("Known pH")
|
Text("Stabilize (s)")
|
||||||
Spacer()
|
Spacer()
|
||||||
TextField("7.00", text: $state.phCalKnown)
|
TextField("120", text: $state.phCalStabilize)
|
||||||
.multilineTextAlignment(.trailing)
|
.multilineTextAlignment(.trailing)
|
||||||
.frame(width: 80)
|
.frame(width: 80)
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
|
|
@ -184,52 +203,74 @@ struct CalibrateView: View {
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
Button("Add Calibration Point") {
|
HStack {
|
||||||
guard let peak = detectQhqPeak(state.lsvPoints) else {
|
Button("Measure") {
|
||||||
state.status = "No Q/HQ peak found in LSV data"
|
state.phCalStartMeasurement()
|
||||||
return
|
|
||||||
}
|
}
|
||||||
guard let ph = Double(state.phCalKnown) else { return }
|
.disabled(state.phCalMeasuring)
|
||||||
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)
|
|
||||||
|
|
||||||
ForEach(Array(state.phCalPoints.enumerated()), id: \.offset) { i, pt in
|
Spacer()
|
||||||
Text(String(format: "%d. pH=%.2f peak=%.1f mV", i + 1, pt.ph, pt.mV))
|
|
||||||
|
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)
|
.font(.caption)
|
||||||
}
|
.foregroundStyle(.quaternary)
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue