diff --git a/cue-ios/CueIOS/AppState.swift b/cue-ios/CueIOS/AppState.swift index 07b0d97..78d6822 100644 --- a/cue-ios/CueIOS/AppState.swift +++ b/cue-ios/CueIOS/AppState.swift @@ -17,6 +17,7 @@ enum Tab: String, CaseIterable, Identifiable { case amp = "Amperometry" case chlorine = "Chlorine" case ph = "pH" + case orp = "ORP" case calibrate = "Calibrate" case sessions = "Sessions" case connection = "Connection" @@ -84,12 +85,19 @@ final class AppState { var phResult: PhResult? = nil var phStabilize: String = "30" + // ORP + var orpResult: OrpResult? = nil + var orpStabilize: String = "30" + var orpHistory: [OrpSample] = [] + private var orpT0: Date? = nil + // Reference baselines var eisRef: [EisPoint]? = nil var lsvRef: [LsvPoint]? = nil var ampRef: [AmpPoint]? = nil var clRef: (points: [ClPoint], result: ClResult)? = nil var phRef: PhResult? = nil + var orpRef: OrpResult? = nil // Device reference collection var collectingRefs: Bool = false @@ -291,6 +299,23 @@ final class AppState { phResult = r } + case .orpResult(let r): + transport.measuring = false + if collectingRefs { + orpRef = r + } else { + saveOrp(r) + let t0 = orpT0 ?? { + let now = Date() + orpT0 = now + return now + }() + let tS = Float(Date().timeIntervalSince(t0)) + orpHistory.append(OrpSample(tS: tS, vMv: r.vOrpMv)) + status = String(format: "ORP: %.0f mV (T=%.1fC)", r.vOrpMv, r.tempC) + orpResult = r + } + case .temperature(let t): tempC = t @@ -545,6 +570,20 @@ final class AppState { send(buildSysexStartPh(stabilizeS: stab)) } + func startOrp() { + orpResult = nil + transport.measuring = true + let stab = Float(orpStabilize) ?? 30 + send(buildSysexGetTemp()) + send(buildSysexStartOrp(stabilizeS: stab)) + } + + func clearOrpHistory() { + orpHistory.removeAll() + orpT0 = nil + status = "ORP history cleared" + } + func phCalStartMeasurement() { guard let stabilize = Float(phCalStabilize) else { return } send(buildSysexPhCalPoint(bufferId: UInt8(phCalSelectedBuf), tempSlot: UInt8(phCalSelectedTslot), stabilizeS: stabilize)) @@ -583,6 +622,11 @@ final class AppState { phRef = r status = String(format: "pH reference set (%.2f)", r.ph) } + case .orp: + if let r = orpResult { + orpRef = r + status = String(format: "ORP reference set (%.0f mV)", r.vOrpMv) + } default: break } @@ -595,6 +639,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 .orp: orpRef = nil; status = "ORP reference cleared" case .calibrate, .sessions, .connection: break } } @@ -643,6 +688,7 @@ final class AppState { case .amp: ampRef != nil case .chlorine: clRef != nil case .ph: phRef != nil + case .orp: orpRef != nil case .calibrate, .sessions, .connection: false } } @@ -654,6 +700,7 @@ final class AppState { case .amp: !ampPoints.isEmpty case .chlorine: clResult != nil case .ph: phResult != nil + case .orp: orpResult != nil case .calibrate, .sessions, .connection: false } } @@ -755,6 +802,21 @@ final class AppState { try? Storage.shared.setMeasurementResult(mid, result: result) } + private func saveOrp(_ result: OrpResult) { + guard let sid = currentSessionId else { return } + let ts = pendingEspTimestamp + pendingEspTimestamp = nil + let params: [String: String] = [ + "stabilize_s": orpStabilize, + ] + guard let configData = try? JSONEncoder().encode(params) else { return } + guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .orp, espTimestamp: ts) else { return } + meas.config = configData + guard let mid = meas.id else { return } + try? Storage.shared.addDataPoint(measurementId: mid, index: 0, point: result) + try? Storage.shared.setMeasurementResult(mid, result: result) + } + // MARK: - Measurement loading func loadMeasurement(_ measurement: Measurement) { @@ -787,6 +849,12 @@ final class AppState { } tab = .ph status = "Loaded pH result" + case .orp: + if let summary = measurement.resultSummary { + orpResult = try JSONDecoder().decode(OrpResult.self, from: summary) + } + tab = .orp + status = "Loaded ORP result" } } catch { status = "Load failed: \(error.localizedDescription)" @@ -819,6 +887,11 @@ final class AppState { phRef = try JSONDecoder().decode(PhResult.self, from: summary) status = String(format: "pH reference loaded (%.2f)", phRef?.ph ?? 0) } + case .orp: + if let summary = measurement.resultSummary { + orpRef = try JSONDecoder().decode(OrpResult.self, from: summary) + status = String(format: "ORP reference loaded (%.0f mV)", orpRef?.vOrpMv ?? 0) + } } } catch { status = "Reference load failed: \(error.localizedDescription)" diff --git a/cue-ios/CueIOS/Assets.xcassets/AppIcon.appiconset/appicon-1024.png b/cue-ios/CueIOS/Assets.xcassets/AppIcon.appiconset/appicon-1024.png index fc59c2e..5933ffe 100644 Binary files a/cue-ios/CueIOS/Assets.xcassets/AppIcon.appiconset/appicon-1024.png and b/cue-ios/CueIOS/Assets.xcassets/AppIcon.appiconset/appicon-1024.png differ diff --git a/cue-ios/CueIOS/Models/DataTypes.swift b/cue-ios/CueIOS/Models/DataTypes.swift index 23c305b..4dc36d4 100644 --- a/cue-ios/CueIOS/Models/DataTypes.swift +++ b/cue-ios/CueIOS/Models/DataTypes.swift @@ -66,6 +66,17 @@ struct PhResult: Codable { var tempC: Float } +struct OrpResult: Codable { + var vOrpMv: Float + var tempC: Float +} + +struct OrpSample: Identifiable { + let id = UUID() + let tS: Float + let vMv: Float +} + struct PhCalCell { let ocpMv: Float let tempC: Float @@ -210,4 +221,5 @@ enum MeasurementType: String, Codable { case amp case chlorine case ph + case orp } diff --git a/cue-ios/CueIOS/Models/Protocol.swift b/cue-ios/CueIOS/Models/Protocol.swift index b0e703d..dc81456 100644 --- a/cue-ios/CueIOS/Models/Protocol.swift +++ b/cue-ios/CueIOS/Models/Protocol.swift @@ -25,6 +25,7 @@ let RSP_CL_END: UInt8 = 0x0E 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_REF_FRAME: UInt8 = 0x20 let RSP_REF_LP_RANGE: UInt8 = 0x21 let RSP_REFS_DONE: UInt8 = 0x22 @@ -53,6 +54,7 @@ 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_START_ORP: UInt8 = 0x2A let CMD_SET_CELL_K: UInt8 = 0x28 let CMD_GET_CELL_K: UInt8 = 0x29 let CMD_SET_CL_FACTOR: UInt8 = 0x33 @@ -161,6 +163,7 @@ enum EisMessage { case clResult(ClResult) case clEnd case phResult(PhResult) + case orpResult(OrpResult) case temperature(Float) case refFrame(mode: UInt8, rtiaIdx: UInt8) case refLpRange(mode: UInt8, lowIdx: UInt8, highIdx: UInt8) @@ -305,6 +308,12 @@ func parseSysex(_ data: [UInt8]) -> EisMessage? { tempC: decodeFloat(p, at: 10) )) + case RSP_ORP_RESULT where p.count >= 10: + return .orpResult(OrpResult( + vOrpMv: decodeFloat(p, at: 0), + tempC: decodeFloat(p, at: 5) + )) + case RSP_TEMP where p.count >= 5: return .temperature(decodeFloat(p, at: 0)) @@ -482,6 +491,13 @@ func buildSysexStartPh(stabilizeS: Float) -> [UInt8] { return sx } +func buildSysexStartOrp(stabilizeS: Float) -> [UInt8] { + var sx: [UInt8] = [0xF0, sysexMfr, CMD_START_ORP] + sx.append(contentsOf: encodeFloat(stabilizeS)) + 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/Models/Storage.swift b/cue-ios/CueIOS/Models/Storage.swift index 4b4f04e..83bb9b2 100644 --- a/cue-ios/CueIOS/Models/Storage.swift +++ b/cue-ios/CueIOS/Models/Storage.swift @@ -393,6 +393,14 @@ final class Storage: @unchecked Sendable { out += "\"Temperature (C)\" = \(tomlFloat(p.tempC))\n" } } + case .orp: + for dp in points { + if let p = try? decoder.decode(OrpResult.self, from: dp.payload) { + out += "\n[[measurement.data]]\n" + out += "\"ORP (mV)\" = \(tomlFloat(p.vOrpMv))\n" + out += "\"Temperature (C)\" = \(tomlFloat(p.tempC))\n" + } + } case nil: break } @@ -478,6 +486,11 @@ final class Storage: @unchecked Sendable { ph: floatVal(row, "pH"), tempC: floatVal(row, "Temperature (C)") )) + case .orp: + payload = try encoder.encode(OrpResult( + vOrpMv: floatVal(row, "ORP (mV)"), + tempC: floatVal(row, "Temperature (C)") + )) case nil: continue } @@ -501,6 +514,13 @@ final class Storage: @unchecked Sendable { ) try setMeasurementResult(mid, result: r) } + if mtype == .orp, let dataRows = mDict["data"] as? [[String: Any]], let first = dataRows.first { + let r = OrpResult( + vOrpMv: floatVal(first, "ORP (mV)"), + tempC: floatVal(first, "Temperature (C)") + ) + try setMeasurementResult(mid, result: r) + } } return sid } diff --git a/cue-ios/CueIOS/Views/ContentView.swift b/cue-ios/CueIOS/Views/ContentView.swift index 36f4fe3..9812adf 100644 --- a/cue-ios/CueIOS/Views/ContentView.swift +++ b/cue-ios/CueIOS/Views/ContentView.swift @@ -37,6 +37,7 @@ struct ContentView: View { sidebarButton(.amp, "Amperometry", "bolt.fill") sidebarButton(.chlorine, "Chlorine", "drop.fill") sidebarButton(.ph, "pH", "scalemass") + sidebarButton(.orp, "ORP", "bolt.circle.fill") } Section("Tools") { sidebarButton(.calibrate, "Calibrate", "lines.measurement.horizontal") @@ -127,6 +128,13 @@ struct ContentView: View { .tabItem { Label("pH", systemImage: "scalemass") } .tag(Tab.ph) + VStack(spacing: 0) { + StatusBar(state: state) + OrpView(state: state) + } + .tabItem { Label("ORP", systemImage: "bolt.circle.fill") } + .tag(Tab.orp) + CalibrateView(state: state) .tabItem { Label("Calibrate", systemImage: "lines.measurement.horizontal") } .tag(Tab.calibrate) @@ -151,6 +159,7 @@ struct ContentView: View { case .amp: AmpView(state: state) case .chlorine: ChlorineView(state: state) case .ph: PhView(state: state) + case .orp: OrpView(state: state) case .calibrate: CalibrateView(state: state) case .sessions: SessionView(state: state) case .connection: ConnectionView(state: state) diff --git a/cue-ios/CueIOS/Views/MeasurementDataView.swift b/cue-ios/CueIOS/Views/MeasurementDataView.swift index 65f21ac..fb6df7b 100644 --- a/cue-ios/CueIOS/Views/MeasurementDataView.swift +++ b/cue-ios/CueIOS/Views/MeasurementDataView.swift @@ -45,6 +45,8 @@ struct MeasurementDataView: View { ) case .ph: PhDataView(result: decodeResult(PhResult.self)) + case .orp: + OrpDataView(result: decodeResult(OrpResult.self)) case nil: Text("Unknown type: \(measurement.type)") .foregroundStyle(.secondary) @@ -58,6 +60,7 @@ struct MeasurementDataView: View { case "amp": "Amperometry" case "chlorine": "Chlorine" case "ph": "pH" + case "orp": "ORP" default: measurement.type } } @@ -639,3 +642,29 @@ struct PhDataView: View { } } +struct OrpDataView: View { + let result: OrpResult? + + var body: some View { + VStack(spacing: 0) { + if let r = result { + VStack(alignment: .leading, spacing: 12) { + Text(String(format: "ORP: %.0f mV", r.vOrpMv)) + .font(.system(size: 56, weight: .bold, design: .rounded)) + .foregroundStyle(.primary) + + Text(String(format: "Temperature: %.1f C", r.tempC)) + .font(.title3) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .padding() + } else { + Text("No ORP result") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } +} + diff --git a/cue-ios/CueIOS/Views/OrpView.swift b/cue-ios/CueIOS/Views/OrpView.swift new file mode 100644 index 0000000..61fdec4 --- /dev/null +++ b/cue-ios/CueIOS/Views/OrpView.swift @@ -0,0 +1,126 @@ +import SwiftUI +import Charts + +struct OrpView: View { + @Bindable var state: AppState + + var body: some View { + VStack(spacing: 0) { + controlsRow + Divider() + VStack(alignment: .leading, spacing: 12) { + headerValues + chart + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .padding() + } + } + + // MARK: - Controls + + private var controlsRow: some View { + HStack(spacing: 10) { + LabeledField("Stabilize s", text: $state.orpStabilize, width: 80) + + Button("Measure ORP") { state.startOrp() } + .buttonStyle(ActionButtonStyle(color: .green)) + + Button("Clear") { state.clearOrpHistory() } + .buttonStyle(ActionButtonStyle(color: Color(red: 0.55, green: 0.3, blue: 0.3))) + + Text("n=\(state.orpHistory.count)") + .font(.caption) + .foregroundStyle(.secondary) + + Spacer() + } + .padding(.horizontal) + .padding(.vertical, 8) + } + + // MARK: - Header + + @ViewBuilder + private var headerValues: some View { + if let r = state.orpResult { + VStack(alignment: .leading, spacing: 6) { + Text(String(format: "ORP: %.0f mV", r.vOrpMv)) + .font(.system(size: 40, weight: .bold, design: .rounded)) + .foregroundStyle(.primary) + + Text(String(format: "Temp: %.1f\u{00B0}C", r.tempC)) + .font(.subheadline) + .foregroundStyle(.secondary) + + if let refR = state.orpRef { + let dV = r.vOrpMv - refR.vOrpMv + Text(String(format: "vs Ref: dORP=%+.1f mV (ref=%.0f mV)", + dV, refR.vOrpMv)) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } else { + VStack(alignment: .leading, spacing: 6) { + Text("No measurement yet") + .font(.title3) + .foregroundStyle(.secondary) + Text("Open-circuit potential vs AgCl reference. Above ~650 mV indicates sanitary water.") + .font(.caption) + .foregroundStyle(Color(white: 0.4)) + } + } + } + + // MARK: - Chart + + @ViewBuilder + private var chart: some View { + if state.orpHistory.isEmpty { + RoundedRectangle(cornerRadius: 8) + .fill(Color.white.opacity(0.03)) + .overlay( + Text("No samples yet") + .font(.caption) + .foregroundStyle(.secondary) + ) + } else { + Chart(state.orpHistory) { sample in + LineMark( + x: .value("t", Double(sample.tS)), + y: .value("mV", Double(sample.vMv)) + ) + .foregroundStyle(Color.orange) + .lineStyle(StrokeStyle(lineWidth: 2)) + + PointMark( + x: .value("t", Double(sample.tS)), + y: .value("mV", Double(sample.vMv)) + ) + .foregroundStyle(Color.orange) + .symbolSize(30) + } + .chartXAxisLabel("t (s)") + .chartYAxisLabel("ORP (mV)") + .chartXAxis { + AxisMarks { _ in + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(Color.gray.opacity(0.3)) + AxisValueLabel() + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .chartYAxis { + AxisMarks(position: .leading) { _ in + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(Color.gray.opacity(0.3)) + AxisValueLabel() + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + } +} diff --git a/cue/assets/cue.icns b/cue/assets/cue.icns index 69cbfed..76ebd31 100644 Binary files a/cue/assets/cue.icns and b/cue/assets/cue.icns differ diff --git a/cue/src/app.rs b/cue/src/app.rs index a4ca6f7..f0c4a4b 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, - PhResult, Rcal, Rtia, + OrpResult, OrpSample, PhResult, Rcal, Rtia, }; use crate::storage::{self, Session, Storage}; use crate::udp::UdpEvent; @@ -50,6 +50,7 @@ pub enum Tab { Amp, Chlorine, Ph, + Orp, Calibrate, Browse, } @@ -121,6 +122,10 @@ pub enum Message { /* pH */ PhStabilizeChanged(String), StartPh, + /* ORP */ + OrpStabilizeChanged(String), + StartOrp, + ClearOrpHistory, /* Calibration */ CalVolumeChanged(String), CalNaclChanged(String), @@ -257,6 +262,12 @@ pub struct App { ph_result: Option, ph_stabilize: String, + /* ORP */ + orp_result: Option, + orp_stabilize: String, + orp_history: Vec, + orp_t0: Option, + /* measurement dedup */ current_esp_ts: Option, @@ -266,6 +277,7 @@ pub struct App { amp_ref: Option>, cl_ref: Option<(Vec, ClResult)>, ph_ref: Option, + orp_ref: Option, /* Device reference collection */ collecting_refs: bool, @@ -513,6 +525,11 @@ impl App { ph_result: None, ph_stabilize: "30".into(), + orp_result: None, + orp_stabilize: "30".into(), + orp_history: Vec::new(), + orp_t0: None, + current_esp_ts: None, eis_ref: None, @@ -520,6 +537,7 @@ impl App { amp_ref: None, cl_ref: None, ph_ref: None, + orp_ref: None, collecting_refs: false, ref_mode: None, @@ -649,6 +667,17 @@ impl App { } } + fn save_orp(&self, session_id: i64, result: &OrpResult) { + let params = serde_json::json!({ + "stabilize_s": self.orp_stabilize, + }); + if let Ok(mid) = self.storage.create_measurement(session_id, "orp", ¶ms.to_string(), self.current_esp_ts) { + if let Ok(j) = serde_json::to_string(result) { + let _ = self.storage.add_data_point(mid, 0, &j); + } + } + } + pub fn update(&mut self, message: Message) -> Task { match message { Message::DeviceReady(tx) => { @@ -836,6 +865,22 @@ impl App { self.ph_result = Some(r); } } + EisMessage::OrpResult(r, esp_ts, _) => { + if self.collecting_refs { + self.orp_ref = Some(r); + } else { + if let Some(sid) = self.current_session { + self.current_esp_ts = esp_ts; + self.save_orp(sid, &r); + } + let t0 = *self.orp_t0.get_or_insert_with(std::time::Instant::now); + let t_s = t0.elapsed().as_secs_f32(); + self.orp_history.push(OrpSample { t_s, v_mv: r.v_orp_mv }); + self.status = format!("ORP: {:.1} mV (T={:.1}C)", + r.v_orp_mv, r.temp_c); + self.orp_result = Some(r); + } + } EisMessage::Temperature(t) => { self.temp_c = t; } @@ -948,7 +993,7 @@ impl App { Tab::Lsv => self.lsv_data.perform(action), Tab::Amp => self.amp_data.perform(action), Tab::Chlorine => self.cl_data.perform(action), - Tab::Ph | Tab::Calibrate | Tab::Browse => {} + Tab::Ph | Tab::Orp | Tab::Calibrate | Tab::Browse => {} } } } @@ -1056,6 +1101,18 @@ impl App { self.send_cmd(&protocol::build_sysex_get_temp()); self.send_cmd(&protocol::build_sysex_start_ph(stab)); } + /* ORP */ + Message::OrpStabilizeChanged(s) => self.orp_stabilize = s, + Message::StartOrp => { + let stab = self.orp_stabilize.parse::().unwrap_or(30.0); + self.send_cmd(&protocol::build_sysex_get_temp()); + self.send_cmd(&protocol::build_sysex_start_orp(stab)); + } + Message::ClearOrpHistory => { + self.orp_history.clear(); + self.orp_t0 = None; + self.status = "ORP history cleared".into(); + } /* Reference baseline */ Message::SetReference => { match self.tab { @@ -1083,6 +1140,12 @@ impl App { self.status = format!("pH reference set ({:.2})", r.ph); } } + Tab::Orp => { + if let Some(r) = &self.orp_result { + self.orp_ref = Some(r.clone()); + self.status = format!("ORP reference set ({:.1} mV)", r.v_orp_mv); + } + } _ => {} } } @@ -1122,6 +1185,7 @@ impl App { Tab::Amp => { self.amp_ref = None; self.status = "Amp reference cleared".into(); } Tab::Chlorine => { self.cl_ref = None; self.status = "Chlorine reference cleared".into(); } Tab::Ph => { self.ph_ref = None; self.status = "pH reference cleared".into(); } + Tab::Orp => { self.orp_ref = None; self.status = "ORP reference cleared".into(); } Tab::Calibrate | Tab::Browse => {} } } @@ -1440,6 +1504,7 @@ impl App { tab_btn("Amperometry", Tab::Amp, self.tab == Tab::Amp), tab_btn("Chlorine", Tab::Chlorine, self.tab == Tab::Chlorine), tab_btn("pH", Tab::Ph, self.tab == Tab::Ph), + tab_btn("ORP", Tab::Orp, self.tab == Tab::Orp), tab_btn("Cal", Tab::Calibrate, self.tab == Tab::Calibrate), tab_btn("Browse", Tab::Browse, self.tab == Tab::Browse), ] @@ -1463,6 +1528,7 @@ impl App { Tab::Amp => self.amp_ref.is_some(), Tab::Chlorine => self.cl_ref.is_some(), Tab::Ph => self.ph_ref.is_some(), + Tab::Orp => self.orp_ref.is_some(), Tab::Calibrate | Tab::Browse => false, }; let has_data = match self.tab { @@ -1471,6 +1537,7 @@ impl App { Tab::Amp => !self.amp_points.is_empty(), Tab::Chlorine => self.cl_result.is_some(), Tab::Ph => self.ph_result.is_some(), + Tab::Orp => self.orp_result.is_some(), Tab::Calibrate | Tab::Browse => false, }; @@ -1554,6 +1621,8 @@ impl App { self.view_browse_body() } else if self.tab == Tab::Ph { self.view_ph_body() + } else if self.tab == Tab::Orp { + self.view_orp_body() } else if self.tab == Tab::Calibrate { self.view_cal_body() } else { @@ -1858,6 +1927,25 @@ impl App { .align_y(iced::Alignment::End) .into(), + Tab::Orp => row![ + column![ + text("Stabilize s").size(12), + text_input("30", &self.orp_stabilize).on_input(Message::OrpStabilizeChanged).width(80), + ].spacing(2), + button(text("Measure ORP").size(13)) + .style(style_action()) + .padding([6, 16]) + .on_press(Message::StartOrp), + button(text("Clear").size(13)) + .style(style_action()) + .padding([6, 12]) + .on_press(Message::ClearOrpHistory), + text(format!("n={}", self.orp_history.len())).size(12), + ] + .spacing(10) + .align_y(iced::Alignment::End) + .into(), + Tab::Calibrate | Tab::Browse => row![].into(), } } @@ -1961,7 +2049,7 @@ impl App { col.into() } - Tab::Ph | Tab::Calibrate | Tab::Browse => text("").into(), + Tab::Ph | Tab::Orp | Tab::Calibrate | Tab::Browse => text("").into(), } } @@ -1971,7 +2059,7 @@ impl App { Tab::Lsv => &self.lsv_data, Tab::Amp => &self.amp_data, Tab::Chlorine => &self.cl_data, - Tab::Ph | Tab::Calibrate | Tab::Browse => return text("").into(), + Tab::Ph | Tab::Orp | Tab::Calibrate | Tab::Browse => return text("").into(), }; text_editor(content) .on_action(Message::DataAction) @@ -2225,6 +2313,39 @@ impl App { } } + fn view_orp_body(&self) -> Element<'_, Message> { + let header: Element<'_, Message> = if let Some(r) = &self.orp_result { + let mut col = column![ + text(format!("ORP: {:.0} mV", r.v_orp_mv)).size(36), + text(format!("Temp: {:.1} C", r.temp_c)).size(14), + ].spacing(4); + if let Some(ref_r) = &self.orp_ref { + let d_v = r.v_orp_mv - ref_r.v_orp_mv; + col = col.push(text(format!( + "vs Ref: dORP={:+.1} mV (ref={:.0} mV)", + d_v, ref_r.v_orp_mv + )).size(14)); + } + col.into() + } else { + column![ + text("No measurement yet").size(16), + text("Open-circuit potential vs AgCl reference. Above ~650 mV indicates sanitary water.").size(12), + ].spacing(4).into() + }; + + let plot = canvas(crate::plot::OrpPlot { samples: &self.orp_history }) + .width(Length::Fill) + .height(Length::Fill); + + column![ + header, + plot, + ] + .spacing(8) + .into() + } + fn view_sysinfo(&self) -> Element<'_, Message> { container( column![ @@ -2515,6 +2636,15 @@ impl App { self.tab = Tab::Ph; } } + "orp" => { + if let Some(dp) = pts.first() + && let Ok(r) = serde_json::from_str::(&dp.data_json) + { + self.status = format!("Loaded ORP: {:.0} mV", r.v_orp_mv); + self.orp_result = Some(r); + self.tab = Tab::Orp; + } + } _ => {} } } @@ -2570,6 +2700,15 @@ impl App { self.tab = Tab::Ph; } } + "orp" => { + if let Some(dp) = pts.first() + && let Ok(r) = serde_json::from_str::(&dp.data_json) + { + self.status = format!("ORP ref loaded: {:.0} mV", r.v_orp_mv); + self.orp_ref = Some(r); + self.tab = Tab::Orp; + } + } _ => {} } } diff --git a/cue/src/plot.rs b/cue/src/plot.rs index 7d3de74..afb2ebd 100644 --- a/cue/src/plot.rs +++ b/cue/src/plot.rs @@ -4,7 +4,7 @@ use iced::mouse; use crate::app::Message; use crate::lsv_analysis::{LsvPeak, PeakKind}; -use crate::protocol::{AmpPoint, ClPoint, EisPoint, LsvPoint}; +use crate::protocol::{AmpPoint, ClPoint, EisPoint, LsvPoint, OrpSample}; const MARGIN_L: f32 = 55.0; const MARGIN_R: f32 = 15.0; @@ -1210,6 +1210,188 @@ impl<'a> canvas::Program for AmperogramPlot<'a> { } } +/* ---- ORP history ---- */ + +#[derive(Default)] +pub struct OrpState { + xv: Option, + yv: Option, + left_drag: Option<(Point, Vr, Vr)>, + right_drag: Option<(Point, Vr, Vr)>, +} + +pub struct OrpPlot<'a> { + pub samples: &'a [OrpSample], +} + +impl OrpPlot<'_> { + fn auto_view(&self) -> Option<(Vr, Vr)> { + let valid: Vec<_> = self.samples.iter() + .filter(|s| s.t_s.is_finite() && s.v_mv.is_finite()) + .collect(); + if valid.is_empty() { return None; } + let (xlo, xhi) = valid.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), s| { + (lo.min(s.t_s), hi.max(s.t_s)) + }); + let (ylo, yhi) = valid.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), s| { + (lo.min(s.v_mv), hi.max(s.v_mv)) + }); + let xpad = (xhi - xlo).max(10.0) * 0.05; + let ypad = (yhi - ylo).max(10.0) * 0.15; + Some((Vr::new(xlo - xpad, xhi + xpad), Vr::new(ylo - ypad, yhi + ypad))) + } + + fn effective_ranges(&self, state: &OrpState) -> (Vr, Vr) { + let auto = self.auto_view().unwrap_or((Vr::new(0.0, 60.0), Vr::new(0.0, 1000.0))); + (state.xv.unwrap_or(auto.0), state.yv.unwrap_or(auto.1)) + } +} + +impl<'a> canvas::Program for OrpPlot<'a> { + type State = OrpState; + + fn update( + &self, state: &mut OrpState, event: Event, + bounds: Rectangle, cursor: mouse::Cursor, + ) -> (canvas::event::Status, Option) { + if let Event::Mouse(mouse::Event::ButtonReleased(_)) = &event { + let was_right = state.right_drag.is_some(); + if was_right { + if let Some(pos) = cursor.position_in(bounds) { + let (start, _, _) = state.right_drag.unwrap(); + let dist = ((pos.x - start.x).powi(2) + (pos.y - start.y).powi(2)).sqrt(); + if dist < 3.0 { state.xv = None; state.yv = None; } + } + } + state.left_drag = None; + state.right_drag = None; + if was_right { return (canvas::event::Status::Captured, None); } + } + + let Some(pos) = cursor.position_in(bounds) else { + return (canvas::event::Status::Ignored, None); + }; + let xl = MARGIN_L; + let xr = bounds.width - MARGIN_R; + let yt = MARGIN_T; + let yb = bounds.height - MARGIN_B; + + match event { + Event::Mouse(mouse::Event::WheelScrolled { delta }) => { + let dy = match delta { + mouse::ScrollDelta::Lines { y, .. } => y, + mouse::ScrollDelta::Pixels { y, .. } => y / 40.0, + }; + let factor = ZOOM_FACTOR.powf(dy); + let (mut xv, mut yv) = self.effective_ranges(state); + xv.zoom_at(factor, screen_frac(pos.x, xl, xr)); + yv.zoom_at(factor, 1.0 - screen_frac(pos.y, yt, yb)); + state.xv = Some(xv); state.yv = Some(yv); + (canvas::event::Status::Captured, None) + } + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { + let (xv, yv) = self.effective_ranges(state); + state.left_drag = Some((pos, xv, yv)); + (canvas::event::Status::Captured, None) + } + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) => { + let (xv, yv) = self.effective_ranges(state); + state.right_drag = Some((pos, xv, yv)); + (canvas::event::Status::Captured, None) + } + Event::Mouse(mouse::Event::CursorMoved { .. }) => { + if let Some((start, sx, sy)) = state.left_drag { + let dx = (pos.x - start.x) / (xr - xl); + let dy = (pos.y - start.y) / (yb - yt); + let mut xv = sx; xv.pan_frac(dx); + let mut yv = sy; yv.pan_frac(-dy); + state.xv = Some(xv); state.yv = Some(yv); + return (canvas::event::Status::Captured, None); + } + if let Some((start, sx, sy)) = state.right_drag { + let dx = pos.x - start.x; + let dy = pos.y - start.y; + let xf = 2.0_f32.powf(dx / DRAG_ZOOM_RATE); + let yf = 2.0_f32.powf(-dy / DRAG_ZOOM_RATE); + let mut xv = sx; xv.zoom_at(xf, screen_frac(start.x, xl, xr)); + let mut yv = sy; yv.zoom_at(yf, 1.0 - screen_frac(start.y, yt, yb)); + state.xv = Some(xv); state.yv = Some(yv); + return (canvas::event::Status::Captured, None); + } + (canvas::event::Status::Ignored, None) + } + _ => (canvas::event::Status::Ignored, None), + } + } + + fn draw( + &self, state: &OrpState, renderer: &Renderer, _theme: &Theme, + bounds: Rectangle, cursor: mouse::Cursor, + ) -> Vec { + let mut frame = Frame::new(renderer, bounds.size()); + let (w, h) = (bounds.width, bounds.height); + + if self.samples.is_empty() { + dt(&mut frame, Point::new(w / 2.0 - 35.0, h / 2.0), + "No samples yet", COL_DIM, 13.0); + return vec![frame.into_geometry()]; + } + + let xl = MARGIN_L; + let xr = w - MARGIN_R; + let yt = MARGIN_T; + let yb = h - MARGIN_B; + + let (xv, yv) = self.effective_ranges(state); + + let x_step = nice_step(xv.span(), 5); + if x_step > 0.0 { + let mut g = (xv.lo / x_step).ceil() * x_step; + while g <= xv.hi { + let x = lerp(g, xv.lo, xv.hi, xl, xr); + dl(&mut frame, Point::new(x, yt), Point::new(x, yb), COL_GRID, 0.5); + dt(&mut frame, Point::new(x - 10.0, yb + 3.0), &format!("{:.0}", g), COL_DIM, 9.0); + g += x_step; + } + } + let y_step = nice_step(yv.span(), 4); + if y_step > 0.0 { + let mut g = (yv.lo / y_step).ceil() * y_step; + while g <= yv.hi { + let y = lerp(g, yv.hi, yv.lo, yt, yb); + dl(&mut frame, Point::new(xl, y), Point::new(xr, y), COL_GRID, 0.5); + dt(&mut frame, Point::new(2.0, y - 5.0), &format!("{:.0}", g), COL_PH, 9.0); + g += y_step; + } + } + + dt(&mut frame, Point::new(2.0, yt - 2.0), "ORP (mV)", COL_PH, 10.0); + dt(&mut frame, Point::new((xl + xr) / 2.0 - 10.0, yb + 3.0), "t (s)", COL_PH, 10.0); + + let pts: Vec = self.samples.iter().map(|s| Point::new( + lerp(s.t_s, xv.lo, xv.hi, xl, xr), + lerp(s.v_mv, yv.hi, yv.lo, yt, yb), + )).collect(); + draw_polyline(&mut frame, &pts, COL_PH, 2.0); + draw_dots(&mut frame, &pts, COL_PH, 3.0); + + if let Some(pos) = cursor.position_in(bounds) { + if pos.x >= xl && pos.x <= xr && pos.y >= yt && pos.y <= yb { + let t = lerp(pos.x, xl, xr, xv.lo, xv.hi); + let v = lerp(pos.y, yt, yb, yv.hi, yv.lo); + dl(&mut frame, Point::new(pos.x, yt), Point::new(pos.x, yb), + Color { a: 0.3, ..COL_AXIS }, 1.0); + dl(&mut frame, Point::new(xl, pos.y), Point::new(xr, pos.y), + Color { a: 0.3, ..COL_AXIS }, 1.0); + dt(&mut frame, Point::new(pos.x + 4.0, pos.y - 14.0), + &format!("{:.1}s, {:.0}mV", t, v), COL_AXIS, 10.0); + } + } + + vec![frame.into_geometry()] + } +} + /* ---- Chlorine (multi-step chronoamperometry) ---- */ #[derive(Default)] diff --git a/cue/src/protocol.rs b/cue/src/protocol.rs index 475fa19..adffea9 100644 --- a/cue/src/protocol.rs +++ b/cue/src/protocol.rs @@ -21,10 +21,11 @@ pub const RSP_CL_RESULT: u8 = 0x0D; pub const RSP_CL_END: u8 = 0x0E; 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_REF_FRAME: u8 = 0x20; pub const RSP_REF_LP_RANGE: u8 = 0x21; pub const RSP_REFS_DONE: u8 = 0x22; -pub const RSP_CELL_K: u8 = 0x11; pub const RSP_REF_STATUS: u8 = 0x23; pub const RSP_CL_FACTOR: u8 = 0x24; pub const RSP_PH_CAL: u8 = 0x25; @@ -47,6 +48,7 @@ pub const CMD_GET_TEMP: u8 = 0x17; pub const CMD_START_CL: u8 = 0x23; pub const CMD_START_PH: u8 = 0x24; pub const CMD_START_CLEAN: u8 = 0x25; +pub const CMD_START_ORP: u8 = 0x2A; pub const CMD_SET_CELL_K: u8 = 0x28; pub const CMD_GET_CELL_K: u8 = 0x29; pub const CMD_SET_CL_FACTOR: u8 = 0x33; @@ -236,6 +238,18 @@ pub struct PhResult { pub temp_c: f32, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OrpResult { + pub v_orp_mv: f32, + pub temp_c: f32, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct OrpSample { + pub t_s: f32, + pub v_mv: f32, +} + #[derive(Debug, Clone)] pub struct EisConfig { pub freq_start: f32, @@ -265,6 +279,7 @@ pub enum EisMessage { ClResult(ClResult), ClEnd, PhResult(PhResult, Option, Option), + OrpResult(OrpResult, Option, Option), Temperature(f32), RefFrame { mode: u8, rtia_idx: u8 }, RefLpRange { mode: u8, low_idx: u8, high_idx: u8 }, @@ -446,6 +461,16 @@ pub fn parse_sysex(data: &[u8]) -> Option { temp_c: decode_float(&p[10..15]), }, ts, mid)) } + RSP_ORP_RESULT if data.len() >= 12 => { + let p = &data[2..]; + let (ts, mid) = if p.len() >= 18 { + (Some(decode_u32(&p[10..15])), Some(decode_u16(&p[15..18]))) + } else { (None, None) }; + Some(EisMessage::OrpResult(OrpResult { + v_orp_mv: decode_float(&p[0..5]), + temp_c: decode_float(&p[5..10]), + }, ts, mid)) + } RSP_REF_FRAME if data.len() >= 4 => { Some(EisMessage::RefFrame { mode: data[2], rtia_idx: data[3] }) } @@ -580,6 +605,13 @@ pub fn build_sysex_start_ph(stabilize_s: f32) -> Vec { sx } +pub fn build_sysex_start_orp(stabilize_s: f32) -> Vec { + let mut sx = vec![0xF0, SYSEX_MFR, CMD_START_ORP]; + sx.extend_from_slice(&encode_float(stabilize_s)); + sx.push(0xF7); + sx +} + pub fn build_sysex_start_clean(v_mv: f32, duration_s: f32) -> Vec { let mut sx = vec![0xF0, SYSEX_MFR, CMD_START_CLEAN]; sx.extend_from_slice(&encode_float(v_mv)); diff --git a/cue/src/storage.rs b/cue/src/storage.rs index 65b28d2..d537cc6 100644 --- a/cue/src/storage.rs +++ b/cue/src/storage.rs @@ -356,6 +356,10 @@ fn data_point_to_toml(mtype: &str, jv: &serde_json::Value) -> Option { t.insert("pH".into(), toml_f(obj, "ph")?); t.insert("Temperature (C)".into(), toml_f(obj, "temp_c")?); } + "orp" => { + t.insert("ORP (mV)".into(), toml_f(obj, "v_orp_mv")?); + t.insert("Temperature (C)".into(), toml_f(obj, "temp_c")?); + } _ => return None, } Some(t) @@ -427,6 +431,10 @@ fn toml_data_row_to_json(mtype: &str, row: &Table) -> serde_json::Value { set_f(&mut obj, "ph", row, "pH"); set_f(&mut obj, "temp_c", row, "Temperature (C)"); } + "orp" => { + set_f(&mut obj, "v_orp_mv", row, "ORP (mV)"); + set_f(&mut obj, "temp_c", row, "Temperature (C)"); + } _ => {} } serde_json::Value::Object(obj) diff --git a/main/echem.c b/main/echem.c index b115bcf..2cb1573 100644 --- a/main/echem.c +++ b/main/echem.c @@ -645,3 +645,14 @@ int echem_ph_ocp(const PhConfig *cfg, PhResult *result) AD5940_AFECtrlS(AFECTRL_ALL, bFALSE); return 0; } + +int echem_orp_read(const OrpConfig *cfg, OrpResult *result) +{ + PhResult ph_res; + int rc = echem_ph_ocp(cfg, &ph_res); + if (rc != 0) return rc; + result->v_orp_mv = ph_res.v_ocp_mv; + result->temp_c = ph_res.temp_c; + printf("ORP: %.1f mV @ %.1f C\n", result->v_orp_mv, result->temp_c); + return 0; +} diff --git a/main/echem.h b/main/echem.h index 5e63c47..bf17298 100644 --- a/main/echem.h +++ b/main/echem.h @@ -97,6 +97,14 @@ typedef struct { float temp_c; /* temperature used */ } PhResult; +/* ORP: raw open-circuit potential without pH calibration */ +typedef PhConfig OrpConfig; + +typedef struct { + float v_orp_mv; /* raw OCP: V(SE0) - V(RE0) in mV */ + float temp_c; /* temperature at measurement */ +} OrpResult; + 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); @@ -113,5 +121,6 @@ int echem_lsv(const LSVConfig *cfg, LSVPoint *out, uint32_t max_points, lsv_poin int echem_amp(const AmpConfig *cfg, AmpPoint *out, uint32_t max_points, amp_point_cb_t cb); int echem_chlorine(const ClConfig *cfg, ClPoint *out, uint32_t max_points, ClResult *result, cl_point_cb_t cb); int echem_ph_ocp(const PhConfig *cfg, PhResult *result); +int echem_orp_read(const OrpConfig *cfg, OrpResult *result); #endif diff --git a/main/eis.c b/main/eis.c index be02577..8ddf1e0 100644 --- a/main/eis.c +++ b/main/eis.c @@ -238,9 +238,11 @@ static void configure_freq(float freq_hz) fp.DftSrc = DFTSRC_ADCRAW; fp.ADCSinc3Osr = ADCSINC3OSR_2; fp.ADCSinc2Osr = 0; - fp.DftNum = DFTNUM_4096; } + /* widest DFT window to suppress non-coherent leakage */ + fp.DftNum = DFTNUM_16384; + AD5940_WriteReg(REG_AFE_WGFCW, AD5940_WGFreqWordCal(freq_hz, ctx.sys_clk)); @@ -271,8 +273,17 @@ static int32_t sign_extend_18(uint32_t v) return (v & (1UL << 17)) ? (int32_t)(v | 0xFFFC0000UL) : (int32_t)v; } +/* settles for a fixed count of excitation periods, floored for high-frequency overhead */ +static void settle(float freq_hz, float cycles, uint32_t floor_us) +{ + float us = cycles * 1e6f / freq_hz; + uint32_t d_us = (us > (float)floor_us) ? (uint32_t)us : floor_us; + AD5940_Delay10us(d_us / 10); +} + /* paired DFT: two measurements under continuous WG excitation */ static void dft_measure_pair( + float freq_hz, uint32_t mux1_p, uint32_t mux1_n, iImpCar_Type *out1, uint32_t mux2_p, uint32_t mux2_n, iImpCar_Type *out2) { @@ -284,7 +295,7 @@ static void dft_measure_pair( AD5940_ADCMuxCfgS(mux1_p, mux1_n); AD5940_AFECtrlS(AFECTRL_WG | AFECTRL_ADCPWR, bTRUE); - AD5940_Delay10us(25); + settle(freq_hz, 2.0f, 100); AD5940_ClrMCUIntFlag(); AD5940_AFECtrlS(AFECTRL_ADCCNV | AFECTRL_DFT, bTRUE); @@ -297,12 +308,12 @@ static void dft_measure_pair( out1->Image = sign_extend_18(AD5940_ReadAfeResult(AFERESULT_DFTIMAGE)); out1->Image = -out1->Image; - /* switch ADC mux, flush stale pipeline, short settle */ + /* switch ADC mux, flush stale pipeline, settle one period */ AD5940_ADCMuxCfgS(mux2_p, mux2_n); AD5940_ReadAfeResult(AFERESULT_DFTREAL); AD5940_ReadAfeResult(AFERESULT_DFTIMAGE); AD5940_INTCClrFlag(AFEINTSRC_DFTRDY); - AD5940_Delay10us(5); + settle(freq_hz, 1.0f, 50); AD5940_ClrMCUIntFlag(); AD5940_AFECtrlS(AFECTRL_ADCCNV | AFECTRL_DFT, bTRUE); @@ -317,10 +328,10 @@ static void dft_measure_pair( out2->Image = -out2->Image; } -static fImpCar_Type measure_rtia(iImpCar_Type *out_hstia) +static fImpCar_Type measure_rtia(float freq_hz, iImpCar_Type *out_hstia) { iImpCar_Type v_rcal, v_raw; - dft_measure_pair( + dft_measure_pair(freq_hz, ADCMUXP_P_NODE, ADCMUXN_N_NODE, &v_rcal, ADCMUXP_HSTIA_P, ADCMUXN_HSTIA_N, &v_raw); if (out_hstia) *out_hstia = v_raw; @@ -336,142 +347,88 @@ static fImpCar_Type measure_rtia(iImpCar_Type *out_hstia) int eis_measure_point(float freq_hz, EISPoint *out) { - configure_freq(freq_hz); + configure_freq(freq_hz); - SWMatrixCfg_Type sw; - iImpCar_Type v_tia, v_sense; + SWMatrixCfg_Type sw; + iImpCar_Type v_tia, v_sense; - /* switch to RCAL before power-up */ - sw.Dswitch = ctx.rcal_sw_d; - sw.Pswitch = ctx.rcal_sw_p; - sw.Nswitch = ctx.rcal_sw_n; - sw.Tswitch = ctx.rcal_sw_t; - AD5940_SWMatrixCfgS(&sw); + /* RCAL reference before power-up */ + sw.Dswitch = ctx.rcal_sw_d; + sw.Pswitch = ctx.rcal_sw_p; + sw.Nswitch = ctx.rcal_sw_n; + sw.Tswitch = ctx.rcal_sw_t; + AD5940_SWMatrixCfgS(&sw); - AD5940_AFECtrlS(AFECTRL_HPREFPWR | AFECTRL_HSTIAPWR | AFECTRL_INAMPPWR | - AFECTRL_EXTBUFPWR | AFECTRL_DACREFPWR | AFECTRL_HSDACPWR | - AFECTRL_SINC2NOTCH, bTRUE); + AD5940_AFECtrlS(AFECTRL_HPREFPWR | AFECTRL_HSTIAPWR | AFECTRL_INAMPPWR | + AFECTRL_EXTBUFPWR | AFECTRL_DACREFPWR | AFECTRL_HSDACPWR | + AFECTRL_SINC2NOTCH, bTRUE); - /* RCAL before — capture raw HSTIA DFT for ratiometric diagnostic */ - iImpCar_Type rcal_hstia; - fImpCar_Type rtia_before = measure_rtia(&rcal_hstia); + /* RCAL reference: raw HSTIA DFT plus measured RTIA */ + iImpCar_Type rcal_hstia; + fImpCar_Type rtia = measure_rtia(freq_hz, &rcal_hstia); - /* DUT forward */ - sw.Dswitch = ctx.dut_sw_d; - sw.Pswitch = ctx.dut_sw_p; - sw.Nswitch = ctx.dut_sw_n; - sw.Tswitch = ctx.dut_sw_t; - AD5940_SWMatrixCfgS(&sw); - AD5940_Delay10us(50); + /* DUT: raw HSTIA DFT */ + sw.Dswitch = ctx.dut_sw_d; + sw.Pswitch = ctx.dut_sw_p; + sw.Nswitch = ctx.dut_sw_n; + sw.Tswitch = ctx.dut_sw_t; + AD5940_SWMatrixCfgS(&sw); + settle(freq_hz, 2.0f, 200); - dft_measure_pair(ADCMUXP_HSTIA_P, ADCMUXN_HSTIA_N, &v_tia, - ctx.dut_mux_vp, ctx.dut_mux_vn, &v_sense); - iImpCar_Type dut_hstia_raw = v_tia; - v_tia.Real = -v_tia.Real; - v_tia.Image = -v_tia.Image; + dft_measure_pair(freq_hz, + ADCMUXP_HSTIA_P, ADCMUXN_HSTIA_N, &v_tia, + ctx.dut_mux_vp, ctx.dut_mux_vn, &v_sense); + iImpCar_Type dut_hstia_raw = v_tia; + (void)v_sense; - iImpCar_Type v_tia_fwd = v_tia; - iImpCar_Type v_sense_fwd = v_sense; + /* power down, open switches */ + AD5940_AFECtrlS(AFECTRL_WG | AFECTRL_ADCPWR | AFECTRL_ADCCNV | + AFECTRL_DFT | AFECTRL_SINC2NOTCH | AFECTRL_HSDACPWR | + AFECTRL_HSTIAPWR | AFECTRL_INAMPPWR | + AFECTRL_EXTBUFPWR, bFALSE); - /* RCAL after */ - sw.Dswitch = ctx.rcal_sw_d; - sw.Pswitch = ctx.rcal_sw_p; - sw.Nswitch = ctx.rcal_sw_n; - sw.Tswitch = ctx.rcal_sw_t; - AD5940_SWMatrixCfgS(&sw); - AD5940_Delay10us(50); + sw.Dswitch = SWD_OPEN; + sw.Pswitch = SWP_OPEN; + sw.Nswitch = SWN_OPEN; + sw.Tswitch = SWT_OPEN; + AD5940_SWMatrixCfgS(&sw); - fImpCar_Type rtia_after = measure_rtia(NULL); + /* ratiometric Z: (DftRcal / DftDut) * RCAL */ + fImpCar_Type fr = { (float)rcal_hstia.Real, (float)rcal_hstia.Image }; + fImpCar_Type fd = { (float)dut_hstia_raw.Real, (float)dut_hstia_raw.Image }; + fImpCar_Type z = AD5940_ComplexDivFloat(&fr, &fd); + z.Real *= ctx.rcal_ohms; + z.Image *= ctx.rcal_ohms; - /* DUT reverse (DUT first, then RCAL) */ - sw.Dswitch = ctx.dut_sw_d; - sw.Pswitch = ctx.dut_sw_p; - sw.Nswitch = ctx.dut_sw_n; - sw.Tswitch = ctx.dut_sw_t; - AD5940_SWMatrixCfgS(&sw); - AD5940_Delay10us(50); + /* apply open-circuit compensation if available */ + if (ocal.valid) { + for (uint32_t k = 0; k < ocal.n; k++) { + if (fabsf(ocal.freq[k] - freq_hz) < freq_hz * 0.01f) { + fImpCar_Type one = {1.0f, 0.0f}; + fImpCar_Type y_meas = AD5940_ComplexDivFloat(&one, &z); + fImpCar_Type y_corr = AD5940_ComplexSubFloat(&y_meas, &ocal.y[k]); + z = AD5940_ComplexDivFloat(&one, &y_corr); + break; + } + } + } - dft_measure_pair(ADCMUXP_HSTIA_P, ADCMUXN_HSTIA_N, &v_tia, - ctx.dut_mux_vp, ctx.dut_mux_vn, &v_sense); - v_tia.Real = -v_tia.Real; - v_tia.Image = -v_tia.Image; + float mag = AD5940_ComplexMag(&z); + float phase = AD5940_ComplexPhase(&z) * (float)(180.0 / M_PI); + float rtia_mag = AD5940_ComplexMag(&rtia); - iImpCar_Type v_tia_rev = v_tia; - iImpCar_Type v_sense_rev = v_sense; + out->freq_hz = freq_hz; + out->z_real = z.Real; + out->z_imag = z.Image; + out->mag_ohms = mag; + out->phase_deg = phase; + out->rtia_mag_before = rtia_mag; + out->rtia_mag_after = rtia_mag; + out->rev_mag = mag; + out->rev_phase = phase; + out->pct_err = 0.0f; - /* RCAL reverse */ - sw.Dswitch = ctx.rcal_sw_d; - sw.Pswitch = ctx.rcal_sw_p; - sw.Nswitch = ctx.rcal_sw_n; - sw.Tswitch = ctx.rcal_sw_t; - AD5940_SWMatrixCfgS(&sw); - AD5940_Delay10us(50); - - fImpCar_Type rtia_rev = measure_rtia(NULL); - - /* power down, open switches */ - AD5940_AFECtrlS(AFECTRL_WG | AFECTRL_ADCPWR | AFECTRL_ADCCNV | - AFECTRL_DFT | AFECTRL_SINC2NOTCH | AFECTRL_HSDACPWR | - AFECTRL_HSTIAPWR | AFECTRL_INAMPPWR | - AFECTRL_EXTBUFPWR, bFALSE); - - sw.Dswitch = SWD_OPEN; - sw.Pswitch = SWP_OPEN; - sw.Nswitch = SWN_OPEN; - sw.Tswitch = SWT_OPEN; - AD5940_SWMatrixCfgS(&sw); - - /* forward Z using averaged RTIA bracket */ - fImpCar_Type rtia_avg = { - .Real = (rtia_before.Real + rtia_after.Real) * 0.5f, - .Image = (rtia_before.Image + rtia_after.Image) * 0.5f, - }; - fImpCar_Type fs_fwd = { (float)v_sense_fwd.Real, (float)v_sense_fwd.Image }; - fImpCar_Type ft_fwd = { (float)v_tia_fwd.Real, (float)v_tia_fwd.Image }; - fImpCar_Type num = AD5940_ComplexMulFloat(&fs_fwd, &rtia_avg); - fImpCar_Type z_fwd = AD5940_ComplexDivFloat(&num, &ft_fwd); - - /* reverse Z using RTIA from RCAL measured after DUT */ - fImpCar_Type fs_rev = { (float)v_sense_rev.Real, (float)v_sense_rev.Image }; - fImpCar_Type ft_rev = { (float)v_tia_rev.Real, (float)v_tia_rev.Image }; - num = AD5940_ComplexMulFloat(&fs_rev, &rtia_rev); - fImpCar_Type z_rev = AD5940_ComplexDivFloat(&num, &ft_rev); - (void)z_rev; - - /* HSTIA-only ratiometric: Z = (DftRcal / DftDut) * RCAL */ - fImpCar_Type fr = { (float)rcal_hstia.Real, (float)rcal_hstia.Image }; - fImpCar_Type fd = { (float)dut_hstia_raw.Real, (float)dut_hstia_raw.Image }; - fImpCar_Type z_ratio = AD5940_ComplexDivFloat(&fr, &fd); - z_ratio.Real *= ctx.rcal_ohms; - z_ratio.Image *= ctx.rcal_ohms; - - /* apply open-circuit compensation if available */ - if (ocal.valid) { - for (uint32_t k = 0; k < ocal.n; k++) { - if (fabsf(ocal.freq[k] - freq_hz) < freq_hz * 0.01f) { - fImpCar_Type one = {1.0f, 0.0f}; - fImpCar_Type y_meas = AD5940_ComplexDivFloat(&one, &z_fwd); - fImpCar_Type y_corr = AD5940_ComplexSubFloat(&y_meas, &ocal.y[k]); - z_fwd = AD5940_ComplexDivFloat(&one, &y_corr); - break; - } - } - } - - float mag_fwd = AD5940_ComplexMag(&z_fwd); - - out->freq_hz = freq_hz; - out->z_real = z_fwd.Real; - out->z_imag = z_fwd.Image; - out->mag_ohms = mag_fwd; - out->phase_deg = AD5940_ComplexPhase(&z_fwd) * (float)(180.0 / M_PI); - out->rtia_mag_before = AD5940_ComplexMag(&rtia_before); - out->rtia_mag_after = AD5940_ComplexMag(&rtia_after); - out->rev_mag = AD5940_ComplexMag(&z_ratio); - out->rev_phase = AD5940_ComplexPhase(&z_ratio) * (float)(180.0 / M_PI); - out->pct_err = 0.0f; - - return 0; + return 0; } int eis_sweep(EISPoint *out, uint32_t max_points, eis_point_cb_t cb) @@ -491,19 +448,16 @@ int eis_sweep(EISPoint *out, uint32_t max_points, eis_point_cb_t cb) sweep.SweepLog = bTRUE; sweep.SweepIndex = 0; - printf("\n%10s %12s %10s %12s %12s | %12s %10s %6s\n", - "Freq(Hz)", "|Z|dual", "Ph_dual", "Re_dual", "Im_dual", - "|Z|ratio", "Ph_ratio", "ms"); - printf("--------------------------------------------------------------------------" - "-------------------------\n"); + printf("\n%10s %12s %10s %12s %12s %6s\n", + "Freq(Hz)", "|Z|", "Phase", "Re", "Im", "ms"); + printf("------------------------------------------------------------------\n"); uint32_t t0 = xTaskGetTickCount(); eis_measure_point(ctx.cfg.freq_start_hz, &out[0]); uint32_t t1 = xTaskGetTickCount(); - printf("%10.1f %12.2f %10.2f %12.2f %12.2f | %12.2f %10.2f %6lu\n", + printf("%10.1f %12.2f %10.2f %12.2f %12.2f %6lu\n", out[0].freq_hz, out[0].mag_ohms, out[0].phase_deg, out[0].z_real, out[0].z_imag, - out[0].rev_mag, out[0].rev_phase, (unsigned long)((t1 - t0) * portTICK_PERIOD_MS)); if (cb) cb(0, &out[0]); @@ -513,10 +467,9 @@ int eis_sweep(EISPoint *out, uint32_t max_points, eis_point_cb_t cb) t0 = xTaskGetTickCount(); eis_measure_point(freq, &out[i]); t1 = xTaskGetTickCount(); - printf("%10.1f %12.2f %10.2f %12.2f %12.2f | %12.2f %10.2f %6lu\n", + printf("%10.1f %12.2f %10.2f %12.2f %12.2f %6lu\n", out[i].freq_hz, out[i].mag_ohms, out[i].phase_deg, out[i].z_real, out[i].z_imag, - out[i].rev_mag, out[i].rev_phase, (unsigned long)((t1 - t0) * portTICK_PERIOD_MS)); if (cb) cb((uint16_t)i, &out[i]); } diff --git a/main/eis4.c b/main/eis4.c index b79f3aa..3003d52 100644 --- a/main/eis4.c +++ b/main/eis4.c @@ -193,6 +193,24 @@ void app_main(void) break; } + case CMD_START_ORP: { + OrpConfig orp_cfg; + orp_cfg.stabilize_s = cmd.orp.stabilize_s; + orp_cfg.temp_c = temp_get(); + printf("ORP: stabilize %.0f s, temp %.1f C\n", + orp_cfg.stabilize_s, orp_cfg.temp_c); + + OrpResult orp_result; + echem_orp_read(&orp_cfg, &orp_result); + { + uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000); + measurement_counter++; + send_orp_result(orp_result.v_orp_mv, orp_result.temp_c, + 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); diff --git a/main/protocol.c b/main/protocol.c index cddea1d..f438ca5 100644 --- a/main/protocol.c +++ b/main/protocol.c @@ -413,6 +413,22 @@ int send_ph_result(float v_ocp_mv, float ph, float temp_c, return send_sysex(sx, p); } +/* ---- outbound: ORP ---- */ + +int send_orp_result(float v_orp_mv, float temp_c, + uint32_t ts_ms, uint16_t meas_id) +{ + uint8_t sx[24]; + uint16_t p = 0; + sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_ORP_RESULT; + encode_float(v_orp_mv, &sx[p]); p += 5; + encode_float(temp_c, &sx[p]); p += 5; + 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: temperature ---- */ int send_temp(float temp_c) diff --git a/main/protocol.h b/main/protocol.h index 67abd42..177a670 100644 --- a/main/protocol.h +++ b/main/protocol.h @@ -19,6 +19,7 @@ #define CMD_START_CL 0x23 #define CMD_START_PH 0x24 #define CMD_START_CLEAN 0x25 +#define CMD_START_ORP 0x2A #define CMD_OPEN_CAL 0x26 #define CMD_CLEAR_OPEN_CAL 0x27 #define CMD_SET_CELL_K 0x28 @@ -58,6 +59,7 @@ #define RSP_PH_RESULT 0x0F #define RSP_TEMP 0x10 #define RSP_CELL_K 0x11 +#define RSP_ORP_RESULT 0x12 #define RSP_REF_FRAME 0x20 #define RSP_REF_LP_RANGE 0x21 #define RSP_REFS_DONE 0x22 @@ -95,6 +97,7 @@ typedef struct { struct { float v_hold, interval_ms, duration_s; uint8_t lp_rtia; } amp; 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_mv; float duration_s; } clean; float cell_k; float cl_factor; @@ -150,6 +153,10 @@ int send_cl_end(void); int send_ph_result(float v_ocp_mv, float ph, float temp_c, uint32_t ts_ms, uint16_t meas_id); +/* outbound: ORP */ +int send_orp_result(float v_orp_mv, float temp_c, + uint32_t ts_ms, uint16_t meas_id); + /* outbound: temperature */ int send_temp(float temp_c); diff --git a/main/wifi_transport.c b/main/wifi_transport.c index 04d1f75..03acb7f 100644 --- a/main/wifi_transport.c +++ b/main/wifi_transport.c @@ -173,6 +173,10 @@ static void parse_udp_sysex(const uint8_t *data, uint16_t len) if (len < 8) return; cmd.ph.stabilize_s = decode_float(&data[3]); break; + case CMD_START_ORP: + if (len < 8) return; + cmd.orp.stabilize_s = decode_float(&data[3]); + break; case CMD_START_CLEAN: if (len < 13) return; cmd.clean.v_mv = decode_float(&data[3]);