From 0ff291549e114dfd2fc4ecf7b6a64871974ef85a Mon Sep 17 00:00:00 2001 From: jess Date: Tue, 31 Mar 2026 20:42:39 -0700 Subject: [PATCH] cue-ios: calibration calculator -- solution prep, conductivity, cell constant from EIS --- cue-ios/CueIOS/AppState.swift | 16 ++- cue-ios/CueIOS/Views/CalibrateView.swift | 143 +++++++++++++++++++++++ cue-ios/CueIOS/Views/ContentView.swift | 8 ++ 3 files changed, 164 insertions(+), 3 deletions(-) create mode 100644 cue-ios/CueIOS/Views/CalibrateView.swift diff --git a/cue-ios/CueIOS/AppState.swift b/cue-ios/CueIOS/AppState.swift index d0ba5da..5ea4b21 100644 --- a/cue-ios/CueIOS/AppState.swift +++ b/cue-ios/CueIOS/AppState.swift @@ -8,6 +8,7 @@ enum Tab: String, CaseIterable, Identifiable { case amp = "Amperometry" case chlorine = "Chlorine" case ph = "pH" + case calibrate = "Calibrate" case sessions = "Sessions" case connection = "Connection" @@ -84,6 +85,15 @@ final class AppState { // Session var currentSessionId: Int64? = nil + // Calibration + var calVolumeGal: Double = 25 + var calNaclPpm: String = "2500" + var calClPpm: String = "5" + var calBleachPct: String = "7.825" + var calTempC: String = "40" + var calCellConstant: Double? = nil + var calRs: Double? = nil + // Clean var cleanV: String = "1200" var cleanDur: String = "30" @@ -361,7 +371,7 @@ final class AppState { case .amp: ampRef = nil; status = "Amp reference cleared" case .chlorine: clRef = nil; status = "Chlorine reference cleared" case .ph: phRef = nil; status = "pH reference cleared" - case .sessions, .connection: break + case .calibrate, .sessions, .connection: break } } @@ -402,7 +412,7 @@ final class AppState { case .amp: ampRef != nil case .chlorine: clRef != nil case .ph: phRef != nil - case .sessions, .connection: false + case .calibrate, .sessions, .connection: false } } @@ -413,7 +423,7 @@ final class AppState { case .amp: !ampPoints.isEmpty case .chlorine: clResult != nil case .ph: phResult != nil - case .sessions, .connection: false + case .calibrate, .sessions, .connection: false } } diff --git a/cue-ios/CueIOS/Views/CalibrateView.swift b/cue-ios/CueIOS/Views/CalibrateView.swift new file mode 100644 index 0000000..16acea2 --- /dev/null +++ b/cue-ios/CueIOS/Views/CalibrateView.swift @@ -0,0 +1,143 @@ +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 + } + .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.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: - 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 + } +} diff --git a/cue-ios/CueIOS/Views/ContentView.swift b/cue-ios/CueIOS/Views/ContentView.swift index 9ca81a4..36f4fe3 100644 --- a/cue-ios/CueIOS/Views/ContentView.swift +++ b/cue-ios/CueIOS/Views/ContentView.swift @@ -38,6 +38,9 @@ struct ContentView: View { sidebarButton(.chlorine, "Chlorine", "drop.fill") sidebarButton(.ph, "pH", "scalemass") } + Section("Tools") { + sidebarButton(.calibrate, "Calibrate", "lines.measurement.horizontal") + } Section("Data") { sidebarButton(.sessions, "Sessions", "folder") sidebarButton(.connection, "Connection", "antenna.radiowaves.left.and.right") @@ -124,6 +127,10 @@ struct ContentView: View { .tabItem { Label("pH", systemImage: "scalemass") } .tag(Tab.ph) + CalibrateView(state: state) + .tabItem { Label("Calibrate", systemImage: "lines.measurement.horizontal") } + .tag(Tab.calibrate) + SessionView(state: state) .tabItem { Label("Sessions", systemImage: "folder") } .tag(Tab.sessions) @@ -144,6 +151,7 @@ struct ContentView: View { case .amp: AmpView(state: state) case .chlorine: ChlorineView(state: state) case .ph: PhView(state: state) + case .calibrate: CalibrateView(state: state) case .sessions: SessionView(state: state) case .connection: ConnectionView(state: state) }