desktop: add pH cal protocol, Q/HQ peak detection, and state

This commit is contained in:
jess 2026-04-02 19:32:27 -07:00
parent 5b051cfa20
commit bdb72a9917
3 changed files with 67 additions and 0 deletions

View File

@ -100,6 +100,11 @@ pub enum Message {
CalComputeK, CalComputeK,
ClCalKnownPpmChanged(String), ClCalKnownPpmChanged(String),
ClSetFactor, ClSetFactor,
/* pH calibration */
PhCalKnownChanged(String),
PhAddCalPoint,
PhClearCalPoints,
PhComputeAndSetCal,
/* Global */ /* Global */
PollTemp, PollTemp,
NativeMenuTick, NativeMenuTick,
@ -235,6 +240,10 @@ pub struct App {
cal_cell_constant: Option<f32>, cal_cell_constant: Option<f32>,
cl_factor: Option<f32>, cl_factor: Option<f32>,
cl_cal_known_ppm: String, cl_cal_known_ppm: String,
ph_slope: Option<f32>,
ph_offset: Option<f32>,
ph_cal_points: Vec<(f32, f32)>,
ph_cal_known: String,
/* Global */ /* Global */
temp_c: f32, temp_c: f32,
@ -469,6 +478,10 @@ impl App {
cal_cell_constant: None, cal_cell_constant: None,
cl_factor: None, cl_factor: None,
cl_cal_known_ppm: String::from("5"), 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, temp_c: 25.0,
conn_gen: 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_config());
self.send_cmd(&protocol::build_sysex_get_cell_k()); 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_cl_factor());
self.send_cmd(&protocol::build_sysex_get_ph_cal());
} }
Message::DeviceStatus(s) => { Message::DeviceStatus(s) => {
if s.contains("Reconnecting") || s.contains("Connecting") { if s.contains("Reconnecting") || s.contains("Connecting") {
@ -754,6 +768,11 @@ impl App {
self.cl_factor = Some(f); self.cl_factor = Some(f);
self.status = format!("Device Cl factor: {:.6}", 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) => { Message::TabSelected(t) => {
if t == Tab::Browse { if t == Tab::Browse {

View File

@ -66,6 +66,31 @@ pub fn find_extrema(_v: &[f32], i_smooth: &[f32], min_prominence: f32) -> Vec<(u
candidates 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<f32> {
if points.len() < 5 {
return None;
}
let i_vals: Vec<f32> = points.iter().map(|p| p.i_ua).collect();
let v_vals: Vec<f32> = 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<LsvPeak> { pub fn detect_peaks(points: &[LsvPoint]) -> Vec<LsvPeak> {
if points.len() < 5 { if points.len() < 5 {
return Vec::new(); return Vec::new();

View File

@ -27,6 +27,7 @@ pub const RSP_REFS_DONE: u8 = 0x22;
pub const RSP_CELL_K: u8 = 0x11; pub const RSP_CELL_K: u8 = 0x11;
pub const RSP_REF_STATUS: u8 = 0x23; pub const RSP_REF_STATUS: u8 = 0x23;
pub const RSP_CL_FACTOR: u8 = 0x24; pub const RSP_CL_FACTOR: u8 = 0x24;
pub const RSP_PH_CAL: u8 = 0x25;
/* Cue → ESP32 */ /* Cue → ESP32 */
pub const CMD_SET_SWEEP: u8 = 0x10; 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_GET_CELL_K: u8 = 0x29;
pub const CMD_SET_CL_FACTOR: u8 = 0x33; pub const CMD_SET_CL_FACTOR: u8 = 0x33;
pub const CMD_GET_CL_FACTOR: u8 = 0x34; 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_START_REFS: u8 = 0x30;
pub const CMD_GET_REFS: u8 = 0x31; pub const CMD_GET_REFS: u8 = 0x31;
pub const CMD_CLEAR_REFS: u8 = 0x32; pub const CMD_CLEAR_REFS: u8 = 0x32;
@ -262,6 +265,7 @@ pub enum EisMessage {
RefStatus { has_refs: bool }, RefStatus { has_refs: bool },
CellK(f32), CellK(f32),
ClFactor(f32), ClFactor(f32),
PhCal { slope: f32, offset: f32 },
} }
fn decode_u16(data: &[u8]) -> u16 { fn decode_u16(data: &[u8]) -> u16 {
@ -421,6 +425,13 @@ pub fn parse_sysex(data: &[u8]) -> Option<EisMessage> {
let p = &data[2..]; let p = &data[2..];
Some(EisMessage::ClFactor(decode_float(&p[0..5]))) 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, _ => None,
} }
} }
@ -546,3 +557,15 @@ pub fn build_sysex_set_cl_factor(f: f32) -> Vec<u8> {
pub fn build_sysex_get_cl_factor() -> Vec<u8> { pub fn build_sysex_get_cl_factor() -> Vec<u8> {
vec![0xF0, SYSEX_MFR, CMD_GET_CL_FACTOR, 0xF7] 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));
sx.push(0xF7);
sx
}
pub fn build_sysex_get_ph_cal() -> Vec<u8> {
vec![0xF0, SYSEX_MFR, CMD_GET_PH_CAL, 0xF7]
}