From ce1a413bae4b8bd913dd99473028f45a1d8c35a0 Mon Sep 17 00:00:00 2001 From: jess Date: Wed, 10 Jun 2026 03:35:24 -0700 Subject: [PATCH] new electrode array --- cue-ios/CueIOS/AppState.swift | 73 +++++++ cue-ios/CueIOS/Models/DataTypes.swift | 31 +++ cue-ios/CueIOS/Models/Protocol.swift | 72 +++++++ cue-ios/CueIOS/Transport/UDPManager.swift | 1 + cue-ios/CueIOS/Views/CalibrateView.swift | 80 +++++++ cue-ios/CueIOS/Views/PhView.swift | 42 +++- cue/src/app.rs | 242 ++++++++++++++++++++-- cue/src/protocol.rs | 112 ++++++++++ main/echem.c | 229 ++++++++++++++++++-- main/echem.h | 61 ++++++ main/eis.c | 197 +++++++++++++----- main/eis.h | 13 ++ main/eis4.c | 94 ++++++++- main/protocol.c | 63 ++++++ main/protocol.h | 18 ++ main/wifi_transport.c | 16 ++ 16 files changed, 1246 insertions(+), 98 deletions(-) diff --git a/cue-ios/CueIOS/AppState.swift b/cue-ios/CueIOS/AppState.swift index 78d6822..efe1c5b 100644 --- a/cue-ios/CueIOS/AppState.swift +++ b/cue-ios/CueIOS/AppState.swift @@ -84,6 +84,21 @@ final class AppState { // pH var phResult: PhResult? = nil var phStabilize: String = "30" + var phVoltResult: PhVoltResult? = nil + var phIdeality: PhIdeality? = nil + + // pH scan settings + var phScanVOxideMv: String = "1100" + var phScanTOxideMs: String = "2000" + var phScanVStart: String = "600" + var phScanVStop: String = "-200" + var phScanRate: String = "100" + var phScanStabilizeMs: String = "500" + var phScanMinPromUa: String = "0.05" + var phScanVClampMinMv: String = "-400" + var phScanVClampMaxMv: String = "1200" + var phScanLpRtia: LpRtia = .r10K + var phScanCfg: PhScanCfg? = nil // ORP var orpResult: OrpResult? = nil @@ -316,6 +331,34 @@ final class AppState { orpResult = r } + case .phVoltResult(let r): + transport.measuring = false + phVoltResult = r + if r.hasPeak { + status = String(format: "pH (volt): %.2f (peak=%.1f mV, %.3f uA, conf=%.2f)", + r.ph, r.peakMv, r.peakIUa, r.confidence) + } else { + status = "pH (volt): no peak detected" + } + + case .phIdeality(let r): + phIdeality = r + status = String(format: "pH ideality: slope=%.1f%% r2=%.4f", r.slopePct, r.r2) + + case .phScan(let cfg): + phScanCfg = cfg + phScanVOxideMv = String(format: "%.0f", cfg.vOxideMv) + phScanTOxideMs = String(format: "%.0f", cfg.tOxideMs) + phScanVStart = String(format: "%.0f", cfg.vStart) + phScanVStop = String(format: "%.0f", cfg.vStop) + phScanRate = String(format: "%.0f", cfg.scanRate) + phScanStabilizeMs = String(format: "%.0f", cfg.stabilizeMs) + phScanMinPromUa = String(format: "%.3f", cfg.minPromUa) + phScanVClampMinMv = String(format: "%.0f", cfg.vClampMinMv) + phScanVClampMaxMv = String(format: "%.0f", cfg.vClampMaxMv) + phScanLpRtia = LpRtia(rawValue: cfg.lpRtia) ?? .r10K + status = "pH scan config received" + case .temperature(let t): tempC = t @@ -570,6 +613,36 @@ final class AppState { send(buildSysexStartPh(stabilizeS: stab)) } + func startPhVolt() { + phVoltResult = nil + lsvPoints.removeAll() + transport.measuring = true + send(buildSysexGetTemp()) + send(buildSysexStartPhVolt()) + } + + func getPhIdeality() { + send(buildSysexGetPhIdeality()) + } + + func savePhScan() { + let cfg = PhScanCfg( + vOxideMv: Float(phScanVOxideMv) ?? 1100, + tOxideMs: Float(phScanTOxideMs) ?? 2000, + vStart: Float(phScanVStart) ?? 600, + vStop: Float(phScanVStop) ?? -200, + scanRate: Float(phScanRate) ?? 100, + stabilizeMs: Float(phScanStabilizeMs) ?? 500, + minPromUa: Float(phScanMinPromUa) ?? 0.05, + vClampMinMv: Float(phScanVClampMinMv) ?? -400, + vClampMaxMv: Float(phScanVClampMaxMv) ?? 1200, + lpRtia: phScanLpRtia.rawValue + ) + phScanCfg = cfg + send(buildSysexSetPhScan(cfg)) + status = "pH scan settings saved" + } + func startOrp() { orpResult = nil transport.measuring = true diff --git a/cue-ios/CueIOS/Models/DataTypes.swift b/cue-ios/CueIOS/Models/DataTypes.swift index 4dc36d4..89c7e47 100644 --- a/cue-ios/CueIOS/Models/DataTypes.swift +++ b/cue-ios/CueIOS/Models/DataTypes.swift @@ -71,6 +71,37 @@ struct OrpResult: Codable { var tempC: Float } +struct PhVoltResult: Codable { + var peakMv: Float + var peakIUa: Float + var ph: Float + var tempC: Float + var confidence: Float + var hasPeak: Bool +} + +struct PhIdeality: Codable { + var slopePct: Float + var r2: Float + var zeroOffsetMv: Float + var phIso: Float + var eIso: Float + var isoValid: Bool +} + +struct PhScanCfg: Codable { + var vOxideMv: Float + var tOxideMs: Float + var vStart: Float + var vStop: Float + var scanRate: Float + var stabilizeMs: Float + var minPromUa: Float + var vClampMinMv: Float + var vClampMaxMv: Float + var lpRtia: UInt8 +} + struct OrpSample: Identifiable { let id = UUID() let tS: Float diff --git a/cue-ios/CueIOS/Models/Protocol.swift b/cue-ios/CueIOS/Models/Protocol.swift index dc81456..a2dd90a 100644 --- a/cue-ios/CueIOS/Models/Protocol.swift +++ b/cue-ios/CueIOS/Models/Protocol.swift @@ -26,6 +26,9 @@ let RSP_PH_RESULT: UInt8 = 0x0F let RSP_TEMP: UInt8 = 0x10 let RSP_CELL_K: UInt8 = 0x11 let RSP_ORP_RESULT: UInt8 = 0x12 +let RSP_PH_VOLT_RESULT: UInt8 = 0x13 +let RSP_PH_IDEALITY: UInt8 = 0x14 +let RSP_PH_SCAN: UInt8 = 0x15 let RSP_REF_FRAME: UInt8 = 0x20 let RSP_REF_LP_RANGE: UInt8 = 0x21 let RSP_REFS_DONE: UInt8 = 0x22 @@ -54,6 +57,9 @@ let CMD_STOP_AMP: UInt8 = 0x22 let CMD_START_CL: UInt8 = 0x23 let CMD_START_PH: UInt8 = 0x24 let CMD_START_CLEAN: UInt8 = 0x25 +let CMD_SET_PH_SCAN: UInt8 = 0x16 +let CMD_GET_PH_SCAN: UInt8 = 0x19 +let CMD_START_PH_VOLT: UInt8 = 0x18 let CMD_START_ORP: UInt8 = 0x2A let CMD_SET_CELL_K: UInt8 = 0x28 let CMD_GET_CELL_K: UInt8 = 0x29 @@ -63,6 +69,7 @@ let CMD_GET_PH_CAL: UInt8 = 0x36 let CMD_PH_CAL_POINT: UInt8 = 0x37 let CMD_PH_CAL_CLEAR: UInt8 = 0x38 let CMD_PH_CAL_STATUS: UInt8 = 0x39 +let CMD_GET_PH_IDEALITY: UInt8 = 0x35 let CMD_START_REFS: UInt8 = 0x30 let CMD_GET_REFS: UInt8 = 0x31 let CMD_CLEAR_REFS: UInt8 = 0x32 @@ -164,6 +171,9 @@ enum EisMessage { case clEnd case phResult(PhResult) case orpResult(OrpResult) + case phVoltResult(PhVoltResult) + case phIdeality(PhIdeality) + case phScan(PhScanCfg) case temperature(Float) case refFrame(mode: UInt8, rtiaIdx: UInt8) case refLpRange(mode: UInt8, lowIdx: UInt8, highIdx: UInt8) @@ -314,6 +324,40 @@ func parseSysex(_ data: [UInt8]) -> EisMessage? { tempC: decodeFloat(p, at: 5) )) + case RSP_PH_VOLT_RESULT where p.count >= 33: + return .phVoltResult(PhVoltResult( + peakMv: decodeFloat(p, at: 0), + peakIUa: decodeFloat(p, at: 5), + ph: decodeFloat(p, at: 10), + tempC: decodeFloat(p, at: 15), + confidence: decodeFloat(p, at: 20), + hasPeak: p[25] != 0 + )) + + case RSP_PH_IDEALITY where p.count >= 26: + return .phIdeality(PhIdeality( + slopePct: decodeFloat(p, at: 0), + r2: decodeFloat(p, at: 5), + zeroOffsetMv: decodeFloat(p, at: 10), + phIso: decodeFloat(p, at: 15), + eIso: decodeFloat(p, at: 20), + isoValid: p[25] != 0 + )) + + case RSP_PH_SCAN where p.count >= 46: + return .phScan(PhScanCfg( + vOxideMv: decodeFloat(p, at: 0), + tOxideMs: decodeFloat(p, at: 5), + vStart: decodeFloat(p, at: 10), + vStop: decodeFloat(p, at: 15), + scanRate: decodeFloat(p, at: 20), + stabilizeMs: decodeFloat(p, at: 25), + minPromUa: decodeFloat(p, at: 30), + vClampMinMv: decodeFloat(p, at: 35), + vClampMaxMv: decodeFloat(p, at: 40), + lpRtia: p[45] + )) + case RSP_TEMP where p.count >= 5: return .temperature(decodeFloat(p, at: 0)) @@ -498,6 +542,34 @@ func buildSysexStartOrp(stabilizeS: Float) -> [UInt8] { return sx } +func buildSysexStartPhVolt() -> [UInt8] { + [0xF0, sysexMfr, CMD_START_PH_VOLT, 0xF7] +} + +func buildSysexGetPhIdeality() -> [UInt8] { + [0xF0, sysexMfr, CMD_GET_PH_IDEALITY, 0xF7] +} + +func buildSysexGetPhScan() -> [UInt8] { + [0xF0, sysexMfr, CMD_GET_PH_SCAN, 0xF7] +} + +func buildSysexSetPhScan(_ cfg: PhScanCfg) -> [UInt8] { + var sx: [UInt8] = [0xF0, sysexMfr, CMD_SET_PH_SCAN] + sx.append(contentsOf: encodeFloat(cfg.vOxideMv)) + sx.append(contentsOf: encodeFloat(cfg.tOxideMs)) + sx.append(contentsOf: encodeFloat(cfg.vStart)) + sx.append(contentsOf: encodeFloat(cfg.vStop)) + sx.append(contentsOf: encodeFloat(cfg.scanRate)) + sx.append(contentsOf: encodeFloat(cfg.stabilizeMs)) + sx.append(contentsOf: encodeFloat(cfg.minPromUa)) + sx.append(contentsOf: encodeFloat(cfg.vClampMinMv)) + sx.append(contentsOf: encodeFloat(cfg.vClampMaxMv)) + sx.append(cfg.lpRtia & 0x7F) + sx.append(0xF7) + return sx +} + func buildSysexStartClean(vMv: Float, durationS: Float) -> [UInt8] { var sx: [UInt8] = [0xF0, sysexMfr, CMD_START_CLEAN] sx.append(contentsOf: encodeFloat(vMv)) diff --git a/cue-ios/CueIOS/Transport/UDPManager.swift b/cue-ios/CueIOS/Transport/UDPManager.swift index 1c46cfa..9764e4c 100644 --- a/cue-ios/CueIOS/Transport/UDPManager.swift +++ b/cue-ios/CueIOS/Transport/UDPManager.swift @@ -109,6 +109,7 @@ final class UDPManager: @unchecked Sendable { send(buildSysexGetClFactor()) send(buildSysexGetPhCal()) send(buildSysexPhCalStatus()) + send(buildSysexGetPhScan()) startTimers() receiveLoop() diff --git a/cue-ios/CueIOS/Views/CalibrateView.swift b/cue-ios/CueIOS/Views/CalibrateView.swift index 5830a41..9df02f1 100644 --- a/cue-ios/CueIOS/Views/CalibrateView.swift +++ b/cue-ios/CueIOS/Views/CalibrateView.swift @@ -16,6 +16,8 @@ struct CalibrateView: View { cellConstantSection chlorineCalSection phCalibrationSection + phScanSettingsSection + phIdealitySection } .navigationTitle("Calibrate") } @@ -176,6 +178,10 @@ struct CalibrateView: View { Text(String(format: "temp slope hot: %.6f", th)) } + Text("cell values = reduction peak (mV)") + .font(.caption2) + .foregroundStyle(.secondary) + phCalGridView Picker("Buffer", selection: $state.phCalSelectedBuf) { @@ -274,6 +280,80 @@ struct CalibrateView: View { } } + // MARK: - pH scan settings + + private var phScanSettingsSection: some View { + Section("pH Scan Settings") { + scanField("Oxide V (mV)", text: $state.phScanVOxideMv) + scanField("Oxide t (ms)", text: $state.phScanTOxideMs) + scanField("V start (mV)", text: $state.phScanVStart) + scanField("V stop (mV)", text: $state.phScanVStop) + scanField("Scan rate (mV/s)", text: $state.phScanRate) + scanField("Stabilize (ms)", text: $state.phScanStabilizeMs) + scanField("Min prominence (\u{00B5}A)", text: $state.phScanMinPromUa) + scanField("Clamp min (mV)", text: $state.phScanVClampMinMv) + scanField("Clamp max (mV)", text: $state.phScanVClampMaxMv) + + Picker("LP RTIA", selection: $state.phScanLpRtia) { + ForEach(LpRtia.allCases) { r in + Text(r.label).tag(r) + } + } + + Button("Save") { + state.savePhScan() + } + } + } + + private func scanField(_ label: String, text: Binding) -> some View { + HStack { + Text(label) + Spacer() + TextField(label, text: text) + .multilineTextAlignment(.trailing) + .frame(width: 80) + #if os(iOS) + .keyboardType(.numbersAndPunctuation) + #endif + } + } + + // MARK: - pH ideality + + private var phIdealitySection: some View { + Section("pH Ideality") { + Button("Read Ideality") { + state.getPhIdeality() + } + + if let i = state.phIdeality { + let slopeGood = i.slopePct >= 85 && i.slopePct <= 102 + HStack { + Text(String(format: "slope: %.1f%%", i.slopePct)) + Spacer() + Text(slopeGood ? "good" : "check") + .foregroundStyle(slopeGood ? .green : .orange) + } + let r2Good = i.r2 > 0.999 + HStack { + Text(String(format: "r\u{00B2}: %.5f", i.r2)) + Spacer() + Text(r2Good ? "good" : "check") + .foregroundStyle(r2Good ? .green : .orange) + } + Text(String(format: "zero offset: %.1f mV", i.zeroOffsetMv)) + if i.isoValid { + Text(String(format: "isopotential: pH %.2f, %.1f mV", i.phIso, i.eIso)) + .foregroundStyle(abs(i.phIso - 7) < 0.5 ? .green : .primary) + } else { + Text("isopotential: invalid") + .foregroundStyle(.orange) + } + } + } + } + // MARK: - Calculations private func saltGrams(volumeGal: Double, ppm: Double) -> Double { diff --git a/cue-ios/CueIOS/Views/PhView.swift b/cue-ios/CueIOS/Views/PhView.swift index 64289cf..24b601f 100644 --- a/cue-ios/CueIOS/Views/PhView.swift +++ b/cue-ios/CueIOS/Views/PhView.swift @@ -22,6 +22,9 @@ struct PhView: View { Button("Measure pH") { state.startPh() } .buttonStyle(ActionButtonStyle(color: .green)) + Button("Measure pH (volt)") { state.startPhVolt() } + .buttonStyle(ActionButtonStyle(color: .blue)) + Spacer() } .padding(.horizontal) @@ -32,10 +35,43 @@ struct PhView: View { @ViewBuilder private var phBody: some View { + VStack(alignment: .leading, spacing: 16) { + if let v = state.phVoltResult { + phVoltSection(v) + } + ocpSection + } + } + + @ViewBuilder + private func phVoltSection(_ v: PhVoltResult) -> some View { + VStack(alignment: .leading, spacing: 8) { + if v.hasPeak { + Text(String(format: "pH (volt): %.2f", v.ph)) + .font(.system(size: 48, weight: .bold, design: .rounded)) + .foregroundStyle(.primary) + + Text(String(format: "peak: %.1f mV | %.3f \u{00B5}A | confidence: %.2f | Temp: %.1f\u{00B0}C", + v.peakMv, v.peakIUa, v.confidence, v.tempC)) + .font(.subheadline) + .foregroundStyle(.secondary) + } else { + Text("pH (volt): no peak detected") + .font(.title3.bold()) + .foregroundStyle(.orange) + Text("Scan completed but no reduction peak was found. Check the scan range and minimum prominence.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + @ViewBuilder + private var ocpSection: some View { if let r = state.phResult { VStack(alignment: .leading, spacing: 8) { - Text(String(format: "pH: %.2f", r.ph)) - .font(.system(size: 48, weight: .bold, design: .rounded)) + Text(String(format: "pH (OCP): %.2f", r.ph)) + .font(.title2.weight(.semibold)) .foregroundStyle(.primary) let nernstSlope = 0.1984 * (Double(r.tempC) + 273.15) @@ -53,7 +89,7 @@ struct PhView: View { .foregroundStyle(.secondary) } } - } else { + } else if state.phVoltResult == nil { VStack(alignment: .leading, spacing: 8) { Text("No measurement yet") .font(.title3) diff --git a/cue/src/app.rs b/cue/src/app.rs index f0c4a4b..d3c4497 100644 --- a/cue/src/app.rs +++ b/cue/src/app.rs @@ -12,7 +12,7 @@ use tokio::sync::mpsc; use crate::native_menu::{MenuAction, NativeMenu}; use crate::protocol::{ self, AmpPoint, ClPoint, ClResult, Electrode, EisMessage, EisPoint, LpRtia, LsvPoint, - OrpResult, OrpSample, PhResult, Rcal, Rtia, + OrpResult, OrpSample, PhIdeality, PhResult, PhScanCfg, PhVoltResult, Rcal, Rtia, }; use crate::storage::{self, Session, Storage}; use crate::udp::UdpEvent; @@ -122,6 +122,19 @@ pub enum Message { /* pH */ PhStabilizeChanged(String), StartPh, + StartPhVolt, + GetPhIdeality, + SavePhScan, + PhScanVOxideChanged(String), + PhScanTOxideChanged(String), + PhScanVStartChanged(String), + PhScanVStopChanged(String), + PhScanRateChanged(String), + PhScanStabilizeChanged(String), + PhScanMinPromChanged(String), + PhScanVClampMinChanged(String), + PhScanVClampMaxChanged(String), + PhScanRtiaSelected(LpRtia), /* ORP */ OrpStabilizeChanged(String), StartOrp, @@ -261,6 +274,18 @@ pub struct App { /* pH */ ph_result: Option, ph_stabilize: String, + ph_volt_result: Option, + ph_ideality: Option, + ph_scan_lp_rtia: LpRtia, + ph_scan_v_oxide: String, + ph_scan_t_oxide: String, + ph_scan_v_start: String, + ph_scan_v_stop: String, + ph_scan_rate: String, + ph_scan_stabilize: String, + ph_scan_min_prom: String, + ph_scan_v_clamp_min: String, + ph_scan_v_clamp_max: String, /* ORP */ orp_result: Option, @@ -524,6 +549,18 @@ impl App { ph_result: None, ph_stabilize: "30".into(), + ph_volt_result: None, + ph_ideality: None, + ph_scan_lp_rtia: LpRtia::R10K, + ph_scan_v_oxide: "800".into(), + ph_scan_t_oxide: "2000".into(), + ph_scan_v_start: "0".into(), + ph_scan_v_stop: "-600".into(), + ph_scan_rate: "50".into(), + ph_scan_stabilize: "2000".into(), + ph_scan_min_prom: "0.5".into(), + ph_scan_v_clamp_min: "-800".into(), + ph_scan_v_clamp_max: "800".into(), orp_result: None, orp_stabilize: "30".into(), @@ -688,6 +725,7 @@ impl App { self.send_cmd(&protocol::build_sysex_get_cl_factor()); self.send_cmd(&protocol::build_sysex_get_ph_cal()); self.send_cmd(&protocol::build_sysex_ph_cal_status()); + self.send_cmd(&protocol::build_sysex_get_ph_scan()); } Message::DeviceStatus(s) => { if s.contains("Reconnecting") || s.contains("Connecting") { @@ -865,6 +903,32 @@ impl App { self.ph_result = Some(r); } } + EisMessage::PhVoltResult(r, _esp_ts, _) => { + if r.has_peak { + self.status = format!("pH (volt): {:.2} peak={:.1} mV, {:.3} uA, conf={:.0}%", + r.ph, r.peak_mv, r.peak_i_ua, r.confidence * 100.0); + } else { + self.status = "pH (volt): no reduction peak detected".into(); + } + self.ph_volt_result = Some(r); + } + EisMessage::PhIdeality(i) => { + self.status = format!("pH ideality: slope={:.1}% r2={:.4}", i.slope_pct, i.r2); + self.ph_ideality = Some(i); + } + EisMessage::PhScan(cfg) => { + self.ph_scan_v_oxide = format!("{:.0}", cfg.v_oxide_mv); + self.ph_scan_t_oxide = format!("{:.0}", cfg.t_oxide_ms); + self.ph_scan_v_start = format!("{:.0}", cfg.v_start); + self.ph_scan_v_stop = format!("{:.0}", cfg.v_stop); + self.ph_scan_rate = format!("{:.0}", cfg.scan_rate); + self.ph_scan_stabilize = format!("{:.0}", cfg.stabilize_ms); + self.ph_scan_min_prom = format!("{:.2}", cfg.min_prom_ua); + self.ph_scan_v_clamp_min = format!("{:.0}", cfg.v_clamp_min_mv); + self.ph_scan_v_clamp_max = format!("{:.0}", cfg.v_clamp_max_mv); + self.ph_scan_lp_rtia = LpRtia::from_byte(cfg.lp_rtia).unwrap_or(LpRtia::R10K); + self.status = "pH scan config received".into(); + } EisMessage::OrpResult(r, esp_ts, _) => { if self.collecting_refs { self.orp_ref = Some(r); @@ -937,15 +1001,15 @@ impl App { self.ph_offset = Some(offset); self.status = format!("pH cal: slope={:.4} offset={:.4}", slope, offset); } - EisMessage::PhCalPoint { buf, tslot, ocp_mv, temp_c, buffer_ph: _, baseline_count } => { + EisMessage::PhCalPoint { buf, tslot, ocp_mv: peak_mv, temp_c, buffer_ph: _, baseline_count } => { let bi = (buf as usize).min(2); let ti = (tslot as usize).min(2); - self.ph_cal_grid[bi][ti] = Some((ocp_mv, temp_c)); + self.ph_cal_grid[bi][ti] = Some((peak_mv, temp_c)); self.ph_cal_baseline_count = baseline_count; self.ph_cal_measuring = false; self.ph_cal_valid_mask |= 1 << (bi * 3 + ti); - self.status = format!("pH cal point [{},{}]: {:.1} mV, {:.1} C", - bi, ti, ocp_mv, temp_c); + self.status = format!("pH cal point [{},{}]: peak {:.1} mV, {:.1} C", + bi, ti, peak_mv, temp_c); } EisMessage::PhCalStatus { valid_mask, slope, offset, temp_slope_cold, temp_slope_hot } => { self.ph_cal_valid_mask = valid_mask; @@ -1101,6 +1165,41 @@ impl App { self.send_cmd(&protocol::build_sysex_get_temp()); self.send_cmd(&protocol::build_sysex_start_ph(stab)); } + Message::StartPhVolt => { + self.send_cmd(&protocol::build_sysex_get_temp()); + self.send_cmd(&protocol::build_sysex_start_ph_volt()); + self.status = "pH (volt): scanning...".into(); + } + Message::GetPhIdeality => { + self.send_cmd(&protocol::build_sysex_get_ph_ideality()); + } + Message::SavePhScan => { + let cfg = PhScanCfg { + v_oxide_mv: self.ph_scan_v_oxide.parse().unwrap_or(800.0), + t_oxide_ms: self.ph_scan_t_oxide.parse().unwrap_or(2000.0), + v_start: self.ph_scan_v_start.parse().unwrap_or(0.0), + v_stop: self.ph_scan_v_stop.parse().unwrap_or(-600.0), + scan_rate: self.ph_scan_rate.parse().unwrap_or(50.0), + stabilize_ms: self.ph_scan_stabilize.parse().unwrap_or(2000.0), + min_prom_ua: self.ph_scan_min_prom.parse().unwrap_or(0.5), + v_clamp_min_mv: self.ph_scan_v_clamp_min.parse().unwrap_or(-800.0), + v_clamp_max_mv: self.ph_scan_v_clamp_max.parse().unwrap_or(800.0), + lp_rtia: self.ph_scan_lp_rtia.as_byte(), + }; + self.send_cmd(&protocol::build_sysex_set_ph_scan(&cfg)); + self.send_cmd(&protocol::build_sysex_get_ph_scan()); + self.status = "pH scan config saved".into(); + } + Message::PhScanVOxideChanged(s) => self.ph_scan_v_oxide = s, + Message::PhScanTOxideChanged(s) => self.ph_scan_t_oxide = s, + Message::PhScanVStartChanged(s) => self.ph_scan_v_start = s, + Message::PhScanVStopChanged(s) => self.ph_scan_v_stop = s, + Message::PhScanRateChanged(s) => self.ph_scan_rate = s, + Message::PhScanStabilizeChanged(s) => self.ph_scan_stabilize = s, + Message::PhScanMinPromChanged(s) => self.ph_scan_min_prom = s, + Message::PhScanVClampMinChanged(s) => self.ph_scan_v_clamp_min = s, + Message::PhScanVClampMaxChanged(s) => self.ph_scan_v_clamp_max = s, + Message::PhScanRtiaSelected(r) => self.ph_scan_lp_rtia = r, /* ORP */ Message::OrpStabilizeChanged(s) => self.orp_stabilize = s, Message::StartOrp => { @@ -1922,6 +2021,10 @@ impl App { .style(style_action()) .padding([6, 16]) .on_press(Message::StartPh), + button(text("Measure pH (volt)").size(13)) + .style(style_action()) + .padding([6, 16]) + .on_press(Message::StartPhVolt), ] .spacing(10) .align_y(iced::Alignment::End) @@ -2165,6 +2268,7 @@ impl App { /* pH calibration (9-point) */ results = results.push(iced::widget::horizontal_rule(1)); results = results.push(text("pH Calibration (9-point)").size(16)); + results = results.push(text("cells show reduction peak (mV) and temp").size(12)); if let (Some(s), Some(o)) = (self.ph_slope, self.ph_offset) { results = results.push(text(format!("slope: {:.4} mV/pH offset: {:.4} mV", s, o)).size(14)); @@ -2192,8 +2296,8 @@ impl App { for bi in 0..3usize { let bit = bi * 3 + ti; let valid = self.ph_cal_valid_mask & (1 << bit) != 0; - let cell_text = if let Some((ocp, tc)) = self.ph_cal_grid[bi][ti] { - format!("{:.1} mV\n{:.1} C", ocp, tc) + let cell_text = if let Some((peak, tc)) = self.ph_cal_grid[bi][ti] { + format!("{:.1} mV\n{:.1} C", peak, tc) } else if valid { "cal'd".into() } else { @@ -2278,10 +2382,96 @@ impl App { row![measure_btn, clear_sel_btn, clear_all_btn].spacing(10) ); + /* pH scan settings (voltammetric) */ + results = results.push(iced::widget::horizontal_rule(1)); + results = results.push(text("pH Scan Settings").size(16)); + results = results.push( + row![ + column![ + text("Oxide mV").size(12), + text_input("800", &self.ph_scan_v_oxide) + .on_input(Message::PhScanVOxideChanged).width(70), + ].spacing(2), + column![ + text("Oxide ms").size(12), + text_input("2000", &self.ph_scan_t_oxide) + .on_input(Message::PhScanTOxideChanged).width(70), + ].spacing(2), + column![ + text("Start mV").size(12), + text_input("0", &self.ph_scan_v_start) + .on_input(Message::PhScanVStartChanged).width(70), + ].spacing(2), + column![ + text("Stop mV").size(12), + text_input("-600", &self.ph_scan_v_stop) + .on_input(Message::PhScanVStopChanged).width(70), + ].spacing(2), + column![ + text("Scan mV/s").size(12), + text_input("50", &self.ph_scan_rate) + .on_input(Message::PhScanRateChanged).width(70), + ].spacing(2), + ].spacing(10).align_y(iced::Alignment::End) + ); + results = results.push( + row![ + column![ + text("Stabilize ms").size(12), + text_input("2000", &self.ph_scan_stabilize) + .on_input(Message::PhScanStabilizeChanged).width(70), + ].spacing(2), + column![ + text("Min prom uA").size(12), + text_input("0.5", &self.ph_scan_min_prom) + .on_input(Message::PhScanMinPromChanged).width(70), + ].spacing(2), + column![ + text("Clamp min mV").size(12), + text_input("-800", &self.ph_scan_v_clamp_min) + .on_input(Message::PhScanVClampMinChanged).width(70), + ].spacing(2), + column![ + text("Clamp max mV").size(12), + text_input("800", &self.ph_scan_v_clamp_max) + .on_input(Message::PhScanVClampMaxChanged).width(70), + ].spacing(2), + column![ + text("RTIA").size(12), + pick_list(LpRtia::ALL, Some(self.ph_scan_lp_rtia), Message::PhScanRtiaSelected).width(Length::Shrink), + ].spacing(2), + ].spacing(10).align_y(iced::Alignment::End) + ); + results = results.push( + button(text("Save pH Scan").size(13)) + .style(style_action()) + .padding([6, 16]) + .on_press(Message::SavePhScan) + ); + + /* pH electrode ideality */ + results = results.push(iced::widget::horizontal_rule(1)); + results = results.push(text("pH Ideality").size(16)); + if let Some(i) = &self.ph_ideality { + results = results.push(text(format!("slope: {:.1}% (good 85-102%)", i.slope_pct)).size(14)); + results = results.push(text(format!("r2: {:.4} (good > 0.999)", i.r2)).size(14)); + results = results.push(text(format!("zero offset: {:.1} mV", i.zero_offset_mv)).size(14)); + results = results.push(text(format!( + "isopotential: pH {:.2} @ {:.1} mV ({}) ideal near pH 7", + i.ph_iso, i.e_iso, if i.iso_valid { "valid" } else { "invalid" } + )).size(13)); + } + results = results.push( + button(text("Read Ideality").size(13)) + .style(style_action()) + .padding([6, 16]) + .on_press(Message::GetPhIdeality) + ); + row![ - container(inputs).width(Length::FillPortion(2)), + container(scrollable(inputs)).width(Length::FillPortion(2)), iced::widget::vertical_rule(1), - container(results).width(Length::FillPortion(3)), + container(scrollable(results)).width(Length::FillPortion(3)), ] .spacing(12) .height(Length::Fill) @@ -2289,9 +2479,27 @@ impl App { } fn view_ph_body(&self) -> Element<'_, Message> { + let mut col = column![].spacing(8); + + if let Some(r) = &self.ph_volt_result { + let mut vc = column![ + text("Voltammetric pH").size(16), + text(format!("pH: {:.2}", r.ph)).size(28), + text(format!("peak: {:.1} mV | {:.3} uA | confidence: {:.0}% | Temp: {:.1} C", + r.peak_mv, r.peak_i_ua, r.confidence * 100.0, r.temp_c)).size(14), + ].spacing(4); + if !r.has_peak { + vc = vc.push(text("No reduction peak detected -- pH value unreliable") + .size(14) + .color(Color::from_rgb(0.95, 0.45, 0.30))); + } + col = col.push(vc); + } + if let Some(r) = &self.ph_result { let nernst_slope = 0.1984 * (r.temp_c as f64 + 273.15); - let mut col = column![ + let mut oc = column![ + text("OCP pH").size(16), text(format!("pH: {:.2}", r.ph)).size(28), text(format!("OCP: {:.1} mV | Nernst slope: {:.2} mV/pH | Temp: {:.1} C", r.v_ocp_mv, nernst_slope, r.temp_c)).size(14), @@ -2299,18 +2507,22 @@ impl App { if let Some(ref_r) = &self.ph_ref { let d_ph = r.ph - ref_r.ph; let d_v = r.v_ocp_mv - ref_r.v_ocp_mv; - col = col.push(text(format!( + oc = oc.push(text(format!( "vs Ref: dpH={:+.3} dOCP={:+.1} mV (ref pH={:.2})", d_ph, d_v, ref_r.ph )).size(14)); } - col.into() - } else { - column![ + col = col.push(oc); + } + + if self.ph_volt_result.is_none() && self.ph_result.is_none() { + col = col.push(column![ text("No measurement yet").size(16), text("OCP method: V(SE0) - V(RE0) with Nernst correction").size(12), - ].spacing(4).into() + ].spacing(4)); } + + col.into() } fn view_orp_body(&self) -> Element<'_, Message> { diff --git a/cue/src/protocol.rs b/cue/src/protocol.rs index adffea9..97ac60c 100644 --- a/cue/src/protocol.rs +++ b/cue/src/protocol.rs @@ -23,6 +23,9 @@ pub const RSP_PH_RESULT: u8 = 0x0F; pub const RSP_TEMP: u8 = 0x10; pub const RSP_CELL_K: u8 = 0x11; pub const RSP_ORP_RESULT: u8 = 0x12; +pub const RSP_PH_VOLT_RESULT: u8 = 0x13; +pub const RSP_PH_IDEALITY: u8 = 0x14; +pub const RSP_PH_SCAN: u8 = 0x15; pub const RSP_REF_FRAME: u8 = 0x20; pub const RSP_REF_LP_RANGE: u8 = 0x21; pub const RSP_REFS_DONE: u8 = 0x22; @@ -57,6 +60,10 @@ pub const CMD_GET_PH_CAL: u8 = 0x36; pub const CMD_PH_CAL_POINT: u8 = 0x37; pub const CMD_PH_CAL_CLEAR: u8 = 0x38; pub const CMD_PH_CAL_STATUS: u8 = 0x39; +pub const CMD_SET_PH_SCAN: u8 = 0x16; +pub const CMD_START_PH_VOLT: u8 = 0x18; +pub const CMD_GET_PH_SCAN: u8 = 0x19; +pub const CMD_GET_PH_IDEALITY: u8 = 0x35; pub const CMD_START_REFS: u8 = 0x30; pub const CMD_GET_REFS: u8 = 0x31; pub const CMD_CLEAR_REFS: u8 = 0x32; @@ -244,6 +251,40 @@ pub struct OrpResult { pub temp_c: f32, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PhVoltResult { + pub peak_mv: f32, + pub peak_i_ua: f32, + pub ph: f32, + pub temp_c: f32, + pub confidence: f32, + pub has_peak: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PhIdeality { + pub slope_pct: f32, + pub r2: f32, + pub zero_offset_mv: f32, + pub ph_iso: f32, + pub e_iso: f32, + pub iso_valid: bool, +} + +#[derive(Debug, Clone)] +pub struct PhScanCfg { + pub v_oxide_mv: f32, + pub t_oxide_ms: f32, + pub v_start: f32, + pub v_stop: f32, + pub scan_rate: f32, + pub stabilize_ms: f32, + pub min_prom_ua: f32, + pub v_clamp_min_mv: f32, + pub v_clamp_max_mv: f32, + pub lp_rtia: u8, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize)] pub struct OrpSample { pub t_s: f32, @@ -279,6 +320,9 @@ pub enum EisMessage { ClResult(ClResult), ClEnd, PhResult(PhResult, Option, Option), + PhVoltResult(PhVoltResult, Option, Option), + PhIdeality(PhIdeality), + PhScan(PhScanCfg), OrpResult(OrpResult, Option, Option), Temperature(f32), RefFrame { mode: u8, rtia_idx: u8 }, @@ -461,6 +505,46 @@ pub fn parse_sysex(data: &[u8]) -> Option { temp_c: decode_float(&p[10..15]), }, ts, mid)) } + RSP_PH_VOLT_RESULT if data.len() >= 35 => { + let p = &data[2..]; + let (ts, mid) = if p.len() >= 33 { + (Some(decode_u32(&p[26..31])), Some(decode_u16(&p[31..34]))) + } else { (None, None) }; + Some(EisMessage::PhVoltResult(PhVoltResult { + peak_mv: decode_float(&p[0..5]), + peak_i_ua: decode_float(&p[5..10]), + ph: decode_float(&p[10..15]), + temp_c: decode_float(&p[15..20]), + confidence: decode_float(&p[20..25]), + has_peak: p[25] != 0, + }, ts, mid)) + } + RSP_PH_IDEALITY if data.len() >= 28 => { + let p = &data[2..]; + Some(EisMessage::PhIdeality(PhIdeality { + slope_pct: decode_float(&p[0..5]), + r2: decode_float(&p[5..10]), + zero_offset_mv: decode_float(&p[10..15]), + ph_iso: decode_float(&p[15..20]), + e_iso: decode_float(&p[20..25]), + iso_valid: p[25] != 0, + })) + } + RSP_PH_SCAN if data.len() >= 48 => { + let p = &data[2..]; + Some(EisMessage::PhScan(PhScanCfg { + v_oxide_mv: decode_float(&p[0..5]), + t_oxide_ms: decode_float(&p[5..10]), + v_start: decode_float(&p[10..15]), + v_stop: decode_float(&p[15..20]), + scan_rate: decode_float(&p[20..25]), + stabilize_ms: decode_float(&p[25..30]), + min_prom_ua: decode_float(&p[30..35]), + v_clamp_min_mv: decode_float(&p[35..40]), + v_clamp_max_mv: decode_float(&p[40..45]), + lp_rtia: p[45], + })) + } RSP_ORP_RESULT if data.len() >= 12 => { let p = &data[2..]; let (ts, mid) = if p.len() >= 18 { @@ -672,3 +756,31 @@ pub fn build_sysex_ph_cal_clear(buffer_id: u8, temp_slot: u8) -> Vec { pub fn build_sysex_ph_cal_status() -> Vec { vec![0xF0, SYSEX_MFR, CMD_PH_CAL_STATUS, 0xF7] } + +pub fn build_sysex_start_ph_volt() -> Vec { + vec![0xF0, SYSEX_MFR, CMD_START_PH_VOLT, 0xF7] +} + +pub fn build_sysex_get_ph_ideality() -> Vec { + vec![0xF0, SYSEX_MFR, CMD_GET_PH_IDEALITY, 0xF7] +} + +pub fn build_sysex_get_ph_scan() -> Vec { + vec![0xF0, SYSEX_MFR, CMD_GET_PH_SCAN, 0xF7] +} + +pub fn build_sysex_set_ph_scan(cfg: &PhScanCfg) -> Vec { + let mut sx = vec![0xF0, SYSEX_MFR, CMD_SET_PH_SCAN]; + sx.extend_from_slice(&encode_float(cfg.v_oxide_mv)); + sx.extend_from_slice(&encode_float(cfg.t_oxide_ms)); + sx.extend_from_slice(&encode_float(cfg.v_start)); + sx.extend_from_slice(&encode_float(cfg.v_stop)); + sx.extend_from_slice(&encode_float(cfg.scan_rate)); + sx.extend_from_slice(&encode_float(cfg.stabilize_ms)); + sx.extend_from_slice(&encode_float(cfg.min_prom_ua)); + sx.extend_from_slice(&encode_float(cfg.v_clamp_min_mv)); + sx.extend_from_slice(&encode_float(cfg.v_clamp_max_mv)); + sx.push(cfg.lp_rtia & 0x7F); + sx.push(0xF7); + sx +} diff --git a/main/echem.c b/main/echem.c index 2cb1573..d77f93d 100644 --- a/main/echem.c +++ b/main/echem.c @@ -2,6 +2,7 @@ #include "eis.h" #include "ad5940.h" #include "protocol.h" +#include "nvs.h" #include #include #include @@ -82,9 +83,37 @@ const float lp_rtia_ohms[] = { #define VBIAS_OFFSET 200.0f #define VBIAS_LSB 0.537f +/* active gold-safe window and saturation flag */ +static float g_vmin_mv = ECHEM_AU_VMIN_MV; +static float g_vmax_mv = ECHEM_AU_VMAX_MV; +static uint8_t g_clamp_tripped; + +/* sets the gold-safe window, ignoring an inverted range */ +void echem_set_v_limits(float vmin_mv, float vmax_mv) +{ + if (vmin_mv < vmax_mv) { g_vmin_mv = vmin_mv; g_vmax_mv = vmax_mv; } +} + +/* reads the active gold-safe window */ +void echem_get_v_limits(float *vmin_mv, float *vmax_mv) +{ + if (vmin_mv) *vmin_mv = g_vmin_mv; + if (vmax_mv) *vmax_mv = g_vmax_mv; +} + +/* returns 1 when a potential write saturated against the window */ +uint8_t echem_clamp_tripped(void) { return g_clamp_tripped; } + +/* clears the saturation flag */ +void echem_clamp_reset(void) { g_clamp_tripped = 0; } + static uint16_t mv_to_vbias_code(float v_cell_mv) { - /* V_cell = VZERO - VBIAS → VBIAS = VZERO - V_cell */ + /* saturate to the gold-safe window, flag on contact, never wrap */ + if (v_cell_mv > g_vmax_mv) { v_cell_mv = g_vmax_mv; g_clamp_tripped = 1; } + if (v_cell_mv < g_vmin_mv) { v_cell_mv = g_vmin_mv; g_clamp_tripped = 1; } + + /* V_cell = VZERO - VBIAS, VBIAS = VZERO - V_cell */ float vbias_mv = VZERO_MV - v_cell_mv; float code = (vbias_mv - VBIAS_OFFSET) / VBIAS_LSB; if (code < 0) code = 0; @@ -487,11 +516,12 @@ int echem_amp(const AmpConfig *cfg, AmpPoint *out, uint32_t max_points, amp_poin void echem_default_cl(ClConfig *cfg) { + /* conservative gold defaults, app-overridable; placeholders pending a bench CV */ memset(cfg, 0, sizeof(*cfg)); - cfg->v_cond = 800.0f; /* +800 mV conditioning pulse */ - cfg->t_cond_ms = 2000.0f; - cfg->v_free = 100.0f; /* +100 mV for HOCl reduction */ - cfg->v_total = -200.0f; /* -200 mV for total chlorine */ + cfg->v_cond = 500.0f; /* below Au oxide/dissolution onset in chloride */ + cfg->t_cond_ms = 1000.0f; /* short anodic dwell on plated gold */ + cfg->v_free = 50.0f; /* gentler HOCl-reduction window */ + cfg->v_total = -150.0f; /* shallower cathodic excursion */ cfg->t_dep_ms = 5000.0f; /* 5s settling */ cfg->t_meas_ms = 5000.0f; /* 5s sampling */ cfg->lp_rtia = LP_RTIA_10K; @@ -624,18 +654,8 @@ int echem_ph_ocp(const PhConfig *cfg, PhResult *result) float v_re0 = sum_re0 / PH_AVG_N; float ocp = v_se0 - v_re0; - float ocp_corrected = ocp; - float tc_cold = eis_get_ph_temp_slope_cold(); - float tc_hot = eis_get_ph_temp_slope_hot(); - if (tc_cold != 0.0f || tc_hot != 0.0f) { - float dt = cfg->temp_c - 25.0f; - float alpha = (dt < 0.0f) ? tc_cold : tc_hot; - if (alpha != 0.0f) - ocp_corrected = ocp - alpha * dt; - } - result->v_ocp_mv = ocp; - result->ph = eis_get_ph_slope() * ocp_corrected + eis_get_ph_offset(); + result->ph = eis_ph_compensate(ocp, cfg->temp_c); result->temp_c = cfg->temp_c; printf("pH: SE0=%.1f mV, RE0=%.1f mV, OCP=%.1f mV, pH=%.2f\n", @@ -656,3 +676,180 @@ int echem_orp_read(const OrpConfig *cfg, OrpResult *result) printf("ORP: %.1f mV @ %.1f C\n", result->v_orp_mv, result->temp_c); return 0; } + +#define NVS_PH_SCAN_NS "eis" +#define NVS_PH_SCAN_KEY "ph_scan" + +/* peak-detection threshold, sourced from the persisted scan config */ +static float g_min_prom_ua = 0.05f; + +/* cached persisted scan settings */ +static PhScanCfg g_scan; + +void echem_default_ph_scan(PhScanCfg *cfg) +{ + /* conservative gold-safe starting values; tune via the calibration page */ + memset(cfg, 0, sizeof(*cfg)); + cfg->v_oxide_mv = 450.0f; + cfg->t_oxide_ms = 1000.0f; + cfg->v_start = 400.0f; + cfg->v_stop = -300.0f; + cfg->scan_rate = 50.0f; + cfg->stabilize_ms = 500.0f; + cfg->min_prom_ua = 0.05f; + cfg->v_clamp_min_mv = ECHEM_AU_VMIN_MV; + cfg->v_clamp_max_mv = ECHEM_AU_VMAX_MV; + cfg->lp_rtia = LP_RTIA_10K; +} + +/* pushes the cached scan config into the live clamp and peak threshold */ +static void echem_apply_ph_scan(void) +{ + g_min_prom_ua = g_scan.min_prom_ua; + echem_set_v_limits(g_scan.v_clamp_min_mv, g_scan.v_clamp_max_mv); +} + +void echem_set_ph_scan(const PhScanCfg *cfg) +{ + g_scan = *cfg; + echem_apply_ph_scan(); + nvs_handle_t h; + if (nvs_open(NVS_PH_SCAN_NS, NVS_READWRITE, &h) != ESP_OK) return; + nvs_set_blob(h, NVS_PH_SCAN_KEY, &g_scan, sizeof(g_scan)); + nvs_commit(h); + nvs_close(h); +} + +void echem_get_ph_scan(PhScanCfg *cfg) { *cfg = g_scan; } + +void echem_load_ph_scan(void) +{ + echem_default_ph_scan(&g_scan); + nvs_handle_t h; + if (nvs_open(NVS_PH_SCAN_NS, NVS_READONLY, &h) == ESP_OK) { + PhScanCfg tmp; + size_t len = sizeof(tmp); + if (nvs_get_blob(h, NVS_PH_SCAN_KEY, &tmp, &len) == ESP_OK && len == sizeof(g_scan)) + g_scan = tmp; + nvs_close(h); + } + echem_apply_ph_scan(); +} + +/* locates the gold-oxide reduction peak as a baseline-subtracted cathodic dip */ +static void ph_find_reduction_peak(const LSVPoint *p, uint32_t n, PhVoltResult *r) +{ + r->has_peak = 0; + r->confidence = 0.0f; + if (n < 7) return; + + static float sm[ECHEM_MAX_POINTS]; + sm[0] = p[0].i_ua; + sm[n - 1] = p[n - 1].i_ua; + for (uint32_t i = 1; i < n - 1; i++) + sm[i] = (p[i - 1].i_ua + p[i].i_ua + p[i + 1].i_ua) / 3.0f; + + float i0 = sm[0], i1 = sm[n - 1]; + + /* most-negative residual against a straight endpoint baseline */ + uint32_t kpk = 0; + float best = 0.0f; + for (uint32_t k = 1; k < n - 1; k++) { + float base = i0 + (i1 - i0) * ((float)k / (float)(n - 1)); + float res = sm[k] - base; + if (res < best) { best = res; kpk = k; } + } + float prominence = -best; + if (kpk == 0) return; + + /* baseline residual RMS away from the peak as a noise floor */ + float sum2 = 0; uint32_t cnt = 0; + for (uint32_t k = 1; k < n - 1; k++) { + if (kpk >= 2 && k >= kpk - 2 && k <= kpk + 2) continue; + float base = i0 + (i1 - i0) * ((float)k / (float)(n - 1)); + float res = sm[k] - base; + sum2 += res * res; cnt++; + } + float noise = (cnt > 0) ? sqrtf(sum2 / (float)cnt) : 0.0f; + + const float MIN_SNR = 3.0f; + if (prominence < g_min_prom_ua) return; + if (noise > 0.0f && prominence < MIN_SNR * noise) return; + + /* parabolic refine of the peak potential on the smoothed curve */ + float vC = p[kpk].v_mv; + float yL = sm[kpk - 1], yC = sm[kpk], yR = sm[kpk + 1]; + float denom = yL - 2.0f * yC + yR; + float v_peak = vC; + if (fabsf(denom) > 1e-9f) { + float delta = 0.5f * (yL - yR) / denom; + float dv = (p[kpk + 1].v_mv - p[kpk - 1].v_mv) * 0.5f; + v_peak = vC + delta * dv; + } + + r->peak_mv = v_peak; + r->peak_i_ua = p[kpk].i_ua; + r->has_peak = 1; + float snr = (noise > 0.0f) ? prominence / noise : MIN_SNR; + r->confidence = snr / (snr + MIN_SNR); +} + +/* grows a thin gold oxide, sweeps cathodic, and reads off the reduction-peak potential */ +int echem_ph_voltammetric(const PhVoltConfig *cfg, PhVoltResult *result, + LSVPoint *out, uint32_t max_points, lsv_point_cb_t cb) +{ + memset(result, 0, sizeof(*result)); + result->temp_c = cfg->temp_c; + if (cfg->lp_rtia >= LP_RTIA_COUNT) return 0; + float rtia = lp_rtia_ohms[cfg->lp_rtia]; + + echem_clamp_reset(); + echem_init_lp(lp_rtia_map[cfg->lp_rtia]); + + /* oxide-grow hold, chunked with keepalives */ + AD5940_LPDAC0WriteS(mv_to_vbias_code(cfg->v_oxide_mv), VZERO_CODE); + { + uint32_t remain_ms = (uint32_t)cfg->t_oxide_ms; + while (remain_ms > 0) { + uint32_t chunk = remain_ms > 3000 ? 3000 : remain_ms; + vTaskDelay(pdMS_TO_TICKS(chunk)); + remain_ms -= chunk; + if (remain_ms > 0) send_keepalive(); + } + } + + /* settle at sweep start and flush the SINC2 filter */ + AD5940_LPDAC0WriteS(mv_to_vbias_code(cfg->v_start), VZERO_CODE); + vTaskDelay(pdMS_TO_TICKS((uint32_t)cfg->stabilize_ms)); + for (int i = 0; i < 4; i++) + read_current_ua(rtia); + + LSVConfig lsv = { cfg->v_start, cfg->v_stop, cfg->scan_rate, cfg->lp_rtia }; + uint32_t n_steps; float step; + lsv_calc_step(&lsv, max_points, &n_steps, &step); + + float delay_ms = fabsf(step / cfg->scan_rate) * 1000.0f; + if (delay_ms < 1.0f) delay_ms = 1.0f; + TickType_t ticks = pdMS_TO_TICKS((uint32_t)delay_ms); + if (ticks < 1) ticks = 1; + + for (uint32_t i = 0; i < n_steps; i++) { + float v_mv = cfg->v_start + i * step; + AD5940_LPDAC0WriteS(mv_to_vbias_code(v_mv), VZERO_CODE); + vTaskDelay(ticks); + float i_ua = read_current_ua(rtia); + out[i].v_mv = v_mv; + out[i].i_ua = i_ua; + if (cb) cb((uint16_t)i, v_mv, i_ua); + } + result->n_points = (uint16_t)n_steps; + + echem_shutdown_lp(); + AD5940_AFECtrlS(AFECTRL_ALL, bFALSE); + + ph_find_reduction_peak(out, n_steps, result); + if (result->has_peak) + result->ph = eis_ph_compensate(result->peak_mv, cfg->temp_c); + + return (int)n_steps; +} diff --git a/main/echem.h b/main/echem.h index bf17298..c557eb3 100644 --- a/main/echem.h +++ b/main/echem.h @@ -5,6 +5,16 @@ #define ECHEM_MAX_POINTS 500 +/* gold-safe cell-potential window vs Ag/AgCl, runtime-overridable. + placeholders pending a bench CV: set just above the lowest oxide-grow + potential that still yields a peak, to spare the thin gold plating. */ +#ifndef ECHEM_AU_VMAX_MV +#define ECHEM_AU_VMAX_MV 600.0f +#endif +#ifndef ECHEM_AU_VMIN_MV +#define ECHEM_AU_VMIN_MV -600.0f +#endif + typedef enum { LP_RTIA_200 = 0, LP_RTIA_1K, @@ -105,12 +115,54 @@ typedef struct { float temp_c; /* temperature at measurement */ } OrpResult; +/* Voltammetric pH: gold-oxide reduction-peak shift */ +typedef struct { + float v_oxide_mv; /* anodic oxide-grow potential, clamped */ + float t_oxide_ms; /* oxide-grow hold */ + float v_start; /* cathodic sweep start, mV */ + float v_stop; /* cathodic sweep end, mV */ + float scan_rate; /* mV/s */ + float stabilize_ms; /* settle at v_start before the sweep */ + EchemLpRtia lp_rtia; + float temp_c; /* caller-injected for cal temp-correction */ +} PhVoltConfig; + +typedef struct { + float peak_mv; /* reduction-peak potential, the pH-bearing value */ + float peak_i_ua; /* current at the peak */ + float ph; /* cal-derived pH, 0 with no peak or no cal */ + float temp_c; + float confidence; /* 0..1 prominence over baseline noise */ + uint8_t has_peak; + uint16_t n_points; +} PhVoltResult; + +/* persisted voltammetric-scan settings owned by the calibration page */ +typedef struct { + float v_oxide_mv; + float t_oxide_ms; + float v_start; + float v_stop; + float scan_rate; + float stabilize_ms; + float min_prom_ua; /* reduction-peak detection threshold */ + float v_clamp_min_mv; /* gold-safe cathodic floor */ + float v_clamp_max_mv; /* gold-safe anodic ceiling */ + uint8_t lp_rtia; +} PhScanCfg; + typedef int (*lsv_point_cb_t)(uint16_t idx, float v_mv, float i_ua); typedef int (*amp_point_cb_t)(uint16_t idx, float t_ms, float i_ua); typedef int (*cl_point_cb_t)(uint16_t idx, float t_ms, float i_ua, uint8_t phase); int echem_clean(float v_mv, float duration_s); +/* gold-safe potential clamp */ +void echem_set_v_limits(float vmin_mv, float vmax_mv); +void echem_get_v_limits(float *vmin_mv, float *vmax_mv); +uint8_t echem_clamp_tripped(void); +void echem_clamp_reset(void); + void echem_default_lsv(LSVConfig *cfg); void echem_default_amp(AmpConfig *cfg); void echem_default_cl(ClConfig *cfg); @@ -123,4 +175,13 @@ int echem_chlorine(const ClConfig *cfg, ClPoint *out, uint32_t max_points, ClRes int echem_ph_ocp(const PhConfig *cfg, PhResult *result); int echem_orp_read(const OrpConfig *cfg, OrpResult *result); +int echem_ph_voltammetric(const PhVoltConfig *cfg, PhVoltResult *result, + LSVPoint *out, uint32_t max_points, lsv_point_cb_t cb); + +/* persisted pH-scan settings */ +void echem_default_ph_scan(PhScanCfg *cfg); +void echem_set_ph_scan(const PhScanCfg *cfg); +void echem_get_ph_scan(PhScanCfg *cfg); +void echem_load_ph_scan(void); + #endif diff --git a/main/eis.c b/main/eis.c index 7854090..ef208b1 100644 --- a/main/eis.c +++ b/main/eis.c @@ -643,7 +643,8 @@ float eis_ph_buffer_at_temp(uint8_t buf, float temp_c) return tbl[i] + frac * (tbl[i + 1] - tbl[i]); } -#define NVS_PH_CAL_PTS_KEY "ph_cal9" +/* bumped from ph_cal9: stored x is now a voltammetric peak potential, not OCP */ +#define NVS_PH_CAL_PTS_KEY "ph_cal10" typedef struct { float ocp_mv; @@ -655,71 +656,124 @@ static struct { uint16_t valid; } ph_cal; -static float ph_temp_slope_cold; -static float ph_temp_slope_hot; +/* theoretical Nernstian pH slope at t_c, mV/pH (59.16 at 25 C) */ +static inline float ph_nernst_slope_mv(float t_c) +{ + return 0.19841f * (273.15f + t_c); +} +/* derived cal-quality and isopotential model, recomputed on every cal edit */ +static struct { + float slope_pct; /* measured vs theoretical Nernst at cal temp */ + float r2; /* fit quality of the base-row E-vs-pH line */ + float zero_offset_mv; /* potential at pH 7 (carries the reference offset) */ + float ph_iso; /* isopotential pH */ + float e_iso; /* isopotential mV */ + float cal_temp_c; /* base-row representative temperature */ + uint8_t iso_valid; /* 1 once two temperature lines intersect cleanly */ + uint8_t n_base; +} ph_diag; + +/* least-squares intersection of the per-temperature E-vs-pH lines */ +static void ph_cal_recalc_iso(void) +{ + float m[PH_CAL_TEMPS], c[PH_CAL_TEMPS]; + int k = 0; + for (int t = 0; t < PH_CAL_TEMPS; t++) { + int nn = 0; float sx = 0, sy = 0, sxx = 0, sxy = 0; + for (int i = 0; i < PH_CAL_BUFFERS; i++) { + int bit = i * PH_CAL_TEMPS + t; + if (!(ph_cal.valid & (1 << bit))) continue; + float ph = eis_ph_buffer_at_temp(i, ph_cal.s[i][t].temp_c); + float e = ph_cal.s[i][t].ocp_mv; + sx += ph; sy += e; sxx += ph * ph; sxy += ph * e; nn++; + } + if (nn < 2) continue; + float d = (float)nn * sxx - sx * sx; + if (fabsf(d) < 1e-6f) continue; + m[k] = ((float)nn * sxy - sx * sy) / d; + c[k] = (sy - m[k] * sx) / (float)nn; + k++; + } + if (k < 2) { /* no temperature spread, fall back to ideal */ + ph_diag.iso_valid = 0; + ph_diag.ph_iso = 7.0f; + ph_diag.e_iso = 0.0f; + return; + } + float Sm = 0, Smm = 0, Sc = 0, Smc = 0; + for (int j = 0; j < k; j++) { Sm += m[j]; Smm += m[j]*m[j]; Sc += c[j]; Smc += m[j]*c[j]; } + float den = (float)k * Smm - Sm * Sm; + if (fabsf(den) < 1e-6f) { /* parallel lines, no unique crossing */ + ph_diag.iso_valid = 0; + ph_diag.ph_iso = 7.0f; + ph_diag.e_iso = 0.0f; + return; + } + ph_diag.ph_iso = (Smm * Sc - Sm * Smc) / den; + ph_diag.e_iso = (Sm * ph_diag.ph_iso + Sc) / (float)k; + ph_diag.iso_valid = 1; + printf("pH cal: iso pH=%.3f E=%.2f mV (%d lines)\n", + ph_diag.ph_iso, ph_diag.e_iso, k); +} + +/* fits base-row slope/offset and the electrode-ideality diagnostics */ static void ph_cal_recalculate(void) { - /* baseline slope/offset from the 3 baseline (tslot=1) points */ int n = 0; - float sx = 0, sy = 0, sxx = 0, sxy = 0; + float sx = 0, sy = 0, sxx = 0, sxy = 0, syy = 0, t_sum = 0; for (int i = 0; i < PH_CAL_BUFFERS; i++) { int bit = i * PH_CAL_TEMPS + PH_TEMP_BASE; if (!(ph_cal.valid & (1 << bit))) continue; - float x = ph_cal.s[i][PH_TEMP_BASE].ocp_mv; - float y = eis_ph_buffer_at_temp(i, ph_cal.s[i][PH_TEMP_BASE].temp_c); - sx += x; sy += y; sxx += x * x; sxy += x * y; + float x = ph_cal.s[i][PH_TEMP_BASE].ocp_mv; + float tc = ph_cal.s[i][PH_TEMP_BASE].temp_c; + float y = eis_ph_buffer_at_temp(i, tc); + sx += x; sy += y; sxx += x*x; sxy += x*y; syy += y*y; t_sum += tc; n++; } + + memset(&ph_diag, 0, sizeof(ph_diag)); + ph_diag.n_base = (uint8_t)n; + ph_diag.ph_iso = 7.0f; + if (n < 2) { ph_slope_cached = 0; ph_offset_cached = 0; - } else { - float d = (float)n * sxx - sx * sx; - if (fabsf(d) < 1e-10f) { - ph_slope_cached = 0; - ph_offset_cached = 0; - } else { - ph_slope_cached = ((float)n * sxy - sx * sy) / d; - ph_offset_cached = (sy - ph_slope_cached * sx) / (float)n; - } + printf("pH cal: base row %d pts, no fit\n", n); + ph_cal_recalc_iso(); + return; } - printf("pH cal: baseline slope=%.6f offset=%.4f (%d pts)\n", - ph_slope_cached, ph_offset_cached, n); - /* temperature drift from off-temperature points */ - ph_temp_slope_cold = 0; - ph_temp_slope_hot = 0; - int nc = 0, nh = 0; - for (int i = 0; i < PH_CAL_BUFFERS; i++) { - int base_bit = i * PH_CAL_TEMPS + PH_TEMP_BASE; - if (!(ph_cal.valid & (1 << base_bit))) continue; - float ocp_base = ph_cal.s[i][PH_TEMP_BASE].ocp_mv; - - int cold_bit = i * PH_CAL_TEMPS + PH_TEMP_BELOW; - if (ph_cal.valid & (1 << cold_bit)) { - float dt = ph_cal.s[i][PH_TEMP_BELOW].temp_c - 25.0f; - if (fabsf(dt) > 0.5f) { - ph_temp_slope_cold += (ph_cal.s[i][PH_TEMP_BELOW].ocp_mv - ocp_base) / dt; - nc++; - } - } - - int hot_bit = i * PH_CAL_TEMPS + PH_TEMP_ABOVE; - if (ph_cal.valid & (1 << hot_bit)) { - float dt = ph_cal.s[i][PH_TEMP_ABOVE].temp_c - 25.0f; - if (fabsf(dt) > 0.5f) { - ph_temp_slope_hot += (ph_cal.s[i][PH_TEMP_ABOVE].ocp_mv - ocp_base) / dt; - nh++; - } - } + float d = (float)n * sxx - sx * sx; + if (fabsf(d) < 1e-6f) { /* buffers collapsed to one mV */ + ph_slope_cached = 0; + ph_offset_cached = 0; + printf("pH cal: singular base fit\n"); + ph_cal_recalc_iso(); + return; } - if (nc > 0) ph_temp_slope_cold /= nc; - if (nh > 0) ph_temp_slope_hot /= nh; - if (nc > 0 || nh > 0) - printf("pH cal: temp drift cold=%.4f hot=%.4f mV/C\n", - ph_temp_slope_cold, ph_temp_slope_hot); + ph_slope_cached = ((float)n * sxy - sx * sy) / d; /* pH per mV */ + ph_offset_cached = (sy - ph_slope_cached * sx) / (float)n; + + float cal_t = t_sum / (float)n; + ph_diag.cal_temp_c = cal_t; + + float s_meas = (fabsf(ph_slope_cached) > 1e-9f) ? (-1.0f / ph_slope_cached) : 0.0f; + float s_theo = ph_nernst_slope_mv(cal_t); + ph_diag.slope_pct = (s_theo != 0.0f) ? (s_meas / s_theo) * 100.0f : 0.0f; + + float sse = syy - ph_slope_cached * sxy - ph_offset_cached * sy; + float sst = syy - (sy * sy) / (float)n; + ph_diag.r2 = (sst > 1e-9f) ? (1.0f - sse / sst) : 1.0f; + + if (fabsf(ph_slope_cached) > 1e-9f) + ph_diag.zero_offset_mv = (7.0f - ph_offset_cached) / ph_slope_cached; + + printf("pH cal: slope=%.6f offset=%.4f S=%.2f mV/pH (%.1f%%) R2=%.4f (%d pts)\n", + ph_slope_cached, ph_offset_cached, s_meas, ph_diag.slope_pct, ph_diag.r2, n); + + ph_cal_recalc_iso(); } static void ph_cal_save(void) @@ -731,8 +785,44 @@ static void ph_cal_save(void) nvs_close(h); } -float eis_get_ph_temp_slope_cold(void) { return ph_temp_slope_cold; } -float eis_get_ph_temp_slope_hot(void) { return ph_temp_slope_hot; } +/* retained for wire compatibility, drift folded into the isopotential model */ +float eis_get_ph_temp_slope_cold(void) { return 0.0f; } +float eis_get_ph_temp_slope_hot(void) { return 0.0f; } + +float eis_get_ph_cal_temp(void) { return ph_diag.cal_temp_c; } + +/* fills the isopotential point, false when fewer than two temperature rows */ +bool eis_get_ph_iso(float *ph_iso, float *e_iso) +{ + if (ph_iso) *ph_iso = ph_diag.ph_iso; + if (e_iso) *e_iso = ph_diag.e_iso; + return ph_diag.iso_valid != 0; +} + +/* snapshots the cal-quality diagnostics */ +void eis_get_ph_diag(PhDiag *out) +{ + if (!out) return; + out->slope_pct = ph_diag.slope_pct; + out->r2 = ph_diag.r2; + out->zero_offset_mv = ph_diag.zero_offset_mv; + out->ph_iso = ph_diag.ph_iso; + out->e_iso = ph_diag.e_iso; + out->iso_valid = ph_diag.iso_valid != 0; +} + +/* converts a calibrated potential to pH via the isopotential Nernst model */ +float eis_ph_compensate(float meas_mv, float temp_c) +{ + if (fabsf(ph_slope_cached) < 1e-9f) return 0.0f; + float s_cal = -1.0f / ph_slope_cached; /* mV/pH at cal temp */ + float s_t = s_cal * (ph_nernst_slope_mv(temp_c) / + ph_nernst_slope_mv(ph_diag.cal_temp_c)); + if (ph_diag.iso_valid) + return ph_diag.ph_iso + (meas_mv - ph_diag.e_iso) / s_t; + float mv_at_ph7 = (7.0f - ph_offset_cached) / ph_slope_cached; + return 7.0f + (meas_mv - mv_at_ph7) / s_t; +} void eis_load_ph_cal(void) { @@ -774,8 +864,7 @@ void eis_ph_cal_clear_all(void) memset(&ph_cal, 0, sizeof(ph_cal)); ph_slope_cached = 0; ph_offset_cached = 0; - ph_temp_slope_cold = 0; - ph_temp_slope_hot = 0; + memset(&ph_diag, 0, sizeof(ph_diag)); ph_cal_save(); } diff --git a/main/eis.h b/main/eis.h index bf8908f..9888d92 100644 --- a/main/eis.h +++ b/main/eis.h @@ -72,10 +72,23 @@ void eis_set_cl_factor(float f); float eis_get_cl_factor(void); void eis_load_cl_factor(void); +typedef struct { + float slope_pct; + float r2; + float zero_offset_mv; + float ph_iso; + float e_iso; + uint8_t iso_valid; +} PhDiag; + float eis_get_ph_slope(void); float eis_get_ph_offset(void); float eis_get_ph_temp_slope_cold(void); float eis_get_ph_temp_slope_hot(void); +float eis_get_ph_cal_temp(void); +bool eis_get_ph_iso(float *ph_iso, float *e_iso); +void eis_get_ph_diag(PhDiag *out); +float eis_ph_compensate(float meas_mv, float temp_c); void eis_load_ph_cal(void); #define PH_CAL_BUFFERS 3 diff --git a/main/eis4.c b/main/eis4.c index 3003d52..7f76afe 100644 --- a/main/eis4.c +++ b/main/eis4.c @@ -63,6 +63,7 @@ void app_main(void) eis_load_cell_k(); eis_load_cl_factor(); eis_load_ph_cal(); + echem_load_ph_scan(); temp_init(); wifi_cfg_init(); @@ -211,6 +212,35 @@ void app_main(void) break; } + case CMD_START_PH_VOLT: { + PhScanCfg sc; + echem_get_ph_scan(&sc); + PhVoltConfig pv = { + sc.v_oxide_mv, sc.t_oxide_ms, sc.v_start, sc.v_stop, + sc.scan_rate, sc.stabilize_ms, sc.lp_rtia, temp_get() + }; + printf("pH-V: oxide %.0f mV/%.0f ms, sweep %.0f->%.0f mV, rtia=%u\n", + pv.v_oxide_mv, pv.t_oxide_ms, pv.v_start, pv.v_stop, pv.lp_rtia); + + uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000); + measurement_counter++; + LSVConfig lv = { pv.v_start, pv.v_stop, pv.scan_rate, pv.lp_rtia }; + uint32_t n = echem_lsv_calc_steps(&lv, ECHEM_MAX_POINTS); + send_lsv_start(n, pv.v_start, pv.v_stop, ts_ms, measurement_counter); + + PhVoltResult res; + echem_ph_voltammetric(&pv, &res, lsv_results, ECHEM_MAX_POINTS, send_lsv_point); + send_lsv_end(); + + uint8_t has_peak = res.has_peak && !echem_clamp_tripped(); + printf("pH-V: peak=%.1f mV i=%.3f uA pH=%.2f conf=%.2f peak=%u clamp=%u\n", + res.peak_mv, res.peak_i_ua, res.ph, res.confidence, + res.has_peak, echem_clamp_tripped()); + send_ph_volt_result(res.peak_mv, res.peak_i_ua, res.ph, res.temp_c, + res.confidence, has_peak, ts_ms, measurement_counter); + break; + } + case CMD_START_CLEAN: printf("Clean: %.0f mV, %.0f s\n", cmd.clean.v_mv, cmd.clean.duration_s); echem_clean(cmd.clean.v_mv, cmd.clean.duration_s); @@ -275,25 +305,41 @@ void app_main(void) printf("pH cal: buffer %u slot %u (nominal pH %.2f)\n", bid, tsl, eis_ph_cal_buffer_ph(bid)); - PhConfig ph_cfg; - ph_cfg.stabilize_s = cmd.ph_cal_point.stabilize_s; - ph_cfg.temp_c = temp_get(); + PhScanCfg sc; + echem_get_ph_scan(&sc); + PhVoltConfig pv = { + sc.v_oxide_mv, sc.t_oxide_ms, sc.v_start, sc.v_stop, + sc.scan_rate, sc.stabilize_ms, sc.lp_rtia, temp_get() + }; - PhResult ph_result; - echem_ph_ocp(&ph_cfg, &ph_result); + uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000); + measurement_counter++; + LSVConfig lv = { pv.v_start, pv.v_stop, pv.scan_rate, pv.lp_rtia }; + uint32_t n = echem_lsv_calc_steps(&lv, ECHEM_MAX_POINTS); + send_lsv_start(n, pv.v_start, pv.v_stop, ts_ms, measurement_counter); - eis_ph_cal_set_point(bid, tsl, ph_result.v_ocp_mv, ph_result.temp_c); + PhVoltResult res; + echem_ph_voltammetric(&pv, &res, lsv_results, ECHEM_MAX_POINTS, send_lsv_point); + send_lsv_end(); - float buf_ph = eis_ph_buffer_at_temp(bid, ph_result.temp_c); + if (!res.has_peak || echem_clamp_tripped()) { + printf("pH cal: [%u][%u] no peak, not stored\n", bid, tsl); + send_ph_cal_status(); + break; + } + + eis_ph_cal_set_point(bid, tsl, res.peak_mv, res.temp_c); + + float buf_ph = eis_ph_buffer_at_temp(bid, res.temp_c); int baseline_n = 0; for (int i = 0; i < PH_CAL_BUFFERS; i++) if (eis_ph_cal_get_point(i, PH_TEMP_BASE, NULL, NULL)) baseline_n++; - printf("pH cal: [%u][%u] OCP=%.1f mV T=%.1f C pH=%.3f (%d/%d)\n", - bid, tsl, ph_result.v_ocp_mv, ph_result.temp_c, buf_ph, + printf("pH cal: [%u][%u] peak=%.1f mV T=%.1f C pH=%.3f (%d/%d)\n", + bid, tsl, res.peak_mv, res.temp_c, buf_ph, baseline_n, eis_ph_cal_count()); - send_ph_cal_point(bid, tsl, ph_result.v_ocp_mv, ph_result.temp_c, + send_ph_cal_point(bid, tsl, res.peak_mv, res.temp_c, buf_ph, (uint8_t)baseline_n); break; } @@ -320,6 +366,34 @@ void app_main(void) send_ph_cal(eis_get_ph_slope(), eis_get_ph_offset()); break; + case CMD_GET_PH_IDEALITY: + send_ph_ideality(); + break; + + case CMD_SET_PH_SCAN: { + PhScanCfg sc; + sc.v_oxide_mv = cmd.ph_scan.v_oxide_mv; + sc.t_oxide_ms = cmd.ph_scan.t_oxide_ms; + sc.v_start = cmd.ph_scan.v_start; + sc.v_stop = cmd.ph_scan.v_stop; + sc.scan_rate = cmd.ph_scan.scan_rate; + sc.stabilize_ms = cmd.ph_scan.stabilize_ms; + sc.min_prom_ua = cmd.ph_scan.min_prom_ua; + sc.v_clamp_min_mv = cmd.ph_scan.v_clamp_min_mv; + sc.v_clamp_max_mv = cmd.ph_scan.v_clamp_max_mv; + sc.lp_rtia = cmd.ph_scan.lp_rtia; + echem_set_ph_scan(&sc); + send_ph_scan(); + printf("pH scan: oxide %.0f mV/%.0f ms, %.0f->%.0f mV, rtia=%u, prom=%.3f, clamp %.0f..%.0f\n", + sc.v_oxide_mv, sc.t_oxide_ms, sc.v_start, sc.v_stop, sc.lp_rtia, + sc.min_prom_ua, sc.v_clamp_min_mv, sc.v_clamp_max_mv); + break; + } + + case CMD_GET_PH_SCAN: + send_ph_scan(); + break; + case CMD_START_CL: { ClConfig cl_cfg; cl_cfg.v_cond = cmd.cl.v_cond; diff --git a/main/protocol.c b/main/protocol.c index f438ca5..6dd35a6 100644 --- a/main/protocol.c +++ b/main/protocol.c @@ -1,5 +1,6 @@ #include "protocol.h" #include "eis.h" +#include "echem.h" #include "wifi_transport.h" #include #include @@ -396,6 +397,24 @@ int send_ph_cal_status(void) return send_sysex(sx, p); } +/* reports electrode-ideality diagnostics: slope%, R2, zero offset, isopotential */ +int send_ph_ideality(void) +{ + PhDiag d; + eis_get_ph_diag(&d); + uint8_t sx[32]; + uint16_t p = 0; + sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_PH_IDEALITY; + encode_float(d.slope_pct, &sx[p]); p += 5; + encode_float(d.r2, &sx[p]); p += 5; + encode_float(d.zero_offset_mv, &sx[p]); p += 5; + encode_float(d.ph_iso, &sx[p]); p += 5; + encode_float(d.e_iso, &sx[p]); p += 5; + sx[p++] = d.iso_valid & 0x7F; + sx[p++] = 0xF7; + return send_sysex(sx, p); +} + /* ---- outbound: pH ---- */ int send_ph_result(float v_ocp_mv, float ph, float temp_c, @@ -429,6 +448,50 @@ int send_orp_result(float v_orp_mv, float temp_c, return send_sysex(sx, p); } +/* ---- outbound: voltammetric pH ---- */ + +int send_ph_volt_result(float peak_mv, float peak_i_ua, float ph, float temp_c, + float confidence, uint8_t has_peak, + uint32_t ts_ms, uint16_t meas_id) +{ + uint8_t sx[40]; + uint16_t p = 0; + sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_PH_VOLT_RESULT; + encode_float(peak_mv, &sx[p]); p += 5; + encode_float(peak_i_ua, &sx[p]); p += 5; + encode_float(ph, &sx[p]); p += 5; + encode_float(temp_c, &sx[p]); p += 5; + encode_float(confidence, &sx[p]); p += 5; + sx[p++] = has_peak & 0x7F; + encode_u32(ts_ms, &sx[p]); p += 5; + encode_u16(meas_id, &sx[p]); p += 3; + sx[p++] = 0xF7; + return send_sysex(sx, p); +} + +/* ---- outbound: pH-scan config ---- */ + +int send_ph_scan(void) +{ + PhScanCfg sc; + echem_get_ph_scan(&sc); + uint8_t sx[56]; + uint16_t p = 0; + sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_PH_SCAN; + encode_float(sc.v_oxide_mv, &sx[p]); p += 5; + encode_float(sc.t_oxide_ms, &sx[p]); p += 5; + encode_float(sc.v_start, &sx[p]); p += 5; + encode_float(sc.v_stop, &sx[p]); p += 5; + encode_float(sc.scan_rate, &sx[p]); p += 5; + encode_float(sc.stabilize_ms, &sx[p]); p += 5; + encode_float(sc.min_prom_ua, &sx[p]); p += 5; + encode_float(sc.v_clamp_min_mv, &sx[p]); p += 5; + encode_float(sc.v_clamp_max_mv, &sx[p]); p += 5; + sx[p++] = sc.lp_rtia & 0x7F; + sx[p++] = 0xF7; + return send_sysex(sx, p); +} + /* ---- outbound: temperature ---- */ int send_temp(float temp_c) diff --git a/main/protocol.h b/main/protocol.h index 177a670..eb253a9 100644 --- a/main/protocol.h +++ b/main/protocol.h @@ -16,6 +16,9 @@ #define CMD_STOP_AMP 0x22 #define CMD_GET_TEMP 0x17 +#define CMD_START_PH_VOLT 0x18 +#define CMD_SET_PH_SCAN 0x16 +#define CMD_GET_PH_SCAN 0x19 #define CMD_START_CL 0x23 #define CMD_START_PH 0x24 #define CMD_START_CLEAN 0x25 @@ -29,6 +32,7 @@ #define CMD_CLEAR_REFS 0x32 #define CMD_SET_CL_FACTOR 0x33 #define CMD_GET_CL_FACTOR 0x34 +#define CMD_GET_PH_IDEALITY 0x35 #define CMD_GET_PH_CAL 0x36 #define CMD_PH_CAL_POINT 0x37 #define CMD_PH_CAL_CLEAR 0x38 @@ -60,6 +64,9 @@ #define RSP_TEMP 0x10 #define RSP_CELL_K 0x11 #define RSP_ORP_RESULT 0x12 +#define RSP_PH_VOLT_RESULT 0x13 +#define RSP_PH_IDEALITY 0x14 +#define RSP_PH_SCAN 0x15 #define RSP_REF_FRAME 0x20 #define RSP_REF_LP_RANGE 0x21 #define RSP_REFS_DONE 0x22 @@ -98,6 +105,8 @@ typedef struct { struct { float v_cond, t_cond_ms, v_free, v_total, t_dep_ms, t_meas_ms; uint8_t lp_rtia; } cl; struct { float stabilize_s; } ph; struct { float stabilize_s; } orp; + struct { float v_oxide_mv, t_oxide_ms, v_start, v_stop, scan_rate, stabilize_ms, + min_prom_ua, v_clamp_min_mv, v_clamp_max_mv; uint8_t lp_rtia; } ph_scan; struct { float v_mv; float duration_s; } clean; float cell_k; float cl_factor; @@ -157,6 +166,14 @@ int send_ph_result(float v_ocp_mv, float ph, float temp_c, int send_orp_result(float v_orp_mv, float temp_c, uint32_t ts_ms, uint16_t meas_id); +/* outbound: voltammetric pH */ +int send_ph_volt_result(float peak_mv, float peak_i_ua, float ph, float temp_c, + float confidence, uint8_t has_peak, + uint32_t ts_ms, uint16_t meas_id); + +/* outbound: pH-scan config */ +int send_ph_scan(void); + /* outbound: temperature */ int send_temp(float temp_c); @@ -171,6 +188,7 @@ int send_ph_cal(float slope, float offset); int send_ph_cal_point(uint8_t buf, uint8_t tslot, float ocp_mv, float temp_c, float buffer_ph, uint8_t baseline_count); int send_ph_cal_status(void); +int send_ph_ideality(void); /* outbound: reference collection */ int send_ref_frame(uint8_t mode, uint8_t rtia_idx); diff --git a/main/wifi_transport.c b/main/wifi_transport.c index 03acb7f..e27416f 100644 --- a/main/wifi_transport.c +++ b/main/wifi_transport.c @@ -177,6 +177,19 @@ static void parse_udp_sysex(const uint8_t *data, uint16_t len) if (len < 8) return; cmd.orp.stabilize_s = decode_float(&data[3]); break; + case CMD_SET_PH_SCAN: + if (len < 50) return; + cmd.ph_scan.v_oxide_mv = decode_float(&data[3]); + cmd.ph_scan.t_oxide_ms = decode_float(&data[8]); + cmd.ph_scan.v_start = decode_float(&data[13]); + cmd.ph_scan.v_stop = decode_float(&data[18]); + cmd.ph_scan.scan_rate = decode_float(&data[23]); + cmd.ph_scan.stabilize_ms = decode_float(&data[28]); + cmd.ph_scan.min_prom_ua = decode_float(&data[33]); + cmd.ph_scan.v_clamp_min_mv = decode_float(&data[38]); + cmd.ph_scan.v_clamp_max_mv = decode_float(&data[43]); + cmd.ph_scan.lp_rtia = data[48]; + break; case CMD_START_CLEAN: if (len < 13) return; cmd.clean.v_mv = decode_float(&data[3]); @@ -229,6 +242,9 @@ static void parse_udp_sysex(const uint8_t *data, uint16_t len) case CMD_GET_CELL_K: case CMD_GET_CL_FACTOR: case CMD_GET_PH_CAL: + case CMD_GET_PH_IDEALITY: + case CMD_START_PH_VOLT: + case CMD_GET_PH_SCAN: case CMD_PH_CAL_STATUS: case CMD_START_REFS: case CMD_GET_REFS: