From c5f2dcfedbe1a7c85ec1113d6983fb7f017dd3c5 Mon Sep 17 00:00:00 2001 From: jess Date: Thu, 9 Apr 2026 15:34:14 -0700 Subject: [PATCH] update Rust app for 9-point pH calibration protocol --- cue/src/app.rs | 254 +++++++++++++++++++++++++++++++------------- cue/src/protocol.rs | 50 +++++++-- 2 files changed, 226 insertions(+), 78 deletions(-) diff --git a/cue/src/app.rs b/cue/src/app.rs index aabedec..a4ca6f7 100644 --- a/cue/src/app.rs +++ b/cue/src/app.rs @@ -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, ph_offset: Option, - 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, + ph_cal_temp_slope_hot: Option, + 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::() { - 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::().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::() / n; - let mean_v: f32 = self.ph_cal_points.iter().map(|p| p.1).sum::() / 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::().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 = buf_labels.iter().map(|s| s.to_string()).collect(); + let tslot_names: Vec = 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![ diff --git a/cue/src/protocol.rs b/cue/src/protocol.rs index 23649b2..475fa19 100644 --- a/cue/src/protocol.rs +++ b/cue/src/protocol.rs @@ -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 { 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 { vec![0xF0, SYSEX_MFR, CMD_GET_CL_FACTOR, 0xF7] } -pub fn build_sysex_set_ph_cal(slope: f32, offset: f32) -> Vec { - 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 { + 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 { + 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 { - vec![0xF0, SYSEX_MFR, CMD_GET_PH_CAL, 0xF7] +pub fn build_sysex_ph_cal_clear(buffer_id: u8, temp_slot: u8) -> Vec { + vec![0xF0, SYSEX_MFR, CMD_PH_CAL_CLEAR, buffer_id & 0x7F, temp_slot & 0x7F, 0xF7] +} + +pub fn build_sysex_ph_cal_status() -> Vec { + vec![0xF0, SYSEX_MFR, CMD_PH_CAL_STATUS, 0xF7] }