add auto-mode chlorine flow to iOS

This commit is contained in:
jess 2026-04-03 02:09:38 -07:00
parent 03d10ab678
commit 91a361732d
3 changed files with 165 additions and 24 deletions

View File

@ -1,6 +1,10 @@
import Foundation import Foundation
import Observation import Observation
enum ClAutoState: Equatable {
case idle, lsvRunning, measureRunning
}
enum LsvDensityMode: String, CaseIterable, Identifiable { enum LsvDensityMode: String, CaseIterable, Identifiable {
case ptsPerMv = "pts/mV" case ptsPerMv = "pts/mV"
case ptsPerSec = "pts/s" case ptsPerSec = "pts/s"
@ -73,6 +77,8 @@ final class AppState {
var clMeasT: String = "5000" var clMeasT: String = "5000"
var clRtia: LpRtia = .r10K var clRtia: LpRtia = .r10K
var clManualPeaks: Bool = false var clManualPeaks: Bool = false
var clAutoState: ClAutoState = .idle
var clAutoPotentials: ClPotentials? = nil
// pH // pH
var phResult: PhResult? = nil var phResult: PhResult? = nil
@ -189,6 +195,25 @@ final class AppState {
} }
status = st status = st
if clAutoState == .lsvRunning {
let pots = deriveClPotentials(lsvPoints)
clFreeV = String(format: "%.0f", pots.vFree)
clTotalV = String(format: "%.0f", pots.vTotal)
clAutoPotentials = pots
clAutoState = .measureRunning
let vCond = Float(clCondV) ?? 800
let tCond = Float(clCondT) ?? 2000
let tDep = Float(clDepT) ?? 5000
let tMeas = Float(clMeasT) ?? 5000
send(buildSysexGetTemp())
send(buildSysexStartCl(
vCond: vCond, tCondMs: tCond, vFree: pots.vFree, vTotal: pots.vTotal,
tDepMs: tDep, tMeasMs: tMeas, lpRtia: clRtia
))
status = String(format: "Auto Cl: measuring (free=%.0f, total=%.0f)", pots.vFree, pots.vTotal)
}
case .ampStart(let vHold): case .ampStart(let vHold):
ampPoints.removeAll() ampPoints.removeAll()
ampRunning = true ampRunning = true
@ -220,7 +245,19 @@ final class AppState {
case .clEnd: case .clEnd:
saveCl() saveCl()
if clAutoState == .measureRunning {
clAutoState = .idle
if let pots = clAutoPotentials {
let fd = pots.vFreeDetected ? "" : " dflt"
let td = pots.vTotalDetected ? "" : " dflt"
status = String(format: "Auto Cl complete: %d pts (free=%.0f%@, total=%.0f%@)",
clPoints.count, pots.vFree, fd, pots.vTotal, td)
} else {
status = "Chlorine complete: \(clPoints.count) points" status = "Chlorine complete: \(clPoints.count) points"
}
} else {
status = "Chlorine complete: \(clPoints.count) points"
}
case .phResult(let r): case .phResult(let r):
if collectingRefs { if collectingRefs {
@ -362,6 +399,28 @@ final class AppState {
)) ))
} }
func lsvCalcPointsFor(vStart: Float, vStop: Float, scanRate: Float) -> UInt16 {
let d = Float(lsvDensity) ?? 1
let range = abs(vStop - vStart)
let raw: Float
switch lsvDensityMode {
case .ptsPerMv:
raw = range * d
case .ptsPerSec:
raw = abs(scanRate) < 0.001 ? 2 : (range / abs(scanRate)) * d
}
return max(2, min(500, UInt16(raw)))
}
func startClAuto() {
clAutoState = .lsvRunning
clAutoPotentials = nil
lsvPoints.removeAll()
let n = lsvCalcPointsFor(vStart: -1100, vStop: 1100, scanRate: 50)
send(buildSysexStartLsv(vStart: -1100, vStop: 1100, scanRate: 50, lpRtia: lsvRtia, numPoints: n))
status = "Auto Cl: running LSV sweep..."
}
func startPh() { func startPh() {
phResult = nil phResult = nil
let stab = Float(phStabilize) ?? 30 let stab = Float(phStabilize) ?? 30

View File

@ -85,6 +85,67 @@ func detectQhqPeak(_ points: [LsvPoint]) -> Float? {
return nil return nil
} }
struct ClPotentials: Equatable {
var vFree: Float
var vFreeDetected: Bool
var vTotal: Float
var vTotalDetected: Bool
}
func deriveClPotentials(_ points: [LsvPoint]) -> ClPotentials {
let dflt = ClPotentials(vFree: 100, vFreeDetected: false, vTotal: -200, vTotalDetected: false)
guard points.count >= 5 else { return dflt }
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 dflt }
let prominence = (iMax - iMin) * 0.05
let extrema = findExtrema(vVals, smoothed, minProminence: prominence)
// v_free: most prominent cathodic peak (isMax==false) in +300 to -300 mV
let freePeak = extrema
.filter { !$0.1 && vVals[$0.0] >= -300 && vVals[$0.0] <= 300 }
.min(by: { smoothed[$0.0] < smoothed[$1.0] })
let vFree: Float
let vFreeDetected: Bool
let freeIdx: Int?
if let (idx, _) = freePeak {
vFree = vVals[idx]
vFreeDetected = true
freeIdx = idx
} else {
vFree = 100
vFreeDetected = false
freeIdx = nil
}
// v_total: secondary cathodic peak between (vFree-100) and -500, excluding free peak
let totalHi = vFree - 100
let totalLo: Float = -500
let totalPeak = extrema
.filter { !$0.1 && vVals[$0.0] >= totalLo && vVals[$0.0] <= totalHi && $0.0 != freeIdx }
.min(by: { smoothed[$0.0] < smoothed[$1.0] })
var vTotal: Float
let vTotalDetected: Bool
if let (idx, _) = totalPeak {
vTotal = vVals[idx]
vTotalDetected = true
} else {
vTotal = vFree - 300
vTotalDetected = false
}
vTotal = max(vTotal, -400)
return ClPotentials(vFree: vFree, vFreeDetected: vFreeDetected, vTotal: vTotal, vTotalDetected: vTotalDetected)
}
func detectLsvPeaks(_ points: [LsvPoint]) -> [LsvPeak] { func detectLsvPeaks(_ points: [LsvPoint]) -> [LsvPeak] {
if points.count < 5 { return [] } if points.count < 5 { return [] }

View File

@ -40,17 +40,14 @@ struct ChlorineView: View {
private var controlsRow: some View { private var controlsRow: some View {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) { HStack(spacing: 8) {
if state.clManualPeaks {
Button("Start LSV") { state.startLSV() } Button("Start LSV") { state.startLSV() }
.buttonStyle(ActionButtonStyle(color: .green)) .buttonStyle(ActionButtonStyle(color: .green))
Button(state.clManualPeaks ? "Manual" : "Auto") { Button("Manual") {
state.clManualPeaks.toggle() state.clManualPeaks = false
if state.clManualPeaks {
state.lsvPeaks.removeAll()
} else {
state.lsvPeaks = detectLsvPeaks(state.lsvPoints) state.lsvPeaks = detectLsvPeaks(state.lsvPoints)
} }
}
.font(.caption) .font(.caption)
Divider().frame(height: 24) Divider().frame(height: 24)
@ -67,6 +64,30 @@ struct ChlorineView: View {
Button("Measure") { state.startChlorine() } Button("Measure") { state.startChlorine() }
.buttonStyle(ActionButtonStyle(color: .green)) .buttonStyle(ActionButtonStyle(color: .green))
} else {
Button("Start Auto") { state.startClAuto() }
.buttonStyle(ActionButtonStyle(color: .green))
.disabled(state.clAutoState != .idle)
Button("Auto") {
state.clManualPeaks = true
state.lsvPeaks.removeAll()
}
.font(.caption)
LabeledPicker("RTIA", selection: $state.clRtia, items: LpRtia.allCases) { $0.label }
.frame(width: 120)
if let pots = state.clAutoPotentials {
Text(String(format: "free=%.0f%@ total=%.0f%@",
pots.vFree,
pots.vFreeDetected ? "" : "?",
pots.vTotal,
pots.vTotalDetected ? "" : "?"))
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
}
} }
.padding(.horizontal) .padding(.horizontal)
.padding(.vertical, 8) .padding(.vertical, 8)