add auto-mode chlorine flow to iOS
This commit is contained in:
parent
03d10ab678
commit
91a361732d
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 [] }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue