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