cue: calibration calculator — solution prep, conductivity, cell constant from EIS

This commit is contained in:
jess 2026-03-31 20:41:39 -07:00
parent 4beb9f4408
commit 6351a8baa0
1 changed files with 159 additions and 7 deletions

View File

@ -31,6 +31,7 @@ pub enum Tab {
Amp,
Chlorine,
Ph,
Calibrate,
Browse,
}
@ -97,6 +98,13 @@ pub enum Message {
/* pH */
PhStabilizeChanged(String),
StartPh,
/* Calibration */
CalVolumeChanged(String),
CalNaclChanged(String),
CalClChanged(String),
CalBleachChanged(String),
CalTempChanged(String),
CalComputeK,
/* Global */
PollTemp,
NativeMenuTick,
@ -221,6 +229,14 @@ pub struct App {
clean_v: String,
clean_dur: String,
/* Calibration */
cal_volume_gal: String,
cal_nacl_ppm: String,
cal_cl_ppm: String,
cal_bleach_pct: String,
cal_temp_c: String,
cal_cell_constant: Option<f32>,
/* Global */
temp_c: f32,
midi_gen: u64,
@ -271,6 +287,34 @@ fn fmt_cl(pts: &[ClPoint]) -> String {
s
}
fn calc_salt_grams(volume_gal: f32, target_ppm: f32) -> f32 {
let liters = volume_gal * 3.78541;
target_ppm * liters / 1000.0
}
fn calc_bleach_ml(volume_gal: f32, target_cl_ppm: f32, bleach_pct: f32) -> f32 {
let liters = volume_gal * 3.78541;
let cl_needed_mg = target_cl_ppm * liters;
let bleach_mg_per_ml = bleach_pct * 10.0;
cl_needed_mg / bleach_mg_per_ml
}
fn theoretical_conductivity_ms_cm(nacl_ppm: f32, temp_c: f32) -> f32 {
let kappa_25 = nacl_ppm * 2.0 / 1000.0;
kappa_25 * (1.0 + 0.0212 * (temp_c - 25.0))
}
fn extract_rs(eis_points: &[EisPoint]) -> Option<f32> {
eis_points.iter()
.map(|p| p.z_real)
.filter(|r| r.is_finite() && *r > 0.0)
.min_by(|a, b| a.partial_cmp(b).unwrap())
}
fn cell_constant(kappa_ms_cm: f32, rs_ohm: f32) -> f32 {
(kappa_ms_cm / 1000.0) * rs_ohm
}
const SQUIRCLE: f32 = 8.0;
fn btn_style(bg: Color, fg: Color) -> impl Fn(&Theme, button::Status) -> ButtonStyle {
@ -417,6 +461,13 @@ impl App {
clean_v: "1200".into(),
clean_dur: "30".into(),
cal_volume_gal: "25".into(),
cal_nacl_ppm: "2500".into(),
cal_cl_ppm: "5".into(),
cal_bleach_pct: "7.825".into(),
cal_temp_c: "40".into(),
cal_cell_constant: None,
temp_c: 25.0,
midi_gen: 0,
transport: TransportMode::Midi,
@ -717,7 +768,7 @@ impl App {
Tab::Lsv => self.lsv_data.perform(action),
Tab::Amp => self.amp_data.perform(action),
Tab::Chlorine => self.cl_data.perform(action),
Tab::Ph | Tab::Browse => {}
Tab::Ph | Tab::Calibrate | Tab::Browse => {}
}
}
}
@ -870,7 +921,7 @@ impl App {
Tab::Amp => { self.amp_ref = None; self.status = "Amp reference cleared".into(); }
Tab::Chlorine => { self.cl_ref = None; self.status = "Chlorine reference cleared".into(); }
Tab::Ph => { self.ph_ref = None; self.status = "pH reference cleared".into(); }
Tab::Browse => {}
Tab::Calibrate | Tab::Browse => {}
}
}
/* Global */
@ -887,6 +938,24 @@ impl App {
Message::CloseSysInfo => {
self.show_sysinfo = false;
}
/* Calibration */
Message::CalVolumeChanged(s) => self.cal_volume_gal = s,
Message::CalNaclChanged(s) => self.cal_nacl_ppm = s,
Message::CalClChanged(s) => self.cal_cl_ppm = s,
Message::CalBleachChanged(s) => self.cal_bleach_pct = s,
Message::CalTempChanged(s) => self.cal_temp_c = s,
Message::CalComputeK => {
let ppm = self.cal_nacl_ppm.parse::<f32>().unwrap_or(2500.0);
let temp = self.cal_temp_c.parse::<f32>().unwrap_or(40.0);
let kappa = theoretical_conductivity_ms_cm(ppm, temp);
if let Some(rs) = extract_rs(&self.eis_points) {
let k = cell_constant(kappa, rs);
self.cal_cell_constant = Some(k);
self.status = format!("Cell constant: {:.4} cm-1 (Rs={:.1} ohm)", k, rs);
} else {
self.status = "No valid EIS data for Rs extraction".into();
}
}
/* Clean */
Message::CleanVChanged(s) => self.clean_v = s,
Message::CleanDurChanged(s) => self.clean_dur = s,
@ -1128,6 +1197,7 @@ impl App {
tab_btn("Amperometry", Tab::Amp, self.tab == Tab::Amp),
tab_btn("Chlorine", Tab::Chlorine, self.tab == Tab::Chlorine),
tab_btn("pH", Tab::Ph, self.tab == Tab::Ph),
tab_btn("Cal", Tab::Calibrate, self.tab == Tab::Calibrate),
tab_btn("Browse", Tab::Browse, self.tab == Tab::Browse),
]
.spacing(4)
@ -1158,7 +1228,7 @@ impl App {
Tab::Amp => self.amp_ref.is_some(),
Tab::Chlorine => self.cl_ref.is_some(),
Tab::Ph => self.ph_ref.is_some(),
Tab::Browse => false,
Tab::Calibrate | Tab::Browse => false,
};
let has_data = match self.tab {
Tab::Eis => !self.eis_points.is_empty(),
@ -1166,7 +1236,7 @@ impl App {
Tab::Amp => !self.amp_points.is_empty(),
Tab::Chlorine => self.cl_result.is_some(),
Tab::Ph => self.ph_result.is_some(),
Tab::Browse => false,
Tab::Calibrate | Tab::Browse => false,
};
let mut ref_row = row![].spacing(4).align_y(iced::Alignment::Center);
@ -1264,6 +1334,8 @@ impl App {
self.view_browse_body()
} else if self.tab == Tab::Ph {
self.view_ph_body()
} else if self.tab == Tab::Calibrate {
self.view_cal_body()
} else {
pane_grid::PaneGrid::new(&self.panes, |_pane, &pane_id, _maximized| {
let el = match pane_id {
@ -1501,7 +1573,7 @@ impl App {
.align_y(iced::Alignment::End)
.into(),
Tab::Browse => row![].into(),
Tab::Calibrate | Tab::Browse => row![].into(),
}
}
@ -1561,7 +1633,7 @@ impl App {
column![result_text, plot].spacing(4).height(Length::Fill).into()
}
}
Tab::Ph | Tab::Browse => text("").into(),
Tab::Ph | Tab::Calibrate | Tab::Browse => text("").into(),
}
}
@ -1571,7 +1643,7 @@ impl App {
Tab::Lsv => &self.lsv_data,
Tab::Amp => &self.amp_data,
Tab::Chlorine => &self.cl_data,
Tab::Ph | Tab::Browse => return text("").into(),
Tab::Ph | Tab::Calibrate | Tab::Browse => return text("").into(),
};
text_editor(content)
.on_action(Message::DataAction)
@ -1582,6 +1654,86 @@ impl App {
.into()
}
fn view_cal_body(&self) -> Element<'_, Message> {
let vol = self.cal_volume_gal.parse::<f32>().unwrap_or(0.0);
let ppm = self.cal_nacl_ppm.parse::<f32>().unwrap_or(0.0);
let cl = self.cal_cl_ppm.parse::<f32>().unwrap_or(0.0);
let bleach = self.cal_bleach_pct.parse::<f32>().unwrap_or(7.825);
let temp = self.cal_temp_c.parse::<f32>().unwrap_or(40.0);
let salt_g = calc_salt_grams(vol, ppm);
let salt_tbsp = salt_g / 17.0;
let bleach_ml = calc_bleach_ml(vol, cl, bleach);
let bleach_tsp = bleach_ml / 5.0;
let kappa = theoretical_conductivity_ms_cm(ppm, temp);
let inputs = column![
text("Calibration Solution").size(16),
iced::widget::horizontal_rule(1),
row![
column![
text("Volume (gal)").size(12),
text_input("25", &self.cal_volume_gal)
.on_input(Message::CalVolumeChanged).width(80),
].spacing(2),
column![
text("NaCl ppm").size(12),
text_input("2500", &self.cal_nacl_ppm)
.on_input(Message::CalNaclChanged).width(80),
].spacing(2),
column![
text("Cl ppm").size(12),
text_input("5", &self.cal_cl_ppm)
.on_input(Message::CalClChanged).width(80),
].spacing(2),
column![
text("Bleach %").size(12),
text_input("7.825", &self.cal_bleach_pct)
.on_input(Message::CalBleachChanged).width(80),
].spacing(2),
column![
text("Temp C").size(12),
text_input("40", &self.cal_temp_c)
.on_input(Message::CalTempChanged).width(80),
].spacing(2),
].spacing(10).align_y(iced::Alignment::End),
].spacing(6);
let mut results = column![
text("Results").size(16),
iced::widget::horizontal_rule(1),
text(format!("Salt: {:.1} g ({:.1} tbsp sea salt)", salt_g, salt_tbsp)).size(14),
text(format!("Bleach: {:.1} mL ({:.1} tsp)", bleach_ml, bleach_tsp)).size(14),
text(format!("Theoretical kappa at {:.0} C: {:.3} mS/cm", temp, kappa)).size(14),
].spacing(4);
let rs = extract_rs(&self.eis_points);
if let Some(rs_val) = rs {
results = results.push(text(format!("Rs from sweep: {:.1} ohm", rs_val)).size(14));
} else {
results = results.push(text("Rs from sweep: (no EIS data)").size(14));
}
if let Some(k) = self.cal_cell_constant {
results = results.push(text(format!("Cell constant K: {:.4} cm-1", k)).size(14));
}
let compute_btn = button(text("Calculate K from Sweep").size(13))
.style(style_action())
.padding([6, 16])
.on_press(Message::CalComputeK);
results = results.push(compute_btn);
row![
container(inputs).width(Length::FillPortion(2)),
iced::widget::vertical_rule(1),
container(results).width(Length::FillPortion(3)),
]
.spacing(12)
.height(Length::Fill)
.into()
}
fn view_ph_body(&self) -> Element<'_, Message> {
if let Some(r) = &self.ph_result {
let nernst_slope = 0.1984 * (r.temp_c as f64 + 273.15);