304 lines
10 KiB
Swift
304 lines
10 KiB
Swift
import SwiftUI
|
|
|
|
struct CalibrateView: View {
|
|
@Bindable var state: AppState
|
|
|
|
private var volumeGal: Double { state.calVolumeGal }
|
|
private var naclPpm: Double { Double(state.calNaclPpm) ?? 0 }
|
|
private var clPpm: Double { Double(state.calClPpm) ?? 0 }
|
|
private var bleachPct: Double { Double(state.calBleachPct) ?? 0 }
|
|
private var tempC: Double { Double(state.calTempC) ?? 25 }
|
|
|
|
var body: some View {
|
|
Form {
|
|
inputSection
|
|
resultsSection
|
|
cellConstantSection
|
|
chlorineCalSection
|
|
phCalibrationSection
|
|
}
|
|
.navigationTitle("Calibrate")
|
|
}
|
|
|
|
// MARK: - Inputs
|
|
|
|
private var inputSection: some View {
|
|
Section("Solution Parameters") {
|
|
Stepper("Volume: \(Int(state.calVolumeGal)) gal",
|
|
value: $state.calVolumeGal, in: 5...30, step: 5)
|
|
|
|
HStack {
|
|
Text("NaCl ppm")
|
|
Spacer()
|
|
TextField("ppm", text: $state.calNaclPpm)
|
|
.multilineTextAlignment(.trailing)
|
|
.frame(width: 80)
|
|
#if os(iOS)
|
|
.keyboardType(.decimalPad)
|
|
#endif
|
|
}
|
|
|
|
HStack {
|
|
Text("Free Cl ppm")
|
|
Spacer()
|
|
TextField("ppm", text: $state.calClPpm)
|
|
.multilineTextAlignment(.trailing)
|
|
.frame(width: 80)
|
|
#if os(iOS)
|
|
.keyboardType(.decimalPad)
|
|
#endif
|
|
}
|
|
|
|
HStack {
|
|
Text("Bleach %")
|
|
Spacer()
|
|
TextField("%", text: $state.calBleachPct)
|
|
.multilineTextAlignment(.trailing)
|
|
.frame(width: 80)
|
|
#if os(iOS)
|
|
.keyboardType(.decimalPad)
|
|
#endif
|
|
}
|
|
|
|
HStack {
|
|
Text("Temperature")
|
|
Spacer()
|
|
TextField("\u{00B0}C", text: $state.calTempC)
|
|
.multilineTextAlignment(.trailing)
|
|
.frame(width: 80)
|
|
#if os(iOS)
|
|
.keyboardType(.decimalPad)
|
|
#endif
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Computed results
|
|
|
|
private var resultsSection: some View {
|
|
Section("Preparation") {
|
|
let salt = saltGrams(volumeGal: volumeGal, ppm: naclPpm)
|
|
let tbsp = salt / 17.0
|
|
Text(String(format: "Salt: %.1f g (%.1f tbsp)", salt, tbsp))
|
|
|
|
let bleach = bleachMl(volumeGal: volumeGal, clPpm: clPpm, bleachPct: bleachPct)
|
|
let tsp = bleach / 5.0
|
|
Text(String(format: "Bleach: %.1f mL (%.1f tsp)", bleach, tsp))
|
|
|
|
let kappa = theoreticalConductivity(naclPpm: naclPpm, tempC: tempC)
|
|
Text(String(format: "Theoretical \u{03BA}: %.2f mS/cm at %.0f\u{00B0}C", kappa, tempC))
|
|
}
|
|
}
|
|
|
|
// MARK: - Cell constant from EIS
|
|
|
|
private var cellConstantSection: some View {
|
|
Section("Cell Constant") {
|
|
Button("Calculate K from Last Sweep") {
|
|
guard let rs = extractRs(points: state.eisPoints) else {
|
|
state.status = "No valid EIS data for Rs"
|
|
return
|
|
}
|
|
let kappa = theoreticalConductivity(naclPpm: naclPpm, tempC: tempC)
|
|
let k = cellConstant(kappaMsCm: kappa, rsOhm: Double(rs))
|
|
state.calRs = Double(rs)
|
|
state.calCellConstant = k
|
|
state.send(buildSysexSetCellK(Float(k)))
|
|
state.status = String(format: "K = %.4f cm\u{207B}\u{00B9} (Rs = %.1f \u{2126})", k, rs)
|
|
}
|
|
.disabled(state.eisPoints.isEmpty)
|
|
|
|
if let rs = state.calRs {
|
|
Text(String(format: "Rs: %.1f \u{2126}", rs))
|
|
}
|
|
if let k = state.calCellConstant {
|
|
Text(String(format: "Cell constant K: %.4f cm\u{207B}\u{00B9}", k))
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Chlorine calibration
|
|
|
|
private var chlorineCalSection: some View {
|
|
Section("Chlorine Calibration") {
|
|
if let f = state.clFactor {
|
|
Text(String(format: "Cl factor: %.6f ppm/\u{00B5}A", f))
|
|
}
|
|
if let r = state.clResult {
|
|
Text(String(format: "Last free Cl peak: %.3f \u{00B5}A", r.iFreeUa))
|
|
}
|
|
|
|
HStack {
|
|
Text("Known Cl ppm")
|
|
Spacer()
|
|
TextField("ppm", text: $state.clCalKnownPpm)
|
|
.multilineTextAlignment(.trailing)
|
|
.frame(width: 80)
|
|
#if os(iOS)
|
|
.keyboardType(.decimalPad)
|
|
#endif
|
|
}
|
|
|
|
Button("Set Cl Factor") {
|
|
guard let r = state.clResult else {
|
|
state.status = "No chlorine measurement"
|
|
return
|
|
}
|
|
let knownPpm = Double(state.clCalKnownPpm) ?? 0
|
|
let peak = abs(Double(r.iFreeUa))
|
|
guard peak > 0 else {
|
|
state.status = "Peak current is zero"
|
|
return
|
|
}
|
|
let factor = knownPpm / peak
|
|
state.clFactor = factor
|
|
state.send(buildSysexSetClFactor(Float(factor)))
|
|
state.status = String(format: "Cl factor: %.6f ppm/\u{00B5}A", factor)
|
|
}
|
|
.disabled(state.clResult == nil)
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
Section("pH Calibration (9-point)") {
|
|
if let s = state.phSlope, let o = state.phOffset {
|
|
Text(String(format: "slope: %.4f mV/pH offset: %.1f mV", s, o))
|
|
}
|
|
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 {
|
|
Text("Stabilize (s)")
|
|
Spacer()
|
|
TextField("120", text: $state.phCalStabilize)
|
|
.multilineTextAlignment(.trailing)
|
|
.frame(width: 80)
|
|
#if os(iOS)
|
|
.keyboardType(.decimalPad)
|
|
#endif
|
|
}
|
|
|
|
HStack {
|
|
Button("Measure") {
|
|
state.phCalStartMeasurement()
|
|
}
|
|
.disabled(state.phCalMeasuring)
|
|
|
|
Spacer()
|
|
|
|
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)
|
|
.foregroundStyle(.quaternary)
|
|
}
|
|
}
|
|
|
|
// MARK: - Calculations
|
|
|
|
private func saltGrams(volumeGal: Double, ppm: Double) -> Double {
|
|
let liters = volumeGal * 3.78541
|
|
return ppm * liters / 1000.0
|
|
}
|
|
|
|
private func bleachMl(volumeGal: Double, clPpm: Double, bleachPct: Double) -> Double {
|
|
let liters = volumeGal * 3.78541
|
|
let clNeededMg = clPpm * liters
|
|
let bleachMgPerMl = bleachPct * 10.0
|
|
return clNeededMg / bleachMgPerMl
|
|
}
|
|
|
|
private func theoreticalConductivity(naclPpm: Double, tempC: Double) -> Double {
|
|
let kappa25 = naclPpm * 2.0 / 1000.0
|
|
return kappa25 * (1.0 + 0.0212 * (tempC - 25.0))
|
|
}
|
|
|
|
private func extractRs(points: [EisPoint]) -> Float? {
|
|
points.map(\.zReal).filter { $0.isFinite && $0 > 0 }.min()
|
|
}
|
|
|
|
private func cellConstant(kappaMsCm: Double, rsOhm: Double) -> Double {
|
|
(kappaMsCm / 1000.0) * rsOhm
|
|
}
|
|
}
|