diff --git a/cue/src/app.rs b/cue/src/app.rs index 1d71c4a..91b8ef4 100644 --- a/cue/src/app.rs +++ b/cue/src/app.rs @@ -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, + /* 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 { + 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::().unwrap_or(2500.0); + let temp = self.cal_temp_c.parse::().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::().unwrap_or(0.0); + let ppm = self.cal_nacl_ppm.parse::().unwrap_or(0.0); + let cl = self.cal_cl_ppm.parse::().unwrap_or(0.0); + let bleach = self.cal_bleach_pct.parse::().unwrap_or(7.825); + let temp = self.cal_temp_c.parse::().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);