update Rust app for 9-point pH calibration protocol
This commit is contained in:
parent
ca512f9f05
commit
c5f2dcfedb
254
cue/src/app.rs
254
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<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![
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue