From 91a361732da02f1e0983411b6fb2c14d76da4a28 Mon Sep 17 00:00:00 2001 From: jess Date: Fri, 3 Apr 2026 02:09:38 -0700 Subject: [PATCH] add auto-mode chlorine flow to iOS --- cue-ios/CueIOS/AppState.swift | 61 +++++++++++++++++++++- cue-ios/CueIOS/Models/LsvAnalysis.swift | 61 ++++++++++++++++++++++ cue-ios/CueIOS/Views/ChlorineView.swift | 67 ++++++++++++++++--------- 3 files changed, 165 insertions(+), 24 deletions(-) diff --git a/cue-ios/CueIOS/AppState.swift b/cue-ios/CueIOS/AppState.swift index 7dfd29d..443800c 100644 --- a/cue-ios/CueIOS/AppState.swift +++ b/cue-ios/CueIOS/AppState.swift @@ -1,6 +1,10 @@ import Foundation import Observation +enum ClAutoState: Equatable { + case idle, lsvRunning, measureRunning +} + enum LsvDensityMode: String, CaseIterable, Identifiable { case ptsPerMv = "pts/mV" case ptsPerSec = "pts/s" @@ -73,6 +77,8 @@ final class AppState { var clMeasT: String = "5000" var clRtia: LpRtia = .r10K var clManualPeaks: Bool = false + var clAutoState: ClAutoState = .idle + var clAutoPotentials: ClPotentials? = nil // pH var phResult: PhResult? = nil @@ -189,6 +195,25 @@ final class AppState { } 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): ampPoints.removeAll() ampRunning = true @@ -220,7 +245,19 @@ final class AppState { case .clEnd: saveCl() - status = "Chlorine complete: \(clPoints.count) points" + 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" + } + } else { + status = "Chlorine complete: \(clPoints.count) points" + } case .phResult(let r): 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() { phResult = nil let stab = Float(phStabilize) ?? 30 diff --git a/cue-ios/CueIOS/Models/LsvAnalysis.swift b/cue-ios/CueIOS/Models/LsvAnalysis.swift index 745b055..9099f2a 100644 --- a/cue-ios/CueIOS/Models/LsvAnalysis.swift +++ b/cue-ios/CueIOS/Models/LsvAnalysis.swift @@ -85,6 +85,67 @@ func detectQhqPeak(_ points: [LsvPoint]) -> Float? { 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] { if points.count < 5 { return [] } diff --git a/cue-ios/CueIOS/Views/ChlorineView.swift b/cue-ios/CueIOS/Views/ChlorineView.swift index 5c129a9..55c2d78 100644 --- a/cue-ios/CueIOS/Views/ChlorineView.swift +++ b/cue-ios/CueIOS/Views/ChlorineView.swift @@ -40,33 +40,54 @@ struct ChlorineView: View { private var controlsRow: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { - Button("Start LSV") { state.startLSV() } - .buttonStyle(ActionButtonStyle(color: .green)) + if state.clManualPeaks { + Button("Start LSV") { state.startLSV() } + .buttonStyle(ActionButtonStyle(color: .green)) - Button(state.clManualPeaks ? "Manual" : "Auto") { - state.clManualPeaks.toggle() - if state.clManualPeaks { - state.lsvPeaks.removeAll() - } else { + Button("Manual") { + state.clManualPeaks = false state.lsvPeaks = detectLsvPeaks(state.lsvPoints) } + .font(.caption) + + Divider().frame(height: 24) + + LabeledField("Cond mV", text: $state.clCondV, width: 70) + LabeledField("Cond ms", text: $state.clCondT, width: 70) + LabeledField("Free mV", text: $state.clFreeV, width: 70) + LabeledField("Total mV", text: $state.clTotalV, width: 70) + LabeledField("Settle ms", text: $state.clDepT, width: 70) + LabeledField("Meas ms", text: $state.clMeasT, width: 70) + + LabeledPicker("RTIA", selection: $state.clRtia, items: LpRtia.allCases) { $0.label } + .frame(width: 120) + + Button("Measure") { state.startChlorine() } + .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) + } } - .font(.caption) - - Divider().frame(height: 24) - - LabeledField("Cond mV", text: $state.clCondV, width: 70) - LabeledField("Cond ms", text: $state.clCondT, width: 70) - LabeledField("Free mV", text: $state.clFreeV, width: 70) - LabeledField("Total mV", text: $state.clTotalV, width: 70) - LabeledField("Settle ms", text: $state.clDepT, width: 70) - LabeledField("Meas ms", text: $state.clMeasT, width: 70) - - LabeledPicker("RTIA", selection: $state.clRtia, items: LpRtia.allCases) { $0.label } - .frame(width: 120) - - Button("Measure") { state.startChlorine() } - .buttonStyle(ActionButtonStyle(color: .green)) } .padding(.horizontal) .padding(.vertical, 8)