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 var phCalibrationSection: some View { Section("pH Calibration (Q/HQ peak-shift)") { if let s = state.phSlope, let o = state.phOffset { Text(String(format: "slope: %.4f mV/pH offset: %.4f mV", s, o)) if let peak = detectQhqPeak(state.lsvPoints) { if abs(s) > 1e-6 { let ph = (Double(peak) - o) / s Text(String(format: "Computed pH: %.2f (peak at %.1f mV)", ph, peak)) } } } HStack { Text("Known pH") Spacer() TextField("7.00", text: $state.phCalKnown) .multilineTextAlignment(.trailing) .frame(width: 80) #if os(iOS) .keyboardType(.decimalPad) #endif } Button("Add Calibration Point") { guard let peak = detectQhqPeak(state.lsvPoints) else { state.status = "No Q/HQ peak found in LSV data" return } guard let ph = Double(state.phCalKnown) else { return } state.phCalPoints.append((ph: ph, mV: Double(peak))) state.status = String(format: "pH cal point: pH=%.2f peak=%.1f mV (%d pts)", ph, peak, state.phCalPoints.count) } .disabled(state.lsvPoints.isEmpty) ForEach(Array(state.phCalPoints.enumerated()), id: \.offset) { i, pt in Text(String(format: "%d. pH=%.2f peak=%.1f mV", i + 1, pt.ph, pt.mV)) .font(.caption) } Button("Clear Points") { state.phCalPoints.removeAll() state.status = "pH cal points cleared" } .disabled(state.phCalPoints.isEmpty) Button("Compute & Set pH Cal") { let pts = state.phCalPoints guard pts.count >= 2 else { state.status = "Need at least 2 calibration points" return } let n = Double(pts.count) let meanPh = pts.map(\.ph).reduce(0, +) / n let meanV = pts.map(\.mV).reduce(0, +) / n let num = pts.map { ($0.ph - meanPh) * ($0.mV - meanV) }.reduce(0, +) let den = pts.map { ($0.ph - meanPh) * ($0.ph - meanPh) }.reduce(0, +) guard abs(den) > 1e-12 else { state.status = "Degenerate calibration data" return } let slope = num / den let offset = meanV - slope * meanPh state.phSlope = slope state.phOffset = offset state.send(buildSysexSetPhCal(Float(slope), Float(offset))) state.status = String(format: "pH cal set: slope=%.4f offset=%.4f", slope, offset) } .disabled(state.phCalPoints.count < 2) } } // 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 } }