new electrode array
This commit is contained in:
parent
cf3aa05426
commit
ce1a413bae
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ final class UDPManager: @unchecked Sendable {
|
|||
send(buildSysexGetClFactor())
|
||||
send(buildSysexGetPhCal())
|
||||
send(buildSysexPhCalStatus())
|
||||
send(buildSysexGetPhScan())
|
||||
startTimers()
|
||||
receiveLoop()
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
242
cue/src/app.rs
242
cue/src/app.rs
|
|
@ -12,7 +12,7 @@ use tokio::sync::mpsc;
|
|||
use crate::native_menu::{MenuAction, NativeMenu};
|
||||
use crate::protocol::{
|
||||
self, AmpPoint, ClPoint, ClResult, Electrode, EisMessage, EisPoint, LpRtia, LsvPoint,
|
||||
OrpResult, OrpSample, PhResult, Rcal, Rtia,
|
||||
OrpResult, OrpSample, PhIdeality, PhResult, PhScanCfg, PhVoltResult, Rcal, Rtia,
|
||||
};
|
||||
use crate::storage::{self, Session, Storage};
|
||||
use crate::udp::UdpEvent;
|
||||
|
|
@ -122,6 +122,19 @@ pub enum Message {
|
|||
/* pH */
|
||||
PhStabilizeChanged(String),
|
||||
StartPh,
|
||||
StartPhVolt,
|
||||
GetPhIdeality,
|
||||
SavePhScan,
|
||||
PhScanVOxideChanged(String),
|
||||
PhScanTOxideChanged(String),
|
||||
PhScanVStartChanged(String),
|
||||
PhScanVStopChanged(String),
|
||||
PhScanRateChanged(String),
|
||||
PhScanStabilizeChanged(String),
|
||||
PhScanMinPromChanged(String),
|
||||
PhScanVClampMinChanged(String),
|
||||
PhScanVClampMaxChanged(String),
|
||||
PhScanRtiaSelected(LpRtia),
|
||||
/* ORP */
|
||||
OrpStabilizeChanged(String),
|
||||
StartOrp,
|
||||
|
|
@ -261,6 +274,18 @@ pub struct App {
|
|||
/* pH */
|
||||
ph_result: Option<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> {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
229
main/echem.c
229
main/echem.c
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
61
main/echem.h
61
main/echem.h
|
|
@ -5,6 +5,16 @@
|
|||
|
||||
#define ECHEM_MAX_POINTS 500
|
||||
|
||||
/* gold-safe cell-potential window vs Ag/AgCl, runtime-overridable.
|
||||
placeholders pending a bench CV: set just above the lowest oxide-grow
|
||||
potential that still yields a peak, to spare the thin gold plating. */
|
||||
#ifndef ECHEM_AU_VMAX_MV
|
||||
#define ECHEM_AU_VMAX_MV 600.0f
|
||||
#endif
|
||||
#ifndef ECHEM_AU_VMIN_MV
|
||||
#define ECHEM_AU_VMIN_MV -600.0f
|
||||
#endif
|
||||
|
||||
typedef enum {
|
||||
LP_RTIA_200 = 0,
|
||||
LP_RTIA_1K,
|
||||
|
|
@ -105,12 +115,54 @@ typedef struct {
|
|||
float temp_c; /* temperature at measurement */
|
||||
} OrpResult;
|
||||
|
||||
/* Voltammetric pH: gold-oxide reduction-peak shift */
|
||||
typedef struct {
|
||||
float v_oxide_mv; /* anodic oxide-grow potential, clamped */
|
||||
float t_oxide_ms; /* oxide-grow hold */
|
||||
float v_start; /* cathodic sweep start, mV */
|
||||
float v_stop; /* cathodic sweep end, mV */
|
||||
float scan_rate; /* mV/s */
|
||||
float stabilize_ms; /* settle at v_start before the sweep */
|
||||
EchemLpRtia lp_rtia;
|
||||
float temp_c; /* caller-injected for cal temp-correction */
|
||||
} PhVoltConfig;
|
||||
|
||||
typedef struct {
|
||||
float peak_mv; /* reduction-peak potential, the pH-bearing value */
|
||||
float peak_i_ua; /* current at the peak */
|
||||
float ph; /* cal-derived pH, 0 with no peak or no cal */
|
||||
float temp_c;
|
||||
float confidence; /* 0..1 prominence over baseline noise */
|
||||
uint8_t has_peak;
|
||||
uint16_t n_points;
|
||||
} PhVoltResult;
|
||||
|
||||
/* persisted voltammetric-scan settings owned by the calibration page */
|
||||
typedef struct {
|
||||
float v_oxide_mv;
|
||||
float t_oxide_ms;
|
||||
float v_start;
|
||||
float v_stop;
|
||||
float scan_rate;
|
||||
float stabilize_ms;
|
||||
float min_prom_ua; /* reduction-peak detection threshold */
|
||||
float v_clamp_min_mv; /* gold-safe cathodic floor */
|
||||
float v_clamp_max_mv; /* gold-safe anodic ceiling */
|
||||
uint8_t lp_rtia;
|
||||
} PhScanCfg;
|
||||
|
||||
typedef int (*lsv_point_cb_t)(uint16_t idx, float v_mv, float i_ua);
|
||||
typedef int (*amp_point_cb_t)(uint16_t idx, float t_ms, float i_ua);
|
||||
typedef int (*cl_point_cb_t)(uint16_t idx, float t_ms, float i_ua, uint8_t phase);
|
||||
|
||||
int echem_clean(float v_mv, float duration_s);
|
||||
|
||||
/* gold-safe potential clamp */
|
||||
void echem_set_v_limits(float vmin_mv, float vmax_mv);
|
||||
void echem_get_v_limits(float *vmin_mv, float *vmax_mv);
|
||||
uint8_t echem_clamp_tripped(void);
|
||||
void echem_clamp_reset(void);
|
||||
|
||||
void echem_default_lsv(LSVConfig *cfg);
|
||||
void echem_default_amp(AmpConfig *cfg);
|
||||
void echem_default_cl(ClConfig *cfg);
|
||||
|
|
@ -123,4 +175,13 @@ int echem_chlorine(const ClConfig *cfg, ClPoint *out, uint32_t max_points, ClRes
|
|||
int echem_ph_ocp(const PhConfig *cfg, PhResult *result);
|
||||
int echem_orp_read(const OrpConfig *cfg, OrpResult *result);
|
||||
|
||||
int echem_ph_voltammetric(const PhVoltConfig *cfg, PhVoltResult *result,
|
||||
LSVPoint *out, uint32_t max_points, lsv_point_cb_t cb);
|
||||
|
||||
/* persisted pH-scan settings */
|
||||
void echem_default_ph_scan(PhScanCfg *cfg);
|
||||
void echem_set_ph_scan(const PhScanCfg *cfg);
|
||||
void echem_get_ph_scan(PhScanCfg *cfg);
|
||||
void echem_load_ph_scan(void);
|
||||
|
||||
#endif
|
||||
|
|
|
|||
197
main/eis.c
197
main/eis.c
|
|
@ -643,7 +643,8 @@ float eis_ph_buffer_at_temp(uint8_t buf, float temp_c)
|
|||
return tbl[i] + frac * (tbl[i + 1] - tbl[i]);
|
||||
}
|
||||
|
||||
#define NVS_PH_CAL_PTS_KEY "ph_cal9"
|
||||
/* bumped from ph_cal9: stored x is now a voltammetric peak potential, not OCP */
|
||||
#define NVS_PH_CAL_PTS_KEY "ph_cal10"
|
||||
|
||||
typedef struct {
|
||||
float ocp_mv;
|
||||
|
|
@ -655,71 +656,124 @@ static struct {
|
|||
uint16_t valid;
|
||||
} ph_cal;
|
||||
|
||||
static float ph_temp_slope_cold;
|
||||
static float ph_temp_slope_hot;
|
||||
/* theoretical Nernstian pH slope at t_c, mV/pH (59.16 at 25 C) */
|
||||
static inline float ph_nernst_slope_mv(float t_c)
|
||||
{
|
||||
return 0.19841f * (273.15f + t_c);
|
||||
}
|
||||
|
||||
/* derived cal-quality and isopotential model, recomputed on every cal edit */
|
||||
static struct {
|
||||
float slope_pct; /* measured vs theoretical Nernst at cal temp */
|
||||
float r2; /* fit quality of the base-row E-vs-pH line */
|
||||
float zero_offset_mv; /* potential at pH 7 (carries the reference offset) */
|
||||
float ph_iso; /* isopotential pH */
|
||||
float e_iso; /* isopotential mV */
|
||||
float cal_temp_c; /* base-row representative temperature */
|
||||
uint8_t iso_valid; /* 1 once two temperature lines intersect cleanly */
|
||||
uint8_t n_base;
|
||||
} ph_diag;
|
||||
|
||||
/* least-squares intersection of the per-temperature E-vs-pH lines */
|
||||
static void ph_cal_recalc_iso(void)
|
||||
{
|
||||
float m[PH_CAL_TEMPS], c[PH_CAL_TEMPS];
|
||||
int k = 0;
|
||||
for (int t = 0; t < PH_CAL_TEMPS; t++) {
|
||||
int nn = 0; float sx = 0, sy = 0, sxx = 0, sxy = 0;
|
||||
for (int i = 0; i < PH_CAL_BUFFERS; i++) {
|
||||
int bit = i * PH_CAL_TEMPS + t;
|
||||
if (!(ph_cal.valid & (1 << bit))) continue;
|
||||
float ph = eis_ph_buffer_at_temp(i, ph_cal.s[i][t].temp_c);
|
||||
float e = ph_cal.s[i][t].ocp_mv;
|
||||
sx += ph; sy += e; sxx += ph * ph; sxy += ph * e; nn++;
|
||||
}
|
||||
if (nn < 2) continue;
|
||||
float d = (float)nn * sxx - sx * sx;
|
||||
if (fabsf(d) < 1e-6f) continue;
|
||||
m[k] = ((float)nn * sxy - sx * sy) / d;
|
||||
c[k] = (sy - m[k] * sx) / (float)nn;
|
||||
k++;
|
||||
}
|
||||
if (k < 2) { /* no temperature spread, fall back to ideal */
|
||||
ph_diag.iso_valid = 0;
|
||||
ph_diag.ph_iso = 7.0f;
|
||||
ph_diag.e_iso = 0.0f;
|
||||
return;
|
||||
}
|
||||
float Sm = 0, Smm = 0, Sc = 0, Smc = 0;
|
||||
for (int j = 0; j < k; j++) { Sm += m[j]; Smm += m[j]*m[j]; Sc += c[j]; Smc += m[j]*c[j]; }
|
||||
float den = (float)k * Smm - Sm * Sm;
|
||||
if (fabsf(den) < 1e-6f) { /* parallel lines, no unique crossing */
|
||||
ph_diag.iso_valid = 0;
|
||||
ph_diag.ph_iso = 7.0f;
|
||||
ph_diag.e_iso = 0.0f;
|
||||
return;
|
||||
}
|
||||
ph_diag.ph_iso = (Smm * Sc - Sm * Smc) / den;
|
||||
ph_diag.e_iso = (Sm * ph_diag.ph_iso + Sc) / (float)k;
|
||||
ph_diag.iso_valid = 1;
|
||||
printf("pH cal: iso pH=%.3f E=%.2f mV (%d lines)\n",
|
||||
ph_diag.ph_iso, ph_diag.e_iso, k);
|
||||
}
|
||||
|
||||
/* fits base-row slope/offset and the electrode-ideality diagnostics */
|
||||
static void ph_cal_recalculate(void)
|
||||
{
|
||||
/* baseline slope/offset from the 3 baseline (tslot=1) points */
|
||||
int n = 0;
|
||||
float sx = 0, sy = 0, sxx = 0, sxy = 0;
|
||||
float sx = 0, sy = 0, sxx = 0, sxy = 0, syy = 0, t_sum = 0;
|
||||
for (int i = 0; i < PH_CAL_BUFFERS; i++) {
|
||||
int bit = i * PH_CAL_TEMPS + PH_TEMP_BASE;
|
||||
if (!(ph_cal.valid & (1 << bit))) continue;
|
||||
float x = ph_cal.s[i][PH_TEMP_BASE].ocp_mv;
|
||||
float y = eis_ph_buffer_at_temp(i, ph_cal.s[i][PH_TEMP_BASE].temp_c);
|
||||
sx += x; sy += y; sxx += x * x; sxy += x * y;
|
||||
float x = ph_cal.s[i][PH_TEMP_BASE].ocp_mv;
|
||||
float tc = ph_cal.s[i][PH_TEMP_BASE].temp_c;
|
||||
float y = eis_ph_buffer_at_temp(i, tc);
|
||||
sx += x; sy += y; sxx += x*x; sxy += x*y; syy += y*y; t_sum += tc;
|
||||
n++;
|
||||
}
|
||||
|
||||
memset(&ph_diag, 0, sizeof(ph_diag));
|
||||
ph_diag.n_base = (uint8_t)n;
|
||||
ph_diag.ph_iso = 7.0f;
|
||||
|
||||
if (n < 2) {
|
||||
ph_slope_cached = 0;
|
||||
ph_offset_cached = 0;
|
||||
} else {
|
||||
float d = (float)n * sxx - sx * sx;
|
||||
if (fabsf(d) < 1e-10f) {
|
||||
ph_slope_cached = 0;
|
||||
ph_offset_cached = 0;
|
||||
} else {
|
||||
ph_slope_cached = ((float)n * sxy - sx * sy) / d;
|
||||
ph_offset_cached = (sy - ph_slope_cached * sx) / (float)n;
|
||||
}
|
||||
printf("pH cal: base row %d pts, no fit\n", n);
|
||||
ph_cal_recalc_iso();
|
||||
return;
|
||||
}
|
||||
printf("pH cal: baseline slope=%.6f offset=%.4f (%d pts)\n",
|
||||
ph_slope_cached, ph_offset_cached, n);
|
||||
|
||||
/* temperature drift from off-temperature points */
|
||||
ph_temp_slope_cold = 0;
|
||||
ph_temp_slope_hot = 0;
|
||||
int nc = 0, nh = 0;
|
||||
for (int i = 0; i < PH_CAL_BUFFERS; i++) {
|
||||
int base_bit = i * PH_CAL_TEMPS + PH_TEMP_BASE;
|
||||
if (!(ph_cal.valid & (1 << base_bit))) continue;
|
||||
float ocp_base = ph_cal.s[i][PH_TEMP_BASE].ocp_mv;
|
||||
|
||||
int cold_bit = i * PH_CAL_TEMPS + PH_TEMP_BELOW;
|
||||
if (ph_cal.valid & (1 << cold_bit)) {
|
||||
float dt = ph_cal.s[i][PH_TEMP_BELOW].temp_c - 25.0f;
|
||||
if (fabsf(dt) > 0.5f) {
|
||||
ph_temp_slope_cold += (ph_cal.s[i][PH_TEMP_BELOW].ocp_mv - ocp_base) / dt;
|
||||
nc++;
|
||||
}
|
||||
}
|
||||
|
||||
int hot_bit = i * PH_CAL_TEMPS + PH_TEMP_ABOVE;
|
||||
if (ph_cal.valid & (1 << hot_bit)) {
|
||||
float dt = ph_cal.s[i][PH_TEMP_ABOVE].temp_c - 25.0f;
|
||||
if (fabsf(dt) > 0.5f) {
|
||||
ph_temp_slope_hot += (ph_cal.s[i][PH_TEMP_ABOVE].ocp_mv - ocp_base) / dt;
|
||||
nh++;
|
||||
}
|
||||
}
|
||||
float d = (float)n * sxx - sx * sx;
|
||||
if (fabsf(d) < 1e-6f) { /* buffers collapsed to one mV */
|
||||
ph_slope_cached = 0;
|
||||
ph_offset_cached = 0;
|
||||
printf("pH cal: singular base fit\n");
|
||||
ph_cal_recalc_iso();
|
||||
return;
|
||||
}
|
||||
if (nc > 0) ph_temp_slope_cold /= nc;
|
||||
if (nh > 0) ph_temp_slope_hot /= nh;
|
||||
|
||||
if (nc > 0 || nh > 0)
|
||||
printf("pH cal: temp drift cold=%.4f hot=%.4f mV/C\n",
|
||||
ph_temp_slope_cold, ph_temp_slope_hot);
|
||||
ph_slope_cached = ((float)n * sxy - sx * sy) / d; /* pH per mV */
|
||||
ph_offset_cached = (sy - ph_slope_cached * sx) / (float)n;
|
||||
|
||||
float cal_t = t_sum / (float)n;
|
||||
ph_diag.cal_temp_c = cal_t;
|
||||
|
||||
float s_meas = (fabsf(ph_slope_cached) > 1e-9f) ? (-1.0f / ph_slope_cached) : 0.0f;
|
||||
float s_theo = ph_nernst_slope_mv(cal_t);
|
||||
ph_diag.slope_pct = (s_theo != 0.0f) ? (s_meas / s_theo) * 100.0f : 0.0f;
|
||||
|
||||
float sse = syy - ph_slope_cached * sxy - ph_offset_cached * sy;
|
||||
float sst = syy - (sy * sy) / (float)n;
|
||||
ph_diag.r2 = (sst > 1e-9f) ? (1.0f - sse / sst) : 1.0f;
|
||||
|
||||
if (fabsf(ph_slope_cached) > 1e-9f)
|
||||
ph_diag.zero_offset_mv = (7.0f - ph_offset_cached) / ph_slope_cached;
|
||||
|
||||
printf("pH cal: slope=%.6f offset=%.4f S=%.2f mV/pH (%.1f%%) R2=%.4f (%d pts)\n",
|
||||
ph_slope_cached, ph_offset_cached, s_meas, ph_diag.slope_pct, ph_diag.r2, n);
|
||||
|
||||
ph_cal_recalc_iso();
|
||||
}
|
||||
|
||||
static void ph_cal_save(void)
|
||||
|
|
@ -731,8 +785,44 @@ static void ph_cal_save(void)
|
|||
nvs_close(h);
|
||||
}
|
||||
|
||||
float eis_get_ph_temp_slope_cold(void) { return ph_temp_slope_cold; }
|
||||
float eis_get_ph_temp_slope_hot(void) { return ph_temp_slope_hot; }
|
||||
/* retained for wire compatibility, drift folded into the isopotential model */
|
||||
float eis_get_ph_temp_slope_cold(void) { return 0.0f; }
|
||||
float eis_get_ph_temp_slope_hot(void) { return 0.0f; }
|
||||
|
||||
float eis_get_ph_cal_temp(void) { return ph_diag.cal_temp_c; }
|
||||
|
||||
/* fills the isopotential point, false when fewer than two temperature rows */
|
||||
bool eis_get_ph_iso(float *ph_iso, float *e_iso)
|
||||
{
|
||||
if (ph_iso) *ph_iso = ph_diag.ph_iso;
|
||||
if (e_iso) *e_iso = ph_diag.e_iso;
|
||||
return ph_diag.iso_valid != 0;
|
||||
}
|
||||
|
||||
/* snapshots the cal-quality diagnostics */
|
||||
void eis_get_ph_diag(PhDiag *out)
|
||||
{
|
||||
if (!out) return;
|
||||
out->slope_pct = ph_diag.slope_pct;
|
||||
out->r2 = ph_diag.r2;
|
||||
out->zero_offset_mv = ph_diag.zero_offset_mv;
|
||||
out->ph_iso = ph_diag.ph_iso;
|
||||
out->e_iso = ph_diag.e_iso;
|
||||
out->iso_valid = ph_diag.iso_valid != 0;
|
||||
}
|
||||
|
||||
/* converts a calibrated potential to pH via the isopotential Nernst model */
|
||||
float eis_ph_compensate(float meas_mv, float temp_c)
|
||||
{
|
||||
if (fabsf(ph_slope_cached) < 1e-9f) return 0.0f;
|
||||
float s_cal = -1.0f / ph_slope_cached; /* mV/pH at cal temp */
|
||||
float s_t = s_cal * (ph_nernst_slope_mv(temp_c) /
|
||||
ph_nernst_slope_mv(ph_diag.cal_temp_c));
|
||||
if (ph_diag.iso_valid)
|
||||
return ph_diag.ph_iso + (meas_mv - ph_diag.e_iso) / s_t;
|
||||
float mv_at_ph7 = (7.0f - ph_offset_cached) / ph_slope_cached;
|
||||
return 7.0f + (meas_mv - mv_at_ph7) / s_t;
|
||||
}
|
||||
|
||||
void eis_load_ph_cal(void)
|
||||
{
|
||||
|
|
@ -774,8 +864,7 @@ void eis_ph_cal_clear_all(void)
|
|||
memset(&ph_cal, 0, sizeof(ph_cal));
|
||||
ph_slope_cached = 0;
|
||||
ph_offset_cached = 0;
|
||||
ph_temp_slope_cold = 0;
|
||||
ph_temp_slope_hot = 0;
|
||||
memset(&ph_diag, 0, sizeof(ph_diag));
|
||||
ph_cal_save();
|
||||
}
|
||||
|
||||
|
|
|
|||
13
main/eis.h
13
main/eis.h
|
|
@ -72,10 +72,23 @@ void eis_set_cl_factor(float f);
|
|||
float eis_get_cl_factor(void);
|
||||
void eis_load_cl_factor(void);
|
||||
|
||||
typedef struct {
|
||||
float slope_pct;
|
||||
float r2;
|
||||
float zero_offset_mv;
|
||||
float ph_iso;
|
||||
float e_iso;
|
||||
uint8_t iso_valid;
|
||||
} PhDiag;
|
||||
|
||||
float eis_get_ph_slope(void);
|
||||
float eis_get_ph_offset(void);
|
||||
float eis_get_ph_temp_slope_cold(void);
|
||||
float eis_get_ph_temp_slope_hot(void);
|
||||
float eis_get_ph_cal_temp(void);
|
||||
bool eis_get_ph_iso(float *ph_iso, float *e_iso);
|
||||
void eis_get_ph_diag(PhDiag *out);
|
||||
float eis_ph_compensate(float meas_mv, float temp_c);
|
||||
void eis_load_ph_cal(void);
|
||||
|
||||
#define PH_CAL_BUFFERS 3
|
||||
|
|
|
|||
94
main/eis4.c
94
main/eis4.c
|
|
@ -63,6 +63,7 @@ void app_main(void)
|
|||
eis_load_cell_k();
|
||||
eis_load_cl_factor();
|
||||
eis_load_ph_cal();
|
||||
echem_load_ph_scan();
|
||||
temp_init();
|
||||
|
||||
wifi_cfg_init();
|
||||
|
|
@ -211,6 +212,35 @@ void app_main(void)
|
|||
break;
|
||||
}
|
||||
|
||||
case CMD_START_PH_VOLT: {
|
||||
PhScanCfg sc;
|
||||
echem_get_ph_scan(&sc);
|
||||
PhVoltConfig pv = {
|
||||
sc.v_oxide_mv, sc.t_oxide_ms, sc.v_start, sc.v_stop,
|
||||
sc.scan_rate, sc.stabilize_ms, sc.lp_rtia, temp_get()
|
||||
};
|
||||
printf("pH-V: oxide %.0f mV/%.0f ms, sweep %.0f->%.0f mV, rtia=%u\n",
|
||||
pv.v_oxide_mv, pv.t_oxide_ms, pv.v_start, pv.v_stop, pv.lp_rtia);
|
||||
|
||||
uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000);
|
||||
measurement_counter++;
|
||||
LSVConfig lv = { pv.v_start, pv.v_stop, pv.scan_rate, pv.lp_rtia };
|
||||
uint32_t n = echem_lsv_calc_steps(&lv, ECHEM_MAX_POINTS);
|
||||
send_lsv_start(n, pv.v_start, pv.v_stop, ts_ms, measurement_counter);
|
||||
|
||||
PhVoltResult res;
|
||||
echem_ph_voltammetric(&pv, &res, lsv_results, ECHEM_MAX_POINTS, send_lsv_point);
|
||||
send_lsv_end();
|
||||
|
||||
uint8_t has_peak = res.has_peak && !echem_clamp_tripped();
|
||||
printf("pH-V: peak=%.1f mV i=%.3f uA pH=%.2f conf=%.2f peak=%u clamp=%u\n",
|
||||
res.peak_mv, res.peak_i_ua, res.ph, res.confidence,
|
||||
res.has_peak, echem_clamp_tripped());
|
||||
send_ph_volt_result(res.peak_mv, res.peak_i_ua, res.ph, res.temp_c,
|
||||
res.confidence, has_peak, ts_ms, measurement_counter);
|
||||
break;
|
||||
}
|
||||
|
||||
case CMD_START_CLEAN:
|
||||
printf("Clean: %.0f mV, %.0f s\n", cmd.clean.v_mv, cmd.clean.duration_s);
|
||||
echem_clean(cmd.clean.v_mv, cmd.clean.duration_s);
|
||||
|
|
@ -275,25 +305,41 @@ void app_main(void)
|
|||
printf("pH cal: buffer %u slot %u (nominal pH %.2f)\n",
|
||||
bid, tsl, eis_ph_cal_buffer_ph(bid));
|
||||
|
||||
PhConfig ph_cfg;
|
||||
ph_cfg.stabilize_s = cmd.ph_cal_point.stabilize_s;
|
||||
ph_cfg.temp_c = temp_get();
|
||||
PhScanCfg sc;
|
||||
echem_get_ph_scan(&sc);
|
||||
PhVoltConfig pv = {
|
||||
sc.v_oxide_mv, sc.t_oxide_ms, sc.v_start, sc.v_stop,
|
||||
sc.scan_rate, sc.stabilize_ms, sc.lp_rtia, temp_get()
|
||||
};
|
||||
|
||||
PhResult ph_result;
|
||||
echem_ph_ocp(&ph_cfg, &ph_result);
|
||||
uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000);
|
||||
measurement_counter++;
|
||||
LSVConfig lv = { pv.v_start, pv.v_stop, pv.scan_rate, pv.lp_rtia };
|
||||
uint32_t n = echem_lsv_calc_steps(&lv, ECHEM_MAX_POINTS);
|
||||
send_lsv_start(n, pv.v_start, pv.v_stop, ts_ms, measurement_counter);
|
||||
|
||||
eis_ph_cal_set_point(bid, tsl, ph_result.v_ocp_mv, ph_result.temp_c);
|
||||
PhVoltResult res;
|
||||
echem_ph_voltammetric(&pv, &res, lsv_results, ECHEM_MAX_POINTS, send_lsv_point);
|
||||
send_lsv_end();
|
||||
|
||||
float buf_ph = eis_ph_buffer_at_temp(bid, ph_result.temp_c);
|
||||
if (!res.has_peak || echem_clamp_tripped()) {
|
||||
printf("pH cal: [%u][%u] no peak, not stored\n", bid, tsl);
|
||||
send_ph_cal_status();
|
||||
break;
|
||||
}
|
||||
|
||||
eis_ph_cal_set_point(bid, tsl, res.peak_mv, res.temp_c);
|
||||
|
||||
float buf_ph = eis_ph_buffer_at_temp(bid, res.temp_c);
|
||||
|
||||
int baseline_n = 0;
|
||||
for (int i = 0; i < PH_CAL_BUFFERS; i++)
|
||||
if (eis_ph_cal_get_point(i, PH_TEMP_BASE, NULL, NULL)) baseline_n++;
|
||||
|
||||
printf("pH cal: [%u][%u] OCP=%.1f mV T=%.1f C pH=%.3f (%d/%d)\n",
|
||||
bid, tsl, ph_result.v_ocp_mv, ph_result.temp_c, buf_ph,
|
||||
printf("pH cal: [%u][%u] peak=%.1f mV T=%.1f C pH=%.3f (%d/%d)\n",
|
||||
bid, tsl, res.peak_mv, res.temp_c, buf_ph,
|
||||
baseline_n, eis_ph_cal_count());
|
||||
send_ph_cal_point(bid, tsl, ph_result.v_ocp_mv, ph_result.temp_c,
|
||||
send_ph_cal_point(bid, tsl, res.peak_mv, res.temp_c,
|
||||
buf_ph, (uint8_t)baseline_n);
|
||||
break;
|
||||
}
|
||||
|
|
@ -320,6 +366,34 @@ void app_main(void)
|
|||
send_ph_cal(eis_get_ph_slope(), eis_get_ph_offset());
|
||||
break;
|
||||
|
||||
case CMD_GET_PH_IDEALITY:
|
||||
send_ph_ideality();
|
||||
break;
|
||||
|
||||
case CMD_SET_PH_SCAN: {
|
||||
PhScanCfg sc;
|
||||
sc.v_oxide_mv = cmd.ph_scan.v_oxide_mv;
|
||||
sc.t_oxide_ms = cmd.ph_scan.t_oxide_ms;
|
||||
sc.v_start = cmd.ph_scan.v_start;
|
||||
sc.v_stop = cmd.ph_scan.v_stop;
|
||||
sc.scan_rate = cmd.ph_scan.scan_rate;
|
||||
sc.stabilize_ms = cmd.ph_scan.stabilize_ms;
|
||||
sc.min_prom_ua = cmd.ph_scan.min_prom_ua;
|
||||
sc.v_clamp_min_mv = cmd.ph_scan.v_clamp_min_mv;
|
||||
sc.v_clamp_max_mv = cmd.ph_scan.v_clamp_max_mv;
|
||||
sc.lp_rtia = cmd.ph_scan.lp_rtia;
|
||||
echem_set_ph_scan(&sc);
|
||||
send_ph_scan();
|
||||
printf("pH scan: oxide %.0f mV/%.0f ms, %.0f->%.0f mV, rtia=%u, prom=%.3f, clamp %.0f..%.0f\n",
|
||||
sc.v_oxide_mv, sc.t_oxide_ms, sc.v_start, sc.v_stop, sc.lp_rtia,
|
||||
sc.min_prom_ua, sc.v_clamp_min_mv, sc.v_clamp_max_mv);
|
||||
break;
|
||||
}
|
||||
|
||||
case CMD_GET_PH_SCAN:
|
||||
send_ph_scan();
|
||||
break;
|
||||
|
||||
case CMD_START_CL: {
|
||||
ClConfig cl_cfg;
|
||||
cl_cfg.v_cond = cmd.cl.v_cond;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue