update Rust app for 9-point pH calibration protocol

This commit is contained in:
jess 2026-04-09 15:34:14 -07:00
parent ca512f9f05
commit c5f2dcfedb
2 changed files with 226 additions and 78 deletions

View File

@ -131,10 +131,12 @@ pub enum Message {
ClCalKnownPpmChanged(String),
ClSetFactor,
/* pH calibration */
PhCalKnownChanged(String),
PhAddCalPoint,
PhClearCalPoints,
PhComputeAndSetCal,
PhCalSelectBuf(usize),
PhCalSelectTslot(usize),
PhCalStartMeasurement,
PhCalClearPoint(u8, u8),
PhCalClearAll,
PhCalStabilizeChanged(String),
/* Global */
PollTemp,
NativeMenuTick,
@ -290,8 +292,15 @@ pub struct App {
cl_cal_known_ppm: String,
ph_slope: Option<f32>,
ph_offset: Option<f32>,
ph_cal_points: Vec<(f32, f32)>,
ph_cal_known: String,
ph_cal_grid: [[Option<(f32, f32)>; 3]; 3],
ph_cal_valid_mask: u16,
ph_cal_temp_slope_cold: Option<f32>,
ph_cal_temp_slope_hot: Option<f32>,
ph_cal_baseline_count: u8,
ph_cal_selected_buf: usize,
ph_cal_selected_tslot: usize,
ph_cal_measuring: bool,
ph_cal_stabilize: String,
/* Global */
temp_c: f32,
@ -534,8 +543,15 @@ impl App {
cl_cal_known_ppm: String::from("5"),
ph_slope: None,
ph_offset: None,
ph_cal_points: vec![],
ph_cal_known: String::from("7.00"),
ph_cal_grid: [[None; 3]; 3],
ph_cal_valid_mask: 0,
ph_cal_temp_slope_cold: None,
ph_cal_temp_slope_hot: None,
ph_cal_baseline_count: 0,
ph_cal_selected_buf: 0,
ph_cal_selected_tslot: 1,
ph_cal_measuring: false,
ph_cal_stabilize: "120".into(),
temp_c: 25.0,
conn_gen: 0,
@ -642,6 +658,7 @@ impl App {
self.send_cmd(&protocol::build_sysex_get_cell_k());
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());
}
Message::DeviceStatus(s) => {
if s.contains("Reconnecting") || s.contains("Connecting") {
@ -875,6 +892,33 @@ 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 } => {
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_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);
}
EisMessage::PhCalStatus { valid_mask, slope, offset, temp_slope_cold, temp_slope_hot } => {
self.ph_cal_valid_mask = valid_mask;
self.ph_slope = Some(slope);
self.ph_offset = Some(offset);
self.ph_cal_temp_slope_cold = Some(temp_slope_cold);
self.ph_cal_temp_slope_hot = Some(temp_slope_hot);
for buf in 0..3usize {
for ts in 0..3usize {
let bit = buf * 3 + ts;
if valid_mask & (1 << bit) == 0 {
self.ph_cal_grid[buf][ts] = None;
}
}
}
self.status = format!("pH cal status: mask=0x{:03X} slope={:.4} offset={:.4}",
valid_mask, slope, offset);
}
EisMessage::Keepalive => {}
},
Message::TabSelected(t) => {
@ -1114,45 +1158,38 @@ impl App {
self.status = "No valid EIS data for Rs extraction".into();
}
}
Message::PhCalKnownChanged(s) => { self.ph_cal_known = s; }
Message::PhAddCalPoint => {
if let Some(peak_mv) = crate::lsv_analysis::detect_qhq_peak(&self.lsv_points) {
if let Ok(ph) = self.ph_cal_known.parse::<f32>() {
self.ph_cal_points.push((ph, peak_mv));
self.status = format!("pH cal point: pH={:.2} peak={:.1} mV ({} pts)",
ph, peak_mv, self.ph_cal_points.len());
}
} else {
self.status = "No Q/HQ peak found in LSV data".into();
}
Message::PhCalSelectBuf(b) => { self.ph_cal_selected_buf = b.min(2); }
Message::PhCalSelectTslot(t) => { self.ph_cal_selected_tslot = t.min(2); }
Message::PhCalStartMeasurement => {
let stab = self.ph_cal_stabilize.parse::<f32>().unwrap_or(120.0);
self.ph_cal_measuring = true;
self.send_cmd(&protocol::build_sysex_ph_cal_point(
self.ph_cal_selected_buf as u8,
self.ph_cal_selected_tslot as u8,
stab,
));
self.status = format!("pH cal: measuring buf={} tslot={} stab={:.0}s",
self.ph_cal_selected_buf, self.ph_cal_selected_tslot, stab);
}
Message::PhClearCalPoints => {
self.ph_cal_points.clear();
self.status = "pH cal points cleared".into();
Message::PhCalClearPoint(buf, tslot) => {
self.send_cmd(&protocol::build_sysex_ph_cal_clear(buf, tslot));
let bi = (buf as usize).min(2);
let ti = (tslot as usize).min(2);
self.ph_cal_grid[bi][ti] = None;
self.ph_cal_valid_mask &= !(1 << (bi * 3 + ti));
}
Message::PhComputeAndSetCal => {
if self.ph_cal_points.len() < 2 {
self.status = "Need at least 2 calibration points".into();
} else {
let n = self.ph_cal_points.len() as f32;
let mean_ph: f32 = self.ph_cal_points.iter().map(|p| p.0).sum::<f32>() / n;
let mean_v: f32 = self.ph_cal_points.iter().map(|p| p.1).sum::<f32>() / n;
let num: f32 = self.ph_cal_points.iter()
.map(|p| (p.0 - mean_ph) * (p.1 - mean_v)).sum();
let den: f32 = self.ph_cal_points.iter()
.map(|p| (p.0 - mean_ph).powi(2)).sum();
if den.abs() < 1e-12 {
self.status = "Degenerate calibration data".into();
} else {
let slope = num / den;
let offset = mean_v - slope * mean_ph;
self.ph_slope = Some(slope);
self.ph_offset = Some(offset);
self.send_cmd(&protocol::build_sysex_set_ph_cal(slope, offset));
self.status = format!("pH cal set: slope={:.4} offset={:.4}", slope, offset);
}
}
Message::PhCalClearAll => {
self.send_cmd(&protocol::build_sysex_ph_cal_clear(0x7F, 0x7F));
self.ph_cal_grid = [[None; 3]; 3];
self.ph_cal_valid_mask = 0;
self.ph_cal_baseline_count = 0;
self.ph_slope = None;
self.ph_offset = None;
self.ph_cal_temp_slope_cold = None;
self.ph_cal_temp_slope_hot = None;
self.status = "pH cal cleared".into();
}
Message::PhCalStabilizeChanged(s) => { self.ph_cal_stabilize = s; }
Message::ClCalKnownPpmChanged(s) => { self.cl_cal_known_ppm = s; }
Message::ClSetFactor => {
let known_ppm = self.cl_cal_known_ppm.parse::<f32>().unwrap_or(0.0);
@ -2037,45 +2074,120 @@ impl App {
].spacing(10).align_y(iced::Alignment::End)
);
/* pH calibration */
/* pH calibration (9-point) */
results = results.push(iced::widget::horizontal_rule(1));
results = results.push(text("pH Calibration (Q/HQ peak-shift)").size(16));
results = results.push(text("pH Calibration (9-point)").size(16));
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));
if let Some(peak) = crate::lsv_analysis::detect_qhq_peak(&self.lsv_points) {
if s.abs() > 1e-6 {
let ph = (peak - o) / s;
results = results.push(text(format!("Computed pH: {:.2} (peak at {:.1} mV)", ph, peak)).size(14));
}
}
}
if let Some(tc) = self.ph_cal_temp_slope_cold {
results = results.push(text(format!("temp correction cold: {:.4} hot: {:.4}",
tc, self.ph_cal_temp_slope_hot.unwrap_or(0.0))).size(13));
}
let buf_labels = ["pH 4.0", "pH 6.86", "pH 9.0"];
let temp_labels = ["Below 25C", "At 25C", "Above 25C"];
/* grid header */
let mut header = row![text("").width(80)].spacing(4);
for bl in &buf_labels {
header = header.push(text(*bl).size(12).width(100).center());
}
results = results.push(header);
/* grid rows */
for ti in 0..3usize {
let mut grid_row = row![
text(temp_labels[ti]).size(12).width(80)
].spacing(4).align_y(iced::Alignment::Center);
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)
} else if valid {
"cal'd".into()
} else {
"\u{2014}".into()
};
let bg = if ti == 1 {
Color::from_rgb(0.22, 0.30, 0.22)
} else {
Color::from_rgb(0.18, 0.18, 0.20)
};
let cell_btn = button(text(cell_text).size(11).center().width(Length::Fill))
.style(btn_style(bg, Color::WHITE))
.padding([4, 6])
.width(100);
grid_row = grid_row.push(cell_btn);
}
results = results.push(grid_row);
}
/* controls */
let buf_names: Vec<String> = buf_labels.iter().map(|s| s.to_string()).collect();
let tslot_names: Vec<String> = temp_labels.iter().map(|s| s.to_string()).collect();
results = results.push(
row![
column![
text("Known pH").size(12),
text_input("7.00", &self.ph_cal_known)
.on_input(Message::PhCalKnownChanged).width(80),
text("Buffer").size(12),
pick_list(
buf_names.clone(),
Some(buf_names[self.ph_cal_selected_buf].clone()),
|s| {
let idx = match s.as_str() {
"pH 4.0" => 0, "pH 6.86" => 1, "pH 9.0" => 2, _ => 0,
};
Message::PhCalSelectBuf(idx)
}
).width(100),
].spacing(2),
column![
text("Temp slot").size(12),
pick_list(
tslot_names.clone(),
Some(tslot_names[self.ph_cal_selected_tslot].clone()),
|s| {
let idx = match s.as_str() {
"Below 25C" => 0, "At 25C" => 1, "Above 25C" => 2, _ => 1,
};
Message::PhCalSelectTslot(idx)
}
).width(100),
].spacing(2),
column![
text("Stabilize (s)").size(12),
text_input("120", &self.ph_cal_stabilize)
.on_input(Message::PhCalStabilizeChanged).width(60),
].spacing(2),
button(text("Add Point").size(13))
.style(style_action())
.padding([6, 16])
.on_press(Message::PhAddCalPoint),
].spacing(10).align_y(iced::Alignment::End)
);
for (i, (ph, mv)) in self.ph_cal_points.iter().enumerate() {
results = results.push(text(format!(" {}. pH={:.2} peak={:.1} mV", i + 1, ph, mv)).size(13));
let mut measure_btn = button(text(
if self.ph_cal_measuring { "Measuring..." } else { "Measure" }
).size(13))
.style(style_action())
.padding([6, 16]);
if !self.ph_cal_measuring {
measure_btn = measure_btn.on_press(Message::PhCalStartMeasurement);
}
let clear_sel_btn = button(text("Clear Selected").size(13))
.style(style_danger())
.padding([6, 16])
.on_press(Message::PhCalClearPoint(
self.ph_cal_selected_buf as u8,
self.ph_cal_selected_tslot as u8,
));
let clear_all_btn = button(text("Clear All").size(13))
.style(style_danger())
.padding([6, 16])
.on_press(Message::PhCalClearAll);
results = results.push(
row![
button(text("Clear Points").size(13))
.style(style_action())
.padding([6, 16])
.on_press(Message::PhClearCalPoints),
button(text("Compute & Set pH Cal").size(13))
.style(style_action())
.padding([6, 16])
.on_press(Message::PhComputeAndSetCal),
].spacing(10)
row![measure_btn, clear_sel_btn, clear_all_btn].spacing(10)
);
row![

View File

@ -28,6 +28,8 @@ 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;
pub const RSP_PH_CAL_POINT: u8 = 0x26;
pub const RSP_PH_CAL_STATUS: u8 = 0x27;
pub const RSP_KEEPALIVE: u8 = 0x50;
/* Cue → ESP32 */
@ -49,8 +51,10 @@ pub const CMD_SET_CELL_K: u8 = 0x28;
pub const CMD_GET_CELL_K: u8 = 0x29;
pub const CMD_SET_CL_FACTOR: u8 = 0x33;
pub const CMD_GET_CL_FACTOR: u8 = 0x34;
pub const CMD_SET_PH_CAL: u8 = 0x35;
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_START_REFS: u8 = 0x30;
pub const CMD_GET_REFS: u8 = 0x31;
pub const CMD_CLEAR_REFS: u8 = 0x32;
@ -269,6 +273,8 @@ pub enum EisMessage {
CellK(f32),
ClFactor(f32),
PhCal { slope: f32, offset: f32 },
PhCalPoint { buf: u8, tslot: u8, ocp_mv: f32, temp_c: f32, buffer_ph: f32, baseline_count: u8 },
PhCalStatus { valid_mask: u16, slope: f32, offset: f32, temp_slope_cold: f32, temp_slope_hot: f32 },
Keepalive,
}
@ -465,6 +471,29 @@ pub fn parse_sysex(data: &[u8]) -> Option<EisMessage> {
offset: decode_float(&p[5..10]),
})
}
RSP_PH_CAL_POINT if data.len() >= 20 => {
let p = &data[2..];
Some(EisMessage::PhCalPoint {
buf: p[0],
tslot: p[1],
ocp_mv: decode_float(&p[2..7]),
temp_c: decode_float(&p[7..12]),
buffer_ph: decode_float(&p[12..17]),
baseline_count: p[17],
})
}
RSP_PH_CAL_STATUS if data.len() >= 24 => {
let p = &data[2..];
let mask_lo = p[0];
let mask_hi = p[1];
Some(EisMessage::PhCalStatus {
valid_mask: (mask_hi as u16) << 7 | mask_lo as u16,
slope: decode_float(&p[2..7]),
offset: decode_float(&p[7..12]),
temp_slope_cold: decode_float(&p[12..17]),
temp_slope_hot: decode_float(&p[17..22]),
})
}
RSP_KEEPALIVE => Some(EisMessage::Keepalive),
_ => None,
}
@ -593,14 +622,21 @@ pub fn build_sysex_get_cl_factor() -> Vec<u8> {
vec![0xF0, SYSEX_MFR, CMD_GET_CL_FACTOR, 0xF7]
}
pub fn build_sysex_set_ph_cal(slope: f32, offset: f32) -> Vec<u8> {
let mut sx = vec![0xF0, SYSEX_MFR, CMD_SET_PH_CAL];
sx.extend_from_slice(&encode_float(slope));
sx.extend_from_slice(&encode_float(offset));
pub fn build_sysex_get_ph_cal() -> Vec<u8> {
vec![0xF0, SYSEX_MFR, CMD_GET_PH_CAL, 0xF7]
}
pub fn build_sysex_ph_cal_point(buffer_id: u8, temp_slot: u8, stabilize_s: f32) -> Vec<u8> {
let mut sx = vec![0xF0, SYSEX_MFR, CMD_PH_CAL_POINT, buffer_id & 0x7F, temp_slot & 0x7F];
sx.extend_from_slice(&encode_float(stabilize_s));
sx.push(0xF7);
sx
}
pub fn build_sysex_get_ph_cal() -> Vec<u8> {
vec![0xF0, SYSEX_MFR, CMD_GET_PH_CAL, 0xF7]
pub fn build_sysex_ph_cal_clear(buffer_id: u8, temp_slot: u8) -> Vec<u8> {
vec![0xF0, SYSEX_MFR, CMD_PH_CAL_CLEAR, buffer_id & 0x7F, temp_slot & 0x7F, 0xF7]
}
pub fn build_sysex_ph_cal_status() -> Vec<u8> {
vec![0xF0, SYSEX_MFR, CMD_PH_CAL_STATUS, 0xF7]
}