new electrode array

This commit is contained in:
jess 2026-06-10 03:35:24 -07:00
parent cf3aa05426
commit ce1a413bae
16 changed files with 1246 additions and 98 deletions

View File

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

View File

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

View File

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

View File

@ -109,6 +109,7 @@ final class UDPManager: @unchecked Sendable {
send(buildSysexGetClFactor())
send(buildSysexGetPhCal())
send(buildSysexPhCalStatus())
send(buildSysexGetPhScan())
startTimers()
receiveLoop()

View File

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

View File

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

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,
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<PhResult>,
ph_stabilize: String,
ph_volt_result: Option<PhVoltResult>,
ph_ideality: Option<PhIdeality>,
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<OrpResult>,
@ -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> {

View File

@ -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<u32>, Option<u16>),
PhVoltResult(PhVoltResult, Option<u32>, Option<u16>),
PhIdeality(PhIdeality),
PhScan(PhScanCfg),
OrpResult(OrpResult, Option<u32>, Option<u16>),
Temperature(f32),
RefFrame { mode: u8, rtia_idx: u8 },
@ -461,6 +505,46 @@ pub fn parse_sysex(data: &[u8]) -> Option<EisMessage> {
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<u8> {
pub fn build_sysex_ph_cal_status() -> Vec<u8> {
vec![0xF0, SYSEX_MFR, CMD_PH_CAL_STATUS, 0xF7]
}
pub fn build_sysex_start_ph_volt() -> Vec<u8> {
vec![0xF0, SYSEX_MFR, CMD_START_PH_VOLT, 0xF7]
}
pub fn build_sysex_get_ph_ideality() -> Vec<u8> {
vec![0xF0, SYSEX_MFR, CMD_GET_PH_IDEALITY, 0xF7]
}
pub fn build_sysex_get_ph_scan() -> Vec<u8> {
vec![0xF0, SYSEX_MFR, CMD_GET_PH_SCAN, 0xF7]
}
pub fn build_sysex_set_ph_scan(cfg: &PhScanCfg) -> Vec<u8> {
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
}

View File

@ -2,6 +2,7 @@
#include "eis.h"
#include "ad5940.h"
#include "protocol.h"
#include "nvs.h"
#include <math.h>
#include <string.h>
#include <stdio.h>
@ -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;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
#include "protocol.h"
#include "eis.h"
#include "echem.h"
#include "wifi_transport.h"
#include <string.h>
#include <stdio.h>
@ -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)

View File

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

View File

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