From bdb72a99179cdab900c0ddd4d8ae1f70cf4d348a Mon Sep 17 00:00:00 2001 From: jess Date: Thu, 2 Apr 2026 19:32:27 -0700 Subject: [PATCH] desktop: add pH cal protocol, Q/HQ peak detection, and state --- cue/src/app.rs | 19 +++++++++++++++++++ cue/src/lsv_analysis.rs | 25 +++++++++++++++++++++++++ cue/src/protocol.rs | 23 +++++++++++++++++++++++ 3 files changed, 67 insertions(+) diff --git a/cue/src/app.rs b/cue/src/app.rs index f40e563..bfd4889 100644 --- a/cue/src/app.rs +++ b/cue/src/app.rs @@ -100,6 +100,11 @@ pub enum Message { CalComputeK, ClCalKnownPpmChanged(String), ClSetFactor, + /* pH calibration */ + PhCalKnownChanged(String), + PhAddCalPoint, + PhClearCalPoints, + PhComputeAndSetCal, /* Global */ PollTemp, NativeMenuTick, @@ -235,6 +240,10 @@ pub struct App { cal_cell_constant: Option, cl_factor: Option, cl_cal_known_ppm: String, + ph_slope: Option, + ph_offset: Option, + ph_cal_points: Vec<(f32, f32)>, + ph_cal_known: String, /* Global */ temp_c: f32, @@ -469,6 +478,10 @@ impl App { cal_cell_constant: None, cl_factor: None, cl_cal_known_ppm: String::from("5"), + ph_slope: None, + ph_offset: None, + ph_cal_points: vec![], + ph_cal_known: String::from("7.00"), temp_c: 25.0, conn_gen: 0, @@ -574,6 +587,7 @@ impl App { self.send_cmd(&protocol::build_sysex_get_config()); 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()); } Message::DeviceStatus(s) => { if s.contains("Reconnecting") || s.contains("Connecting") { @@ -754,6 +768,11 @@ impl App { self.cl_factor = Some(f); self.status = format!("Device Cl factor: {:.6}", f); } + EisMessage::PhCal { slope, offset } => { + self.ph_slope = Some(slope); + self.ph_offset = Some(offset); + self.status = format!("pH cal: slope={:.4} offset={:.4}", slope, offset); + } }, Message::TabSelected(t) => { if t == Tab::Browse { diff --git a/cue/src/lsv_analysis.rs b/cue/src/lsv_analysis.rs index 4e52640..0fe8ba9 100644 --- a/cue/src/lsv_analysis.rs +++ b/cue/src/lsv_analysis.rs @@ -66,6 +66,31 @@ pub fn find_extrema(_v: &[f32], i_smooth: &[f32], min_prominence: f32) -> Vec<(u candidates } +/// Detect Q/HQ redox peak in the -100 to +600 mV window. +/// Returns peak voltage in mV if found. +pub fn detect_qhq_peak(points: &[LsvPoint]) -> Option { + if points.len() < 5 { + return None; + } + + let i_vals: Vec = points.iter().map(|p| p.i_ua).collect(); + let v_vals: Vec = points.iter().map(|p| p.v_mv).collect(); + + let window = 5.max(points.len() / 50); + let smoothed = smooth(&i_vals, window); + + let i_min = smoothed.iter().copied().fold(f32::INFINITY, f32::min); + let i_max = smoothed.iter().copied().fold(f32::NEG_INFINITY, f32::max); + let prominence = (i_max - i_min) * 0.05; + + let extrema = find_extrema(&v_vals, &smoothed, prominence); + + extrema.iter() + .filter(|&&(idx, is_max)| is_max && v_vals[idx] >= -100.0 && v_vals[idx] <= 600.0) + .max_by(|a, b| smoothed[a.0].partial_cmp(&smoothed[b.0]).unwrap_or(std::cmp::Ordering::Equal)) + .map(|&(idx, _)| v_vals[idx]) +} + pub fn detect_peaks(points: &[LsvPoint]) -> Vec { if points.len() < 5 { return Vec::new(); diff --git a/cue/src/protocol.rs b/cue/src/protocol.rs index 58d2a0b..bfb8363 100644 --- a/cue/src/protocol.rs +++ b/cue/src/protocol.rs @@ -27,6 +27,7 @@ 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; /* Cue → ESP32 */ pub const CMD_SET_SWEEP: u8 = 0x10; @@ -47,6 +48,8 @@ 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_START_REFS: u8 = 0x30; pub const CMD_GET_REFS: u8 = 0x31; pub const CMD_CLEAR_REFS: u8 = 0x32; @@ -262,6 +265,7 @@ pub enum EisMessage { RefStatus { has_refs: bool }, CellK(f32), ClFactor(f32), + PhCal { slope: f32, offset: f32 }, } fn decode_u16(data: &[u8]) -> u16 { @@ -421,6 +425,13 @@ pub fn parse_sysex(data: &[u8]) -> Option { let p = &data[2..]; Some(EisMessage::ClFactor(decode_float(&p[0..5]))) } + RSP_PH_CAL if data.len() >= 12 => { + let p = &data[2..]; + Some(EisMessage::PhCal { + slope: decode_float(&p[0..5]), + offset: decode_float(&p[5..10]), + }) + } _ => None, } } @@ -546,3 +557,15 @@ pub fn build_sysex_set_cl_factor(f: f32) -> Vec { 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)); + sx.push(0xF7); + sx +} + +pub fn build_sysex_get_ph_cal() -> Vec { + vec![0xF0, SYSEX_MFR, CMD_GET_PH_CAL, 0xF7] +}