add auto-mode chlorine flow to iOS
This commit is contained in:
parent
03d10ab678
commit
91a361732d
|
|
@ -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()
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 [] }
|
||||
|
||||
|
|
|
|||
|
|
@ -40,17 +40,14 @@ struct ChlorineView: View {
|
|||
private var controlsRow: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
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)
|
||||
|
|
@ -67,6 +64,30 @@ struct ChlorineView: View {
|
|||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
|
|
|
|||
Loading…
Reference in New Issue