longer settling time for lower freqs

This commit is contained in:
jess 2026-05-30 21:37:36 -07:00
parent 332aeb10d6
commit c98445b377
20 changed files with 808 additions and 144 deletions

View File

@ -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)"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

After

Width:  |  Height:  |  Size: 179 KiB

View File

@ -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
}

View File

@ -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))

View File

@ -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
}

View File

@ -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)

View File

@ -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)
}
}
}
}

View File

@ -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)
}
}
}
}
}

Binary file not shown.

View File

@ -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<PhResult>,
ph_stabilize: String,
/* ORP */
orp_result: Option<OrpResult>,
orp_stabilize: String,
orp_history: Vec<OrpSample>,
orp_t0: Option<std::time::Instant>,
/* measurement dedup */
current_esp_ts: Option<u32>,
@ -266,6 +277,7 @@ pub struct App {
amp_ref: Option<Vec<AmpPoint>>,
cl_ref: Option<(Vec<ClPoint>, ClResult)>,
ph_ref: Option<PhResult>,
orp_ref: Option<OrpResult>,
/* 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", &params.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<Message> {
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::<f32>().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::<OrpResult>(&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::<OrpResult>(&dp.data_json)
{
self.status = format!("ORP ref loaded: {:.0} mV", r.v_orp_mv);
self.orp_ref = Some(r);
self.tab = Tab::Orp;
}
}
_ => {}
}
}

View File

@ -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<Message> for AmperogramPlot<'a> {
}
}
/* ---- ORP history ---- */
#[derive(Default)]
pub struct OrpState {
xv: Option<Vr>,
yv: Option<Vr>,
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<Message> for OrpPlot<'a> {
type State = OrpState;
fn update(
&self, state: &mut OrpState, event: Event,
bounds: Rectangle, cursor: mouse::Cursor,
) -> (canvas::event::Status, Option<Message>) {
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<Geometry> {
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<Point> = 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)]

View File

@ -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<u32>, Option<u16>),
OrpResult(OrpResult, Option<u32>, Option<u16>),
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<EisMessage> {
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<u8> {
sx
}
pub fn build_sysex_start_orp(stabilize_s: f32) -> Vec<u8> {
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<u8> {
let mut sx = vec![0xF0, SYSEX_MFR, CMD_START_CLEAN];
sx.extend_from_slice(&encode_float(v_mv));

View File

@ -356,6 +356,10 @@ fn data_point_to_toml(mtype: &str, jv: &serde_json::Value) -> Option<Table> {
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)

View File

@ -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;
}

View File

@ -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

View File

@ -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]);
}

View File

@ -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);

View File

@ -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)

View File

@ -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);

View File

@ -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]);