cue: calibration calculator — solution prep, conductivity, cell constant from EIS
This commit is contained in:
parent
4beb9f4408
commit
6351a8baa0
166
cue/src/app.rs
166
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<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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue