2306 lines
92 KiB
Rust
2306 lines
92 KiB
Rust
use futures::SinkExt;
|
|
use iced::widget::{
|
|
button, canvas, column, container, pane_grid, pick_list, row, rule, scrollable, text,
|
|
text_editor, text_input,
|
|
};
|
|
use iced::widget::button::Style as ButtonStyle;
|
|
use iced::{Border, Color, Element, Length, Subscription, Task, Theme};
|
|
use std::fmt::Write;
|
|
use std::time::Duration;
|
|
use tokio::sync::mpsc;
|
|
|
|
use crate::native_menu::{MenuAction, NativeMenu};
|
|
use crate::protocol::{
|
|
self, AmpPoint, ClPoint, ClResult, Electrode, EisMessage, EisPoint, LpRtia, LsvPoint,
|
|
PhResult, Rcal, Rtia,
|
|
};
|
|
use crate::storage::{self, Session, Storage};
|
|
use crate::udp::UdpEvent;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Tab {
|
|
Eis,
|
|
Lsv,
|
|
Amp,
|
|
Chlorine,
|
|
Ph,
|
|
Calibrate,
|
|
Browse,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum PaneId {
|
|
Plot,
|
|
Data,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
enum SessionItem {
|
|
None,
|
|
Some(i64, String),
|
|
}
|
|
|
|
impl std::fmt::Display for SessionItem {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
SessionItem::None => f.write_str("(none)"),
|
|
SessionItem::Some(_, name) => f.write_str(name),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum Message {
|
|
DeviceReady(mpsc::UnboundedSender<Vec<u8>>),
|
|
DeviceStatus(String),
|
|
DeviceData(EisMessage),
|
|
TabSelected(Tab),
|
|
PaneResized(pane_grid::ResizeEvent),
|
|
DataAction(text_editor::Action),
|
|
/* EIS */
|
|
FreqStartChanged(String),
|
|
FreqStopChanged(String),
|
|
PpdChanged(String),
|
|
RtiaSelected(Rtia),
|
|
RcalSelected(Rcal),
|
|
ElectrodeSelected(Electrode),
|
|
ApplySettings,
|
|
StartSweep,
|
|
/* LSV */
|
|
LsvStartVChanged(String),
|
|
LsvStopVChanged(String),
|
|
LsvScanRateChanged(String),
|
|
LsvRtiaSelected(LpRtia),
|
|
StartLsv,
|
|
/* Amperometry */
|
|
AmpVholdChanged(String),
|
|
AmpIntervalChanged(String),
|
|
AmpDurationChanged(String),
|
|
AmpRtiaSelected(LpRtia),
|
|
StartAmp,
|
|
StopAmp,
|
|
/* Chlorine */
|
|
ClCondVChanged(String),
|
|
ClCondTChanged(String),
|
|
ClFreeVChanged(String),
|
|
ClTotalVChanged(String),
|
|
ClDepTChanged(String),
|
|
ClMeasTChanged(String),
|
|
ClRtiaSelected(LpRtia),
|
|
StartCl,
|
|
/* pH */
|
|
PhStabilizeChanged(String),
|
|
StartPh,
|
|
/* Calibration */
|
|
CalVolumeChanged(String),
|
|
CalNaclChanged(String),
|
|
CalClChanged(String),
|
|
CalBleachChanged(String),
|
|
CalTempChanged(String),
|
|
CalComputeK,
|
|
ClCalKnownPpmChanged(String),
|
|
ClSetFactor,
|
|
/* pH calibration */
|
|
PhCalKnownChanged(String),
|
|
PhAddCalPoint,
|
|
PhClearCalPoints,
|
|
PhComputeAndSetCal,
|
|
/* Global */
|
|
PollTemp,
|
|
NativeMenuTick,
|
|
CloseSysInfo,
|
|
/* Reference baseline */
|
|
SetReference,
|
|
ClearReference,
|
|
CollectRefs,
|
|
GetRefs,
|
|
ClearRefs,
|
|
/* Clean */
|
|
CleanVChanged(String),
|
|
CleanDurChanged(String),
|
|
StartClean,
|
|
/* Sessions */
|
|
CreateSession,
|
|
SelectSession(Option<i64>),
|
|
DeleteSession,
|
|
SessionNameInput(String),
|
|
/* Browse */
|
|
BrowseSelectSession(i64),
|
|
BrowseSelectMeasurement(i64),
|
|
BrowseLoadAsActive(i64),
|
|
BrowseLoadAsReference(i64),
|
|
BrowseDeleteMeasurement(i64),
|
|
BrowseBack,
|
|
ExportSession(i64),
|
|
ImportSession,
|
|
/* LSV analysis */
|
|
LsvToggleManual,
|
|
/* Misc */
|
|
Reconnect,
|
|
UdpAddrChanged(String),
|
|
}
|
|
|
|
pub struct App {
|
|
tab: Tab,
|
|
status: String,
|
|
cmd_tx: Option<mpsc::UnboundedSender<Vec<u8>>>,
|
|
connected: bool,
|
|
panes: pane_grid::State<PaneId>,
|
|
native_menu: NativeMenu,
|
|
show_sysinfo: bool,
|
|
|
|
/* Storage */
|
|
storage: Storage,
|
|
current_session: Option<i64>,
|
|
sessions: Vec<Session>,
|
|
session_name_input: String,
|
|
creating_session: bool,
|
|
|
|
/* Browse */
|
|
browse_sessions: Vec<(Session, i64)>,
|
|
browse_measurements: Vec<(storage::Measurement, i64)>,
|
|
browse_selected_session: Option<i64>,
|
|
browse_selected_measurement: Option<i64>,
|
|
browse_preview: String,
|
|
|
|
/* EIS */
|
|
eis_points: Vec<EisPoint>,
|
|
sweep_total: u16,
|
|
freq_start: String,
|
|
freq_stop: String,
|
|
ppd: String,
|
|
rtia: Rtia,
|
|
rcal: Rcal,
|
|
electrode: Electrode,
|
|
eis_data: text_editor::Content,
|
|
|
|
/* LSV */
|
|
lsv_points: Vec<LsvPoint>,
|
|
lsv_total: u16,
|
|
lsv_start_v: String,
|
|
lsv_stop_v: String,
|
|
lsv_scan_rate: String,
|
|
lsv_rtia: LpRtia,
|
|
lsv_peaks: Vec<crate::lsv_analysis::LsvPeak>,
|
|
lsv_manual_peaks: bool,
|
|
lsv_data: text_editor::Content,
|
|
|
|
/* Amp */
|
|
amp_points: Vec<AmpPoint>,
|
|
amp_total: u16,
|
|
amp_running: bool,
|
|
amp_v_hold: String,
|
|
amp_interval: String,
|
|
amp_duration: String,
|
|
amp_rtia: LpRtia,
|
|
amp_data: text_editor::Content,
|
|
|
|
/* Chlorine */
|
|
cl_points: Vec<ClPoint>,
|
|
cl_result: Option<ClResult>,
|
|
cl_total: u16,
|
|
cl_cond_v: String,
|
|
cl_cond_t: String,
|
|
cl_free_v: String,
|
|
cl_total_v: String,
|
|
cl_dep_t: String,
|
|
cl_meas_t: String,
|
|
cl_rtia: LpRtia,
|
|
cl_data: text_editor::Content,
|
|
|
|
/* pH */
|
|
ph_result: Option<PhResult>,
|
|
ph_stabilize: String,
|
|
|
|
/* Reference baselines */
|
|
eis_ref: Option<Vec<EisPoint>>,
|
|
lsv_ref: Option<Vec<LsvPoint>>,
|
|
amp_ref: Option<Vec<AmpPoint>>,
|
|
cl_ref: Option<(Vec<ClPoint>, ClResult)>,
|
|
ph_ref: Option<PhResult>,
|
|
|
|
/* Device reference collection */
|
|
collecting_refs: bool,
|
|
ref_mode: Option<u8>,
|
|
ref_rtia: Option<u8>,
|
|
has_device_refs: bool,
|
|
eis_refs: [Option<Vec<EisPoint>>; 8],
|
|
lsv_lp_range: Option<(u8, u8)>,
|
|
amp_lp_range: Option<(u8, u8)>,
|
|
cl_lp_range: Option<(u8, u8)>,
|
|
|
|
/* Clean */
|
|
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>,
|
|
cl_factor: Option<f32>,
|
|
cl_cal_known_ppm: String,
|
|
ph_slope: Option<f32>,
|
|
ph_offset: Option<f32>,
|
|
ph_cal_points: Vec<(f32, f32)>,
|
|
ph_cal_known: String,
|
|
|
|
/* Global */
|
|
temp_c: f32,
|
|
conn_gen: u64,
|
|
udp_addr: String,
|
|
}
|
|
|
|
/* ---- data table formatting ---- */
|
|
|
|
fn fmt_eis(pts: &[EisPoint]) -> String {
|
|
let mut s = String::with_capacity(pts.len() * 130 + 130);
|
|
writeln!(s, "{:>10} {:>12} {:>10} {:>12} {:>12} {:>10} {:>10} {:>12} {:>10} {:>7}",
|
|
"Freq (Hz)", "|Z| (Ohm)", "Phase (°)", "Re (Ohm)", "Im (Ohm)",
|
|
"RTIA bef", "RTIA aft", "|Z| rev", "Ph rev", "Err%").unwrap();
|
|
for pt in pts {
|
|
writeln!(s, "{:>10.1} {:>12.2} {:>10.2} {:>12.2} {:>12.2} {:>10.1} {:>10.1} {:>12.2} {:>10.2} {:>6.2}%",
|
|
pt.freq_hz, pt.mag_ohms, pt.phase_deg, pt.z_real, pt.z_imag,
|
|
pt.rtia_mag_before, pt.rtia_mag_after, pt.rev_mag, pt.rev_phase, pt.pct_err).unwrap();
|
|
}
|
|
s
|
|
}
|
|
|
|
fn fmt_lsv(pts: &[LsvPoint]) -> String {
|
|
let mut s = String::with_capacity(pts.len() * 28 + 28);
|
|
writeln!(s, "{:>10} {:>12}", "V (mV)", "I (uA)").unwrap();
|
|
for pt in pts {
|
|
writeln!(s, "{:>10.1} {:>12.3}", pt.v_mv, pt.i_ua).unwrap();
|
|
}
|
|
s
|
|
}
|
|
|
|
fn fmt_amp(pts: &[AmpPoint]) -> String {
|
|
let mut s = String::with_capacity(pts.len() * 28 + 28);
|
|
writeln!(s, "{:>10} {:>12}", "t (ms)", "I (uA)").unwrap();
|
|
for pt in pts {
|
|
writeln!(s, "{:>10.1} {:>12.3}", pt.t_ms, pt.i_ua).unwrap();
|
|
}
|
|
s
|
|
}
|
|
|
|
fn fmt_cl(pts: &[ClPoint]) -> String {
|
|
let mut s = String::with_capacity(pts.len() * 36 + 36);
|
|
writeln!(s, "{:>10} {:>12} {:>8}", "t (ms)", "I (uA)", "Phase").unwrap();
|
|
for pt in pts {
|
|
let phase = match pt.phase { 1 => "Free", 2 => "Total", _ => "Cond" };
|
|
writeln!(s, "{:>10.1} {:>12.3} {:>8}", pt.t_ms, pt.i_ua, phase).unwrap();
|
|
}
|
|
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 {
|
|
move |_theme, status| {
|
|
let (bg, fg) = match status {
|
|
button::Status::Hovered => (
|
|
Color { a: bg.a * 0.85, ..bg },
|
|
fg,
|
|
),
|
|
button::Status::Pressed => (
|
|
Color { a: bg.a * 0.7, ..bg },
|
|
fg,
|
|
),
|
|
button::Status::Disabled => (
|
|
Color { a: 0.3, ..bg },
|
|
Color { a: 0.5, ..fg },
|
|
),
|
|
_ => (bg, fg),
|
|
};
|
|
ButtonStyle {
|
|
background: Some(iced::Background::Color(bg)),
|
|
text_color: fg,
|
|
border: Border {
|
|
radius: SQUIRCLE.into(),
|
|
..Border::default()
|
|
},
|
|
..ButtonStyle::default()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn style_action() -> impl Fn(&Theme, button::Status) -> ButtonStyle {
|
|
btn_style(Color::from_rgb(0.20, 0.65, 0.35), Color::WHITE)
|
|
}
|
|
|
|
fn style_danger() -> impl Fn(&Theme, button::Status) -> ButtonStyle {
|
|
btn_style(Color::from_rgb(0.80, 0.25, 0.25), Color::WHITE)
|
|
}
|
|
|
|
fn style_apply() -> impl Fn(&Theme, button::Status) -> ButtonStyle {
|
|
btn_style(Color::from_rgb(0.25, 0.50, 0.85), Color::WHITE)
|
|
}
|
|
|
|
fn style_tab(active: bool) -> impl Fn(&Theme, button::Status) -> ButtonStyle {
|
|
let bg = if active {
|
|
Color::from_rgb(0.35, 0.35, 0.40)
|
|
} else {
|
|
Color::from_rgb(0.22, 0.22, 0.25)
|
|
};
|
|
btn_style(bg, Color::WHITE)
|
|
}
|
|
|
|
fn style_neutral() -> impl Fn(&Theme, button::Status) -> ButtonStyle {
|
|
btn_style(Color::from_rgb(0.30, 0.30, 0.35), Color::WHITE)
|
|
}
|
|
|
|
impl App {
|
|
pub fn new() -> (Self, Task<Message>) {
|
|
let storage = Storage::open().expect("failed to open database");
|
|
let sessions = storage.list_sessions().unwrap_or_default();
|
|
(Self {
|
|
tab: Tab::Eis,
|
|
status: "Starting...".into(),
|
|
cmd_tx: None,
|
|
connected: false,
|
|
panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split {
|
|
axis: pane_grid::Axis::Horizontal,
|
|
ratio: 0.55,
|
|
a: Box::new(pane_grid::Configuration::Pane(PaneId::Plot)),
|
|
b: Box::new(pane_grid::Configuration::Pane(PaneId::Data)),
|
|
}),
|
|
native_menu: NativeMenu::init(),
|
|
show_sysinfo: false,
|
|
|
|
storage,
|
|
current_session: None,
|
|
sessions,
|
|
session_name_input: String::new(),
|
|
creating_session: false,
|
|
|
|
browse_sessions: Vec::new(),
|
|
browse_measurements: Vec::new(),
|
|
browse_selected_session: None,
|
|
browse_selected_measurement: None,
|
|
browse_preview: String::new(),
|
|
|
|
eis_points: Vec::new(),
|
|
sweep_total: 0,
|
|
freq_start: "1000".into(),
|
|
freq_stop: "200000".into(),
|
|
ppd: "10".into(),
|
|
rtia: Rtia::R5K,
|
|
rcal: Rcal::R3K,
|
|
electrode: Electrode::FourWire,
|
|
eis_data: text_editor::Content::with_text(&fmt_eis(&[])),
|
|
|
|
lsv_points: Vec::new(),
|
|
lsv_total: 0,
|
|
lsv_start_v: "0".into(),
|
|
lsv_stop_v: "500".into(),
|
|
lsv_scan_rate: "50".into(),
|
|
lsv_rtia: LpRtia::R10K,
|
|
lsv_peaks: Vec::new(),
|
|
lsv_manual_peaks: false,
|
|
lsv_data: text_editor::Content::with_text(&fmt_lsv(&[])),
|
|
|
|
amp_points: Vec::new(),
|
|
amp_total: 0,
|
|
amp_running: false,
|
|
amp_v_hold: "200".into(),
|
|
amp_interval: "100".into(),
|
|
amp_duration: "60".into(),
|
|
amp_rtia: LpRtia::R10K,
|
|
amp_data: text_editor::Content::with_text(&fmt_amp(&[])),
|
|
|
|
cl_points: Vec::new(),
|
|
cl_result: None,
|
|
cl_total: 0,
|
|
cl_cond_v: "800".into(),
|
|
cl_cond_t: "2000".into(),
|
|
cl_free_v: "100".into(),
|
|
cl_total_v: "-200".into(),
|
|
cl_dep_t: "5000".into(),
|
|
cl_meas_t: "5000".into(),
|
|
cl_rtia: LpRtia::R10K,
|
|
cl_data: text_editor::Content::with_text(&fmt_cl(&[])),
|
|
|
|
ph_result: None,
|
|
ph_stabilize: "30".into(),
|
|
|
|
eis_ref: None,
|
|
lsv_ref: None,
|
|
amp_ref: None,
|
|
cl_ref: None,
|
|
ph_ref: None,
|
|
|
|
collecting_refs: false,
|
|
ref_mode: None,
|
|
ref_rtia: None,
|
|
has_device_refs: false,
|
|
eis_refs: Default::default(),
|
|
lsv_lp_range: None,
|
|
amp_lp_range: None,
|
|
cl_lp_range: None,
|
|
|
|
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,
|
|
cl_factor: None,
|
|
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,
|
|
conn_gen: 0,
|
|
udp_addr: crate::udp::load_addr(),
|
|
}, Task::none())
|
|
}
|
|
|
|
pub fn title(&self) -> String { "Cue".into() }
|
|
pub fn theme(&self) -> Theme { Theme::Dark }
|
|
|
|
fn send_cmd(&self, sysex: &[u8]) {
|
|
if let Some(tx) = &self.cmd_tx {
|
|
let _ = tx.send(sysex.to_vec());
|
|
}
|
|
}
|
|
|
|
fn save_eis(&self, session_id: i64) {
|
|
let params = serde_json::json!({
|
|
"freq_start": self.freq_start,
|
|
"freq_stop": self.freq_stop,
|
|
"ppd": self.ppd,
|
|
"rtia": format!("{}", self.rtia),
|
|
"rcal": format!("{}", self.rcal),
|
|
"electrode": format!("{}", self.electrode),
|
|
});
|
|
if let Ok(mid) = self.storage.create_measurement(session_id, "eis", ¶ms.to_string()) {
|
|
let pts: Vec<(i32, String)> = self.eis_points.iter().enumerate()
|
|
.filter_map(|(i, p)| serde_json::to_string(p).ok().map(|j| (i as i32, j)))
|
|
.collect();
|
|
let _ = self.storage.add_data_points_batch(mid, &pts);
|
|
}
|
|
}
|
|
|
|
fn save_lsv(&self, session_id: i64) {
|
|
let params = serde_json::json!({
|
|
"v_start": self.lsv_start_v,
|
|
"v_stop": self.lsv_stop_v,
|
|
"scan_rate": self.lsv_scan_rate,
|
|
"rtia": format!("{}", self.lsv_rtia),
|
|
});
|
|
if let Ok(mid) = self.storage.create_measurement(session_id, "lsv", ¶ms.to_string()) {
|
|
let pts: Vec<(i32, String)> = self.lsv_points.iter().enumerate()
|
|
.filter_map(|(i, p)| serde_json::to_string(p).ok().map(|j| (i as i32, j)))
|
|
.collect();
|
|
let _ = self.storage.add_data_points_batch(mid, &pts);
|
|
}
|
|
}
|
|
|
|
fn save_amp(&self, session_id: i64) {
|
|
let params = serde_json::json!({
|
|
"v_hold": self.amp_v_hold,
|
|
"interval_ms": self.amp_interval,
|
|
"duration_s": self.amp_duration,
|
|
"rtia": format!("{}", self.amp_rtia),
|
|
});
|
|
if let Ok(mid) = self.storage.create_measurement(session_id, "amp", ¶ms.to_string()) {
|
|
let pts: Vec<(i32, String)> = self.amp_points.iter().enumerate()
|
|
.filter_map(|(i, p)| serde_json::to_string(p).ok().map(|j| (i as i32, j)))
|
|
.collect();
|
|
let _ = self.storage.add_data_points_batch(mid, &pts);
|
|
}
|
|
}
|
|
|
|
fn save_cl(&self, session_id: i64) {
|
|
let params = serde_json::json!({
|
|
"cond_v": self.cl_cond_v,
|
|
"cond_t": self.cl_cond_t,
|
|
"free_v": self.cl_free_v,
|
|
"total_v": self.cl_total_v,
|
|
"dep_t": self.cl_dep_t,
|
|
"meas_t": self.cl_meas_t,
|
|
"rtia": format!("{}", self.cl_rtia),
|
|
});
|
|
if let Ok(mid) = self.storage.create_measurement(session_id, "chlorine", ¶ms.to_string()) {
|
|
let mut pts: Vec<(i32, String)> = self.cl_points.iter().enumerate()
|
|
.filter_map(|(i, p)| serde_json::to_string(p).ok().map(|j| (i as i32, j)))
|
|
.collect();
|
|
if let Some(r) = &self.cl_result {
|
|
if let Ok(j) = serde_json::to_string(r) {
|
|
pts.push((pts.len() as i32, format!("{{\"result\":{}}}", j)));
|
|
}
|
|
}
|
|
let _ = self.storage.add_data_points_batch(mid, &pts);
|
|
}
|
|
}
|
|
|
|
fn save_ph(&self, session_id: i64, result: &PhResult) {
|
|
let params = serde_json::json!({
|
|
"stabilize_s": self.ph_stabilize,
|
|
});
|
|
if let Ok(mid) = self.storage.create_measurement(session_id, "ph", ¶ms.to_string()) {
|
|
if let Ok(j) = serde_json::to_string(result) {
|
|
let _ = self.storage.add_data_point(mid, 0, &j);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn update(&mut self, message: Message) -> Task<Message> {
|
|
match message {
|
|
Message::DeviceReady(tx) => {
|
|
self.cmd_tx = Some(tx);
|
|
self.connected = true;
|
|
self.send_cmd(&protocol::build_sysex_get_config());
|
|
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());
|
|
}
|
|
Message::DeviceStatus(s) => {
|
|
if s.contains("Reconnecting") || s.contains("Connecting") {
|
|
self.connected = false;
|
|
self.cmd_tx = None;
|
|
}
|
|
self.status = s;
|
|
}
|
|
Message::DeviceData(msg) => match msg {
|
|
EisMessage::SweepStart { num_points, freq_start, freq_stop } => {
|
|
if self.collecting_refs {
|
|
/* ref collection: clear temp buffer */
|
|
self.eis_points.clear();
|
|
self.sweep_total = num_points;
|
|
} else {
|
|
self.eis_points.clear();
|
|
self.sweep_total = num_points;
|
|
self.eis_data = text_editor::Content::with_text(&fmt_eis(&self.eis_points));
|
|
self.status = format!("Sweep: {} pts, {:.0}--{:.0} Hz", num_points, freq_start, freq_stop);
|
|
}
|
|
}
|
|
EisMessage::DataPoint { point, .. } => {
|
|
if self.collecting_refs {
|
|
self.eis_points.push(point);
|
|
} else {
|
|
self.eis_points.push(point);
|
|
self.eis_data = text_editor::Content::with_text(&fmt_eis(&self.eis_points));
|
|
self.status = format!("Receiving: {}/{}", self.eis_points.len(), self.sweep_total);
|
|
}
|
|
}
|
|
EisMessage::SweepEnd => {
|
|
if self.collecting_refs {
|
|
if let Some(r) = self.ref_rtia {
|
|
if (r as usize) < 8 {
|
|
self.eis_refs[r as usize] = Some(self.eis_points.clone());
|
|
}
|
|
}
|
|
self.eis_points.clear();
|
|
} else {
|
|
if let Some(sid) = self.current_session {
|
|
self.save_eis(sid);
|
|
}
|
|
self.status = format!("Sweep complete: {} points", self.eis_points.len());
|
|
}
|
|
}
|
|
EisMessage::Config(cfg) => {
|
|
self.freq_start = format!("{:.0}", cfg.freq_start);
|
|
self.freq_stop = format!("{:.0}", cfg.freq_stop);
|
|
self.ppd = format!("{}", cfg.ppd);
|
|
self.rtia = cfg.rtia;
|
|
self.rcal = cfg.rcal;
|
|
self.electrode = cfg.electrode;
|
|
self.status = "Config received".into();
|
|
}
|
|
EisMessage::LsvStart { num_points, v_start, v_stop } => {
|
|
self.lsv_points.clear();
|
|
self.lsv_total = num_points;
|
|
self.lsv_data = text_editor::Content::with_text(&fmt_lsv(&self.lsv_points));
|
|
self.status = format!("LSV: {} pts, {:.0}--{:.0} mV", num_points, v_start, v_stop);
|
|
}
|
|
EisMessage::LsvPoint { point, .. } => {
|
|
self.lsv_points.push(point);
|
|
self.lsv_data = text_editor::Content::with_text(&fmt_lsv(&self.lsv_points));
|
|
self.status = format!("LSV: {}/{}", self.lsv_points.len(), self.lsv_total);
|
|
}
|
|
EisMessage::LsvEnd => {
|
|
if let Some(sid) = self.current_session {
|
|
self.save_lsv(sid);
|
|
}
|
|
if !self.lsv_manual_peaks {
|
|
self.lsv_peaks = crate::lsv_analysis::detect_peaks(&self.lsv_points);
|
|
}
|
|
let mut st = format!("LSV complete: {} points", self.lsv_points.len());
|
|
if let (Some(s), Some(o)) = (self.ph_slope, self.ph_offset) {
|
|
if let Some(peak) = crate::lsv_analysis::detect_qhq_peak(&self.lsv_points) {
|
|
if s.abs() > 1e-6 {
|
|
let ph = (peak - o) / s;
|
|
write!(st, " | pH={:.2}", ph).ok();
|
|
}
|
|
}
|
|
}
|
|
self.status = st;
|
|
}
|
|
EisMessage::AmpStart { v_hold } => {
|
|
self.amp_points.clear();
|
|
self.amp_running = true;
|
|
self.amp_data = text_editor::Content::with_text(&fmt_amp(&self.amp_points));
|
|
self.status = format!("Amp: {:.0} mV", v_hold);
|
|
}
|
|
EisMessage::AmpPoint { _index, point } => {
|
|
self.amp_points.push(point);
|
|
self.amp_total = _index + 1;
|
|
self.amp_data = text_editor::Content::with_text(&fmt_amp(&self.amp_points));
|
|
self.status = format!("Amp: {} pts", self.amp_points.len());
|
|
}
|
|
EisMessage::AmpEnd => {
|
|
self.amp_running = false;
|
|
if let Some(sid) = self.current_session {
|
|
self.save_amp(sid);
|
|
}
|
|
self.status = format!("Amp complete: {} points", self.amp_points.len());
|
|
}
|
|
EisMessage::ClStart { num_points } => {
|
|
self.cl_points.clear();
|
|
self.cl_result = None;
|
|
self.cl_total = num_points;
|
|
self.cl_data = text_editor::Content::with_text(&fmt_cl(&self.cl_points));
|
|
self.status = format!("Chlorine: {} pts", num_points);
|
|
}
|
|
EisMessage::ClPoint { point, .. } => {
|
|
self.cl_points.push(point);
|
|
self.cl_data = text_editor::Content::with_text(&fmt_cl(&self.cl_points));
|
|
self.status = format!("Chlorine: {}/{}", self.cl_points.len(), self.cl_total);
|
|
}
|
|
EisMessage::ClResult(r) => {
|
|
self.cl_result = Some(r);
|
|
self.status = format!("Chlorine: free={:.3} uA, total={:.3} uA",
|
|
self.cl_result.as_ref().unwrap().i_free_ua,
|
|
self.cl_result.as_ref().unwrap().i_total_ua);
|
|
}
|
|
EisMessage::ClEnd => {
|
|
if let Some(sid) = self.current_session {
|
|
self.save_cl(sid);
|
|
}
|
|
self.status = format!("Chlorine complete: {} points", self.cl_points.len());
|
|
}
|
|
EisMessage::PhResult(r) => {
|
|
if self.collecting_refs {
|
|
self.ph_ref = Some(r);
|
|
} else {
|
|
if let Some(sid) = self.current_session {
|
|
self.save_ph(sid, &r);
|
|
}
|
|
self.status = format!("pH: {:.2} (OCP={:.1} mV, T={:.1}C)",
|
|
r.ph, r.v_ocp_mv, r.temp_c);
|
|
self.ph_result = Some(r);
|
|
}
|
|
}
|
|
EisMessage::Temperature(t) => {
|
|
self.temp_c = t;
|
|
}
|
|
EisMessage::RefFrame { mode, rtia_idx } => {
|
|
self.ref_mode = Some(mode);
|
|
self.ref_rtia = Some(rtia_idx);
|
|
let mode_name = match mode {
|
|
0 => "EIS", 1 => "LSV", 2 => "Amp", 3 => "Cl", 4 => "pH", _ => "?"
|
|
};
|
|
if mode == 0 {
|
|
self.status = format!("Ref: {} RTIA {}/8", mode_name, rtia_idx + 1);
|
|
} else {
|
|
self.status = format!("Ref: {} range search", mode_name);
|
|
}
|
|
}
|
|
EisMessage::RefLpRange { mode, low_idx, high_idx } => {
|
|
match mode {
|
|
1 => self.lsv_lp_range = Some((low_idx, high_idx)),
|
|
2 => self.amp_lp_range = Some((low_idx, high_idx)),
|
|
3 => self.cl_lp_range = Some((low_idx, high_idx)),
|
|
_ => {}
|
|
}
|
|
}
|
|
EisMessage::RefsDone => {
|
|
self.collecting_refs = false;
|
|
self.has_device_refs = true;
|
|
self.ref_mode = None;
|
|
self.ref_rtia = None;
|
|
/* populate eis_ref from current RTIA's ref if available */
|
|
let rtia_idx = self.rtia.as_byte() as usize;
|
|
if rtia_idx < 8 {
|
|
if let Some(pts) = &self.eis_refs[rtia_idx] {
|
|
self.eis_ref = Some(pts.clone());
|
|
}
|
|
}
|
|
self.status = "Reference collection complete".into();
|
|
}
|
|
EisMessage::RefStatus { has_refs } => {
|
|
self.has_device_refs = has_refs;
|
|
if !has_refs {
|
|
self.status = "No device refs".into();
|
|
}
|
|
}
|
|
EisMessage::CellK(k) => {
|
|
self.cal_cell_constant = Some(k);
|
|
self.status = format!("Device cell constant: {:.4} cm-1", k);
|
|
}
|
|
EisMessage::ClFactor(f) => {
|
|
self.cl_factor = Some(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) => {
|
|
if t == Tab::Browse {
|
|
self.browse_sessions = self.storage.list_sessions()
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|s| {
|
|
let cnt = self.storage.measurement_count(s.id).unwrap_or(0);
|
|
(s, cnt)
|
|
})
|
|
.collect();
|
|
self.browse_selected_session = None;
|
|
self.browse_selected_measurement = None;
|
|
self.browse_measurements.clear();
|
|
self.browse_preview.clear();
|
|
}
|
|
self.tab = t;
|
|
}
|
|
Message::PaneResized(event) => {
|
|
self.panes.resize(event.split, event.ratio);
|
|
}
|
|
Message::DataAction(action) => {
|
|
if !matches!(action, text_editor::Action::Edit(_)) {
|
|
match self.tab {
|
|
Tab::Eis => self.eis_data.perform(action),
|
|
Tab::Lsv => self.lsv_data.perform(action),
|
|
Tab::Amp => self.amp_data.perform(action),
|
|
Tab::Chlorine => self.cl_data.perform(action),
|
|
Tab::Ph | Tab::Calibrate | Tab::Browse => {}
|
|
}
|
|
}
|
|
}
|
|
/* EIS */
|
|
Message::FreqStartChanged(s) => self.freq_start = s,
|
|
Message::FreqStopChanged(s) => self.freq_stop = s,
|
|
Message::PpdChanged(s) => self.ppd = s,
|
|
Message::RtiaSelected(r) => {
|
|
self.rtia = r;
|
|
let idx = r.as_byte() as usize;
|
|
if idx < 8 {
|
|
if let Some(pts) = &self.eis_refs[idx] {
|
|
self.eis_ref = Some(pts.clone());
|
|
}
|
|
}
|
|
}
|
|
Message::RcalSelected(r) => self.rcal = r,
|
|
Message::ElectrodeSelected(e) => self.electrode = e,
|
|
Message::ApplySettings => {
|
|
let fs = self.freq_start.parse::<f32>().unwrap_or(1000.0);
|
|
let fe = self.freq_stop.parse::<f32>().unwrap_or(200000.0);
|
|
let ppd = self.ppd.parse::<u16>().unwrap_or(10);
|
|
self.send_cmd(&protocol::build_sysex_set_sweep(fs, fe, ppd));
|
|
self.send_cmd(&protocol::build_sysex_set_rtia(self.rtia));
|
|
self.send_cmd(&protocol::build_sysex_set_rcal(self.rcal));
|
|
self.send_cmd(&protocol::build_sysex_set_electrode(self.electrode));
|
|
self.send_cmd(&protocol::build_sysex_get_config());
|
|
}
|
|
Message::StartSweep => {
|
|
self.send_cmd(&protocol::build_sysex_get_temp());
|
|
self.send_cmd(&protocol::build_sysex_start_sweep());
|
|
}
|
|
/* LSV */
|
|
Message::LsvStartVChanged(s) => self.lsv_start_v = s,
|
|
Message::LsvStopVChanged(s) => self.lsv_stop_v = s,
|
|
Message::LsvScanRateChanged(s) => self.lsv_scan_rate = s,
|
|
Message::LsvRtiaSelected(r) => self.lsv_rtia = r,
|
|
Message::StartLsv => {
|
|
let vs = self.lsv_start_v.parse::<f32>().unwrap_or(0.0);
|
|
let ve = self.lsv_stop_v.parse::<f32>().unwrap_or(500.0);
|
|
let sr = self.lsv_scan_rate.parse::<f32>().unwrap_or(50.0);
|
|
self.send_cmd(&protocol::build_sysex_get_temp());
|
|
self.send_cmd(&protocol::build_sysex_start_lsv(vs, ve, sr, self.lsv_rtia));
|
|
}
|
|
Message::LsvToggleManual => {
|
|
self.lsv_manual_peaks = !self.lsv_manual_peaks;
|
|
if self.lsv_manual_peaks {
|
|
self.lsv_peaks.clear();
|
|
} else {
|
|
self.lsv_peaks = crate::lsv_analysis::detect_peaks(&self.lsv_points);
|
|
}
|
|
}
|
|
/* Amp */
|
|
Message::AmpVholdChanged(s) => self.amp_v_hold = s,
|
|
Message::AmpIntervalChanged(s) => self.amp_interval = s,
|
|
Message::AmpDurationChanged(s) => self.amp_duration = s,
|
|
Message::AmpRtiaSelected(r) => self.amp_rtia = r,
|
|
Message::StartAmp => {
|
|
let vh = self.amp_v_hold.parse::<f32>().unwrap_or(200.0);
|
|
let iv = self.amp_interval.parse::<f32>().unwrap_or(100.0);
|
|
let dur = self.amp_duration.parse::<f32>().unwrap_or(60.0);
|
|
self.send_cmd(&protocol::build_sysex_get_temp());
|
|
self.send_cmd(&protocol::build_sysex_start_amp(vh, iv, dur, self.amp_rtia));
|
|
}
|
|
Message::StopAmp => {
|
|
self.send_cmd(&protocol::build_sysex_stop_amp());
|
|
}
|
|
/* Chlorine */
|
|
Message::ClCondVChanged(s) => self.cl_cond_v = s,
|
|
Message::ClCondTChanged(s) => self.cl_cond_t = s,
|
|
Message::ClFreeVChanged(s) => self.cl_free_v = s,
|
|
Message::ClTotalVChanged(s) => self.cl_total_v = s,
|
|
Message::ClDepTChanged(s) => self.cl_dep_t = s,
|
|
Message::ClMeasTChanged(s) => self.cl_meas_t = s,
|
|
Message::ClRtiaSelected(r) => self.cl_rtia = r,
|
|
Message::StartCl => {
|
|
let v_cond = self.cl_cond_v.parse::<f32>().unwrap_or(800.0);
|
|
let t_cond = self.cl_cond_t.parse::<f32>().unwrap_or(2000.0);
|
|
let v_free = self.cl_free_v.parse::<f32>().unwrap_or(100.0);
|
|
let v_total = self.cl_total_v.parse::<f32>().unwrap_or(-200.0);
|
|
let t_dep = self.cl_dep_t.parse::<f32>().unwrap_or(5000.0);
|
|
let t_meas = self.cl_meas_t.parse::<f32>().unwrap_or(5000.0);
|
|
self.send_cmd(&protocol::build_sysex_get_temp());
|
|
self.send_cmd(&protocol::build_sysex_start_cl(
|
|
v_cond, t_cond, v_free, v_total, t_dep, t_meas, self.cl_rtia,
|
|
));
|
|
}
|
|
/* pH */
|
|
Message::PhStabilizeChanged(s) => self.ph_stabilize = s,
|
|
Message::StartPh => {
|
|
let stab = self.ph_stabilize.parse::<f32>().unwrap_or(30.0);
|
|
self.send_cmd(&protocol::build_sysex_get_temp());
|
|
self.send_cmd(&protocol::build_sysex_start_ph(stab));
|
|
}
|
|
/* Reference baseline */
|
|
Message::SetReference => {
|
|
match self.tab {
|
|
Tab::Eis if !self.eis_points.is_empty() => {
|
|
self.eis_ref = Some(self.eis_points.clone());
|
|
self.status = format!("EIS reference set ({} pts)", self.eis_points.len());
|
|
}
|
|
Tab::Lsv if !self.lsv_points.is_empty() => {
|
|
self.lsv_ref = Some(self.lsv_points.clone());
|
|
self.status = format!("LSV reference set ({} pts)", self.lsv_points.len());
|
|
}
|
|
Tab::Amp if !self.amp_points.is_empty() => {
|
|
self.amp_ref = Some(self.amp_points.clone());
|
|
self.status = format!("Amp reference set ({} pts)", self.amp_points.len());
|
|
}
|
|
Tab::Chlorine if !self.cl_points.is_empty() => {
|
|
if let Some(r) = &self.cl_result {
|
|
self.cl_ref = Some((self.cl_points.clone(), r.clone()));
|
|
self.status = "Chlorine reference set".into();
|
|
}
|
|
}
|
|
Tab::Ph => {
|
|
if let Some(r) = &self.ph_result {
|
|
self.ph_ref = Some(r.clone());
|
|
self.status = format!("pH reference set ({:.2})", r.ph);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
Message::CollectRefs => {
|
|
self.collecting_refs = true;
|
|
self.eis_refs = Default::default();
|
|
self.lsv_lp_range = None;
|
|
self.amp_lp_range = None;
|
|
self.cl_lp_range = None;
|
|
self.status = "Starting reference collection...".into();
|
|
self.send_cmd(&protocol::build_sysex_start_refs());
|
|
}
|
|
Message::GetRefs => {
|
|
self.collecting_refs = true;
|
|
self.eis_refs = Default::default();
|
|
self.lsv_lp_range = None;
|
|
self.amp_lp_range = None;
|
|
self.cl_lp_range = None;
|
|
self.send_cmd(&protocol::build_sysex_get_refs());
|
|
}
|
|
Message::ClearRefs => {
|
|
self.collecting_refs = false;
|
|
self.has_device_refs = false;
|
|
self.eis_refs = Default::default();
|
|
self.eis_ref = None;
|
|
self.lsv_lp_range = None;
|
|
self.amp_lp_range = None;
|
|
self.cl_lp_range = None;
|
|
self.ph_ref = None;
|
|
self.send_cmd(&protocol::build_sysex_clear_refs());
|
|
self.status = "Refs cleared".into();
|
|
}
|
|
Message::ClearReference => {
|
|
match self.tab {
|
|
Tab::Eis => { self.eis_ref = None; self.status = "EIS reference cleared".into(); }
|
|
Tab::Lsv => { self.lsv_ref = None; self.status = "LSV reference cleared".into(); }
|
|
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::Calibrate | Tab::Browse => {}
|
|
}
|
|
}
|
|
/* Global */
|
|
Message::PollTemp => {
|
|
self.send_cmd(&protocol::build_sysex_get_temp());
|
|
}
|
|
Message::NativeMenuTick => {
|
|
for action in self.native_menu.poll_events() {
|
|
match action {
|
|
MenuAction::SystemInfo => self.show_sysinfo = !self.show_sysinfo,
|
|
}
|
|
}
|
|
}
|
|
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.send_cmd(&protocol::build_sysex_set_cell_k(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();
|
|
}
|
|
}
|
|
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::PhClearCalPoints => {
|
|
self.ph_cal_points.clear();
|
|
self.status = "pH cal points cleared".into();
|
|
}
|
|
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::ClCalKnownPpmChanged(s) => { self.cl_cal_known_ppm = s; }
|
|
Message::ClSetFactor => {
|
|
let known_ppm = self.cl_cal_known_ppm.parse::<f32>().unwrap_or(0.0);
|
|
if let Some(r) = &self.cl_result {
|
|
let peak = r.i_free_ua.abs();
|
|
if peak > 0.0 {
|
|
let factor = known_ppm / peak;
|
|
self.cl_factor = Some(factor);
|
|
self.send_cmd(&protocol::build_sysex_set_cl_factor(factor));
|
|
self.status = format!("Cl factor: {:.6} ppm/uA", factor);
|
|
}
|
|
}
|
|
}
|
|
/* Clean */
|
|
Message::CleanVChanged(s) => self.clean_v = s,
|
|
Message::CleanDurChanged(s) => self.clean_dur = s,
|
|
Message::StartClean => {
|
|
let v = self.clean_v.parse::<f32>().unwrap_or(1200.0);
|
|
let d = self.clean_dur.parse::<f32>().unwrap_or(30.0);
|
|
self.send_cmd(&protocol::build_sysex_start_clean(v, d));
|
|
self.status = format!("Cleaning: {:.0} mV for {:.0}s", v, d);
|
|
}
|
|
/* Sessions */
|
|
Message::CreateSession => {
|
|
if self.creating_session {
|
|
let name = self.session_name_input.trim();
|
|
if !name.is_empty() {
|
|
if let Ok(id) = self.storage.create_session(name, "") {
|
|
self.current_session = Some(id);
|
|
self.sessions = self.storage.list_sessions().unwrap_or_default();
|
|
self.status = format!("Session: {}", name);
|
|
}
|
|
}
|
|
self.session_name_input.clear();
|
|
self.creating_session = false;
|
|
} else {
|
|
self.creating_session = true;
|
|
}
|
|
}
|
|
Message::SelectSession(id) => {
|
|
self.current_session = id;
|
|
}
|
|
Message::DeleteSession => {
|
|
if let Some(id) = self.current_session {
|
|
let _ = self.storage.delete_session(id);
|
|
self.current_session = None;
|
|
self.sessions = self.storage.list_sessions().unwrap_or_default();
|
|
self.status = "Session deleted".into();
|
|
}
|
|
}
|
|
Message::SessionNameInput(s) => {
|
|
self.session_name_input = s;
|
|
}
|
|
/* Browse */
|
|
Message::BrowseSelectSession(sid) => {
|
|
self.browse_selected_session = Some(sid);
|
|
self.browse_selected_measurement = None;
|
|
self.browse_preview.clear();
|
|
self.browse_measurements = self.storage.get_measurements(sid)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|m| {
|
|
let cnt = self.storage.data_point_count(m.id).unwrap_or(0);
|
|
(m, cnt)
|
|
})
|
|
.collect();
|
|
}
|
|
Message::BrowseSelectMeasurement(mid) => {
|
|
self.browse_selected_measurement = Some(mid);
|
|
let mtype = self.browse_measurements.iter()
|
|
.find(|(m, _)| m.id == mid)
|
|
.map(|(m, _)| m.mtype.clone());
|
|
if let Some(mt) = mtype {
|
|
let pts = self.storage.get_data_points(mid).unwrap_or_default();
|
|
self.browse_preview = Self::format_preview(&mt, &pts);
|
|
}
|
|
}
|
|
Message::BrowseLoadAsActive(mid) => {
|
|
let mtype = self.browse_measurements.iter()
|
|
.find(|(m, _)| m.id == mid)
|
|
.map(|(m, _)| m.mtype.clone());
|
|
if let Some(mt) = mtype {
|
|
let pts = self.storage.get_data_points(mid).unwrap_or_default();
|
|
self.load_measurement_active(&mt, &pts);
|
|
}
|
|
}
|
|
Message::BrowseLoadAsReference(mid) => {
|
|
let mtype = self.browse_measurements.iter()
|
|
.find(|(m, _)| m.id == mid)
|
|
.map(|(m, _)| m.mtype.clone());
|
|
if let Some(mt) = mtype {
|
|
let pts = self.storage.get_data_points(mid).unwrap_or_default();
|
|
self.load_measurement_reference(&mt, &pts);
|
|
}
|
|
}
|
|
Message::BrowseDeleteMeasurement(mid) => {
|
|
let _ = self.storage.delete_measurement(mid);
|
|
if self.browse_selected_measurement == Some(mid) {
|
|
self.browse_selected_measurement = None;
|
|
self.browse_preview.clear();
|
|
}
|
|
if let Some(sid) = self.browse_selected_session {
|
|
self.browse_measurements = self.storage.get_measurements(sid)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|m| {
|
|
let cnt = self.storage.data_point_count(m.id).unwrap_or(0);
|
|
(m, cnt)
|
|
})
|
|
.collect();
|
|
}
|
|
self.browse_sessions = self.storage.list_sessions()
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|s| {
|
|
let cnt = self.storage.measurement_count(s.id).unwrap_or(0);
|
|
(s, cnt)
|
|
})
|
|
.collect();
|
|
self.status = "Measurement deleted".into();
|
|
}
|
|
Message::BrowseBack => {
|
|
if self.browse_selected_measurement.is_some() {
|
|
self.browse_selected_measurement = None;
|
|
self.browse_preview.clear();
|
|
} else {
|
|
self.browse_selected_session = None;
|
|
self.browse_measurements.clear();
|
|
}
|
|
}
|
|
Message::ExportSession(sid) => {
|
|
match self.storage.export_session(sid) {
|
|
Ok(toml_str) => {
|
|
let name = self.browse_sessions.iter()
|
|
.find(|(s, _)| s.id == sid)
|
|
.map(|(s, _)| s.name.clone())
|
|
.unwrap_or_else(|| format!("session_{}", sid));
|
|
let filename = format!("{}.toml", name.replace(' ', "_"));
|
|
let dialog = rfd::FileDialog::new()
|
|
.set_file_name(&filename)
|
|
.add_filter("TOML", &["toml"]);
|
|
if let Some(path) = dialog.save_file() {
|
|
match std::fs::write(&path, &toml_str) {
|
|
Ok(_) => self.status = format!("Exported to {}", path.display()),
|
|
Err(e) => self.status = format!("Write failed: {}", e),
|
|
}
|
|
}
|
|
}
|
|
Err(e) => self.status = format!("Export failed: {}", e),
|
|
}
|
|
}
|
|
Message::ImportSession => {
|
|
let dialog = rfd::FileDialog::new()
|
|
.add_filter("TOML", &["toml"]);
|
|
if let Some(path) = dialog.pick_file() {
|
|
match std::fs::read_to_string(&path) {
|
|
Ok(toml_str) => {
|
|
match self.storage.import_session(&toml_str) {
|
|
Ok(_) => {
|
|
self.browse_sessions = self.storage.list_sessions()
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|s| {
|
|
let cnt = self.storage.measurement_count(s.id).unwrap_or(0);
|
|
(s, cnt)
|
|
})
|
|
.collect();
|
|
self.sessions = self.storage.list_sessions().unwrap_or_default();
|
|
self.status = format!("Imported from {}", path.display());
|
|
}
|
|
Err(e) => self.status = format!("Import failed: {}", e),
|
|
}
|
|
}
|
|
Err(e) => self.status = format!("Read failed: {}", e),
|
|
}
|
|
}
|
|
}
|
|
Message::Reconnect => {
|
|
self.conn_gen += 1;
|
|
self.cmd_tx = None;
|
|
self.connected = false;
|
|
self.status = format!("Connecting to {}...", self.udp_addr);
|
|
}
|
|
Message::UdpAddrChanged(s) => {
|
|
self.udp_addr = s;
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
pub fn subscription(&self) -> Subscription<Message> {
|
|
let udp_addr = self.udp_addr.clone();
|
|
let transport = Subscription::run_with_id(
|
|
self.conn_gen,
|
|
iced::stream::channel(100, move |mut output| async move {
|
|
let addr = udp_addr.clone();
|
|
loop {
|
|
let (udp_tx, mut udp_rx) = mpsc::unbounded_channel::<UdpEvent>();
|
|
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<Vec<u8>>();
|
|
|
|
let tx = udp_tx.clone();
|
|
let a = addr.clone();
|
|
tokio::spawn(async move {
|
|
if let Err(e) = crate::udp::connect_and_run(tx, cmd_rx, a).await {
|
|
eprintln!("UDP: {e}");
|
|
}
|
|
});
|
|
|
|
let mut ready_sent = false;
|
|
while let Some(ev) = udp_rx.recv().await {
|
|
let msg = match ev {
|
|
UdpEvent::Status(ref s) if s == "Connected" && !ready_sent => {
|
|
ready_sent = true;
|
|
let _ = output.send(Message::DeviceReady(cmd_tx.clone())).await;
|
|
Message::DeviceStatus(s.clone())
|
|
}
|
|
UdpEvent::Status(s) => Message::DeviceStatus(s),
|
|
UdpEvent::Data(m) => Message::DeviceData(m),
|
|
};
|
|
let _ = output.send(msg).await;
|
|
}
|
|
|
|
let _ = output.send(Message::DeviceStatus("Reconnecting...".into())).await;
|
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
|
}
|
|
}),
|
|
);
|
|
|
|
let temp_poll = iced::time::every(Duration::from_millis(500))
|
|
.map(|_| Message::PollTemp);
|
|
let menu_tick = iced::time::every(Duration::from_millis(50))
|
|
.map(|_| Message::NativeMenuTick);
|
|
|
|
Subscription::batch([transport, temp_poll, menu_tick])
|
|
}
|
|
|
|
pub fn view(&self) -> Element<'_, Message> {
|
|
let tab_btn = |label: &'static str, t: Tab, active: bool| -> Element<'_, Message> {
|
|
let b = button(text(label).size(13))
|
|
.style(style_tab(active))
|
|
.padding([6, 14]);
|
|
if active { b.into() } else { b.on_press(Message::TabSelected(t)).into() }
|
|
};
|
|
|
|
let mut tabs = row![
|
|
tab_btn("EIS", Tab::Eis, self.tab == Tab::Eis),
|
|
tab_btn("LSV", Tab::Lsv, self.tab == Tab::Lsv),
|
|
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)
|
|
.align_y(iced::Alignment::Center);
|
|
tabs = tabs
|
|
.push(iced::widget::horizontal_space())
|
|
.push(text("Clean").size(12))
|
|
.push(text_input("mV", &self.clean_v).on_input(Message::CleanVChanged).width(60))
|
|
.push(text_input("s", &self.clean_dur).on_input(Message::CleanDurChanged).width(45))
|
|
.push(
|
|
button(text("Clean").size(13))
|
|
.style(btn_style(Color::from_rgb(0.65, 0.55, 0.15), Color::WHITE))
|
|
.padding([6, 14])
|
|
.on_press(Message::StartClean),
|
|
);
|
|
|
|
let has_ref = match self.tab {
|
|
Tab::Eis => self.eis_ref.is_some(),
|
|
Tab::Lsv => self.lsv_ref.is_some(),
|
|
Tab::Amp => self.amp_ref.is_some(),
|
|
Tab::Chlorine => self.cl_ref.is_some(),
|
|
Tab::Ph => self.ph_ref.is_some(),
|
|
Tab::Calibrate | Tab::Browse => false,
|
|
};
|
|
let has_data = match self.tab {
|
|
Tab::Eis => !self.eis_points.is_empty(),
|
|
Tab::Lsv => !self.lsv_points.is_empty(),
|
|
Tab::Amp => !self.amp_points.is_empty(),
|
|
Tab::Chlorine => self.cl_result.is_some(),
|
|
Tab::Ph => self.ph_result.is_some(),
|
|
Tab::Calibrate | Tab::Browse => false,
|
|
};
|
|
|
|
let mut ref_row = row![].spacing(4).align_y(iced::Alignment::Center);
|
|
|
|
if !self.collecting_refs {
|
|
ref_row = ref_row.push(
|
|
button(text("Collect Refs").size(11))
|
|
.style(style_action())
|
|
.padding([4, 10])
|
|
.on_press(Message::CollectRefs),
|
|
);
|
|
} else {
|
|
ref_row = ref_row.push(
|
|
button(text("Collecting...").size(11))
|
|
.style(style_neutral())
|
|
.padding([4, 10]),
|
|
);
|
|
}
|
|
if self.has_device_refs {
|
|
ref_row = ref_row.push(
|
|
button(text("Get Refs").size(11))
|
|
.style(style_neutral())
|
|
.padding([4, 10])
|
|
.on_press(Message::GetRefs),
|
|
);
|
|
}
|
|
if self.has_device_refs || has_ref {
|
|
ref_row = ref_row.push(
|
|
button(text("Clear Refs").size(11))
|
|
.style(style_danger())
|
|
.padding([4, 10])
|
|
.on_press(Message::ClearRefs),
|
|
);
|
|
}
|
|
if has_data {
|
|
ref_row = ref_row.push(
|
|
button(text("Set Ref").size(11))
|
|
.style(style_neutral())
|
|
.padding([4, 10])
|
|
.on_press(Message::SetReference),
|
|
);
|
|
}
|
|
if has_ref {
|
|
ref_row = ref_row.push(
|
|
button(text("Clear Ref").size(11))
|
|
.style(style_danger())
|
|
.padding([4, 10])
|
|
.on_press(Message::ClearReference),
|
|
);
|
|
ref_row = ref_row.push(text("REF").size(11));
|
|
}
|
|
|
|
let mut status_row = row![text(&self.status).size(16)].spacing(6)
|
|
.align_y(iced::Alignment::Center);
|
|
status_row = status_row.push(
|
|
text_input("IP:port", &self.udp_addr)
|
|
.size(12)
|
|
.width(160)
|
|
.on_input(Message::UdpAddrChanged)
|
|
.on_submit(Message::Reconnect),
|
|
);
|
|
status_row = status_row.push(
|
|
button(text("Reconnect").size(11))
|
|
.style(style_apply())
|
|
.padding([4, 10])
|
|
.on_press(Message::Reconnect),
|
|
);
|
|
status_row = status_row
|
|
.push(iced::widget::horizontal_space())
|
|
.push(ref_row)
|
|
.push(text(format!("{:.1} C", self.temp_c)).size(14));
|
|
|
|
let session_row = self.view_session_row();
|
|
|
|
let controls = self.view_controls();
|
|
|
|
let body: Element<'_, Message> = if self.show_sysinfo {
|
|
self.view_sysinfo()
|
|
} else if self.tab == Tab::Browse {
|
|
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 {
|
|
PaneId::Plot => self.view_plot_pane(),
|
|
PaneId::Data => self.view_data_pane(),
|
|
};
|
|
pane_grid::Content::new(el)
|
|
})
|
|
.on_resize(6, Message::PaneResized)
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.into()
|
|
};
|
|
|
|
container(
|
|
column![tabs, session_row, status_row, controls, body]
|
|
.spacing(10)
|
|
.padding(20)
|
|
.width(Length::Fill)
|
|
.height(Length::Fill),
|
|
)
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.into()
|
|
}
|
|
|
|
fn view_session_row(&self) -> Element<'_, Message> {
|
|
let mut r = row![].spacing(6).align_y(iced::Alignment::Center);
|
|
|
|
let selected = self.current_session.and_then(|id| {
|
|
self.sessions.iter().find(|s| s.id == id)
|
|
});
|
|
let options: Vec<SessionItem> = std::iter::once(SessionItem::None)
|
|
.chain(self.sessions.iter().map(|s| SessionItem::Some(s.id, s.name.clone())))
|
|
.collect();
|
|
let current = match selected {
|
|
Some(s) => SessionItem::Some(s.id, s.name.clone()),
|
|
None => SessionItem::None,
|
|
};
|
|
r = r.push(text("Session:").size(12));
|
|
r = r.push(
|
|
pick_list(options, Some(current), |item| match item {
|
|
SessionItem::None => Message::SelectSession(None),
|
|
SessionItem::Some(id, _) => Message::SelectSession(Some(id)),
|
|
}).width(180).text_size(12),
|
|
);
|
|
|
|
if self.creating_session {
|
|
r = r.push(
|
|
text_input("Session name", &self.session_name_input)
|
|
.on_input(Message::SessionNameInput)
|
|
.on_submit(Message::CreateSession)
|
|
.width(150)
|
|
.size(12),
|
|
);
|
|
r = r.push(
|
|
button(text("Save").size(11))
|
|
.style(style_action())
|
|
.padding([4, 10])
|
|
.on_press(Message::CreateSession),
|
|
);
|
|
} else {
|
|
r = r.push(
|
|
button(text("New").size(11))
|
|
.style(style_apply())
|
|
.padding([4, 10])
|
|
.on_press(Message::CreateSession),
|
|
);
|
|
}
|
|
|
|
if self.current_session.is_some() {
|
|
r = r.push(
|
|
button(text("Delete").size(11))
|
|
.style(style_danger())
|
|
.padding([4, 10])
|
|
.on_press(Message::DeleteSession),
|
|
);
|
|
} else {
|
|
r = r.push(
|
|
text("No session -- data will not be saved").size(11),
|
|
);
|
|
}
|
|
|
|
r.into()
|
|
}
|
|
|
|
fn view_controls(&self) -> Element<'_, Message> {
|
|
match self.tab {
|
|
Tab::Eis => row![
|
|
column![
|
|
text("Start Hz").size(12),
|
|
text_input("1000", &self.freq_start).on_input(Message::FreqStartChanged).width(90),
|
|
].spacing(2),
|
|
column![
|
|
text("Stop Hz").size(12),
|
|
text_input("200000", &self.freq_stop).on_input(Message::FreqStopChanged).width(90),
|
|
].spacing(2),
|
|
column![
|
|
text("PPD").size(12),
|
|
text_input("10", &self.ppd).on_input(Message::PpdChanged).width(50),
|
|
].spacing(2),
|
|
column![
|
|
text("RTIA").size(12),
|
|
pick_list(Rtia::ALL, Some(self.rtia), Message::RtiaSelected).width(110),
|
|
].spacing(2),
|
|
column![
|
|
text("RCAL").size(12),
|
|
pick_list(Rcal::ALL, Some(self.rcal), Message::RcalSelected).width(160),
|
|
].spacing(2),
|
|
column![
|
|
text("Electrodes").size(12),
|
|
pick_list(Electrode::ALL, Some(self.electrode), Message::ElectrodeSelected).width(170),
|
|
].spacing(2),
|
|
button(text("Apply").size(13))
|
|
.style(style_apply())
|
|
.padding([6, 16])
|
|
.on_press(Message::ApplySettings),
|
|
button(text("Sweep").size(13))
|
|
.style(style_action())
|
|
.padding([6, 20])
|
|
.on_press(Message::StartSweep),
|
|
]
|
|
.spacing(8)
|
|
.align_y(iced::Alignment::End)
|
|
.into(),
|
|
|
|
Tab::Lsv => row![
|
|
column![
|
|
text("Start mV").size(12),
|
|
text_input("0", &self.lsv_start_v).on_input(Message::LsvStartVChanged).width(80),
|
|
].spacing(2),
|
|
column![
|
|
text("Stop mV").size(12),
|
|
text_input("500", &self.lsv_stop_v).on_input(Message::LsvStopVChanged).width(80),
|
|
].spacing(2),
|
|
column![
|
|
text("Scan mV/s").size(12),
|
|
text_input("50", &self.lsv_scan_rate).on_input(Message::LsvScanRateChanged).width(80),
|
|
].spacing(2),
|
|
column![
|
|
text("RTIA").size(12),
|
|
pick_list(LpRtia::ALL, Some(self.lsv_rtia), Message::LsvRtiaSelected).width(Length::Shrink),
|
|
].spacing(2),
|
|
button(text("Start LSV").size(13))
|
|
.style(style_action())
|
|
.padding([6, 16])
|
|
.on_press(Message::StartLsv),
|
|
button(text(if self.lsv_manual_peaks { "Manual" } else { "Auto" }).size(13))
|
|
.padding([6, 12])
|
|
.on_press(Message::LsvToggleManual),
|
|
]
|
|
.spacing(10)
|
|
.align_y(iced::Alignment::End)
|
|
.into(),
|
|
|
|
Tab::Amp => row![
|
|
column![
|
|
text("V hold mV").size(12),
|
|
text_input("200", &self.amp_v_hold).on_input(Message::AmpVholdChanged).width(80),
|
|
].spacing(2),
|
|
column![
|
|
text("Interval ms").size(12),
|
|
text_input("100", &self.amp_interval).on_input(Message::AmpIntervalChanged).width(80),
|
|
].spacing(2),
|
|
column![
|
|
text("Duration s").size(12),
|
|
text_input("60", &self.amp_duration).on_input(Message::AmpDurationChanged).width(80),
|
|
].spacing(2),
|
|
column![
|
|
text("RTIA").size(12),
|
|
pick_list(LpRtia::ALL, Some(self.amp_rtia), Message::AmpRtiaSelected).width(Length::Shrink),
|
|
].spacing(2),
|
|
if self.amp_running {
|
|
button(text("Stop").size(13))
|
|
.style(style_danger())
|
|
.padding([6, 16])
|
|
.on_press(Message::StopAmp)
|
|
} else {
|
|
button(text("Start Amp").size(13))
|
|
.style(style_action())
|
|
.padding([6, 16])
|
|
.on_press(Message::StartAmp)
|
|
},
|
|
]
|
|
.spacing(10)
|
|
.align_y(iced::Alignment::End)
|
|
.into(),
|
|
|
|
Tab::Chlorine => row![
|
|
button(text("Start LSV").size(13))
|
|
.style(style_action())
|
|
.padding([6, 16])
|
|
.on_press(Message::StartLsv),
|
|
button(text(if self.lsv_manual_peaks { "Manual" } else { "Auto" }).size(13))
|
|
.padding([6, 12])
|
|
.on_press(Message::LsvToggleManual),
|
|
rule::Rule::vertical(1),
|
|
column![
|
|
text("Cond mV").size(12),
|
|
text_input("800", &self.cl_cond_v).on_input(Message::ClCondVChanged).width(70),
|
|
].spacing(2),
|
|
column![
|
|
text("Cond ms").size(12),
|
|
text_input("2000", &self.cl_cond_t).on_input(Message::ClCondTChanged).width(70),
|
|
].spacing(2),
|
|
column![
|
|
text("Free mV").size(12),
|
|
text_input("100", &self.cl_free_v).on_input(Message::ClFreeVChanged).width(70),
|
|
].spacing(2),
|
|
column![
|
|
text("Total mV").size(12),
|
|
text_input("-200", &self.cl_total_v).on_input(Message::ClTotalVChanged).width(70),
|
|
].spacing(2),
|
|
column![
|
|
text("Settle ms").size(12),
|
|
text_input("5000", &self.cl_dep_t).on_input(Message::ClDepTChanged).width(70),
|
|
].spacing(2),
|
|
column![
|
|
text("Meas ms").size(12),
|
|
text_input("5000", &self.cl_meas_t).on_input(Message::ClMeasTChanged).width(70),
|
|
].spacing(2),
|
|
column![
|
|
text("RTIA").size(12),
|
|
pick_list(LpRtia::ALL, Some(self.cl_rtia), Message::ClRtiaSelected).width(Length::Shrink),
|
|
].spacing(2),
|
|
button(text("Measure").size(13))
|
|
.style(style_action())
|
|
.padding([6, 16])
|
|
.on_press(Message::StartCl),
|
|
]
|
|
.spacing(8)
|
|
.align_y(iced::Alignment::End)
|
|
.into(),
|
|
|
|
Tab::Ph => row![
|
|
column![
|
|
text("Stabilize s").size(12),
|
|
text_input("30", &self.ph_stabilize).on_input(Message::PhStabilizeChanged).width(80),
|
|
].spacing(2),
|
|
button(text("Measure pH").size(13))
|
|
.style(style_action())
|
|
.padding([6, 16])
|
|
.on_press(Message::StartPh),
|
|
]
|
|
.spacing(10)
|
|
.align_y(iced::Alignment::End)
|
|
.into(),
|
|
|
|
Tab::Calibrate | Tab::Browse => row![].into(),
|
|
}
|
|
}
|
|
|
|
fn view_plot_pane(&self) -> Element<'_, Message> {
|
|
match self.tab {
|
|
Tab::Eis => {
|
|
let bode = canvas(crate::plot::BodePlot {
|
|
points: &self.eis_points,
|
|
reference: self.eis_ref.as_deref(),
|
|
})
|
|
.width(Length::FillPortion(3))
|
|
.height(Length::Fill);
|
|
let nyquist = canvas(crate::plot::NyquistPlot {
|
|
points: &self.eis_points,
|
|
reference: self.eis_ref.as_deref(),
|
|
})
|
|
.width(Length::FillPortion(2))
|
|
.height(Length::Fill);
|
|
row![bode, nyquist].spacing(10).height(Length::Fill).into()
|
|
}
|
|
Tab::Lsv => {
|
|
let plot = canvas(crate::plot::VoltammogramPlot {
|
|
points: &self.lsv_points,
|
|
reference: self.lsv_ref.as_deref(),
|
|
peaks: &self.lsv_peaks,
|
|
})
|
|
.width(Length::Fill).height(Length::Fill);
|
|
if self.lsv_peaks.is_empty() {
|
|
plot.into()
|
|
} else {
|
|
let info: Vec<String> = self.lsv_peaks.iter().map(|p| {
|
|
use crate::lsv_analysis::PeakKind;
|
|
match p.kind {
|
|
PeakKind::FreeCl => format!("Free: {:.0}mV {:.2}uA", p.v_mv, p.i_ua),
|
|
PeakKind::TotalCl => format!("Total: {:.0}mV {:.2}uA", p.v_mv, p.i_ua),
|
|
PeakKind::Crossover => format!("X-over: {:.0}mV", p.v_mv),
|
|
}
|
|
}).collect();
|
|
column![text(info.join(" | ")).size(14), plot].spacing(4).height(Length::Fill).into()
|
|
}
|
|
}
|
|
Tab::Amp => canvas(crate::plot::AmperogramPlot {
|
|
points: &self.amp_points,
|
|
reference: self.amp_ref.as_deref(),
|
|
})
|
|
.width(Length::Fill).height(Length::Fill).into(),
|
|
Tab::Chlorine => {
|
|
let mut col = column![].spacing(4).height(Length::Fill);
|
|
|
|
if !self.lsv_peaks.is_empty() {
|
|
let info: Vec<String> = self.lsv_peaks.iter().map(|p| {
|
|
use crate::lsv_analysis::PeakKind;
|
|
match p.kind {
|
|
PeakKind::FreeCl => format!("Free: {:.0}mV {:.2}uA", p.v_mv, p.i_ua),
|
|
PeakKind::TotalCl => format!("Total: {:.0}mV {:.2}uA", p.v_mv, p.i_ua),
|
|
PeakKind::Crossover => format!("X-over: {:.0}mV", p.v_mv),
|
|
}
|
|
}).collect();
|
|
col = col.push(text(info.join(" | ")).size(14));
|
|
}
|
|
|
|
let voltammogram = canvas(crate::plot::VoltammogramPlot {
|
|
points: &self.lsv_points,
|
|
reference: self.lsv_ref.as_deref(),
|
|
peaks: &self.lsv_peaks,
|
|
})
|
|
.width(Length::Fill).height(Length::FillPortion(1));
|
|
col = col.push(voltammogram);
|
|
|
|
let mut result_parts: Vec<String> = Vec::new();
|
|
if let Some(r) = &self.cl_result {
|
|
result_parts.push(format!(
|
|
"Free: {:.3} uA | Total: {:.3} uA | Combined: {:.3} uA",
|
|
r.i_free_ua, r.i_total_ua, r.i_total_ua - r.i_free_ua
|
|
));
|
|
if let (Some(f), Some(r)) = (self.cl_factor, &self.cl_result) {
|
|
let ppm = f * r.i_free_ua.abs();
|
|
result_parts.push(format!("Free Cl: {:.2} ppm", ppm));
|
|
}
|
|
if let Some((_, ref_r)) = &self.cl_ref {
|
|
let df = r.i_free_ua - ref_r.i_free_ua;
|
|
let dt = r.i_total_ua - ref_r.i_total_ua;
|
|
result_parts.push(format!(
|
|
"vs Ref: dFree={:.3} uA, dTotal={:.3} uA",
|
|
df, dt
|
|
));
|
|
}
|
|
}
|
|
if !result_parts.is_empty() {
|
|
col = col.push(text(result_parts.join(" ")).size(14));
|
|
}
|
|
|
|
let ref_pts = self.cl_ref.as_ref().map(|(pts, _)| pts.as_slice());
|
|
let cl_plot = canvas(crate::plot::ChlorinePlot {
|
|
points: &self.cl_points,
|
|
reference: ref_pts,
|
|
})
|
|
.width(Length::Fill).height(Length::FillPortion(1));
|
|
col = col.push(cl_plot);
|
|
|
|
col.into()
|
|
}
|
|
Tab::Ph | Tab::Calibrate | Tab::Browse => text("").into(),
|
|
}
|
|
}
|
|
|
|
fn view_data_pane(&self) -> Element<'_, Message> {
|
|
let content = match self.tab {
|
|
Tab::Eis => &self.eis_data,
|
|
Tab::Lsv => &self.lsv_data,
|
|
Tab::Amp => &self.amp_data,
|
|
Tab::Chlorine => &self.cl_data,
|
|
Tab::Ph | Tab::Calibrate | Tab::Browse => return text("").into(),
|
|
};
|
|
text_editor(content)
|
|
.on_action(Message::DataAction)
|
|
.font(iced::Font::MONOSPACE)
|
|
.size(13)
|
|
.height(Length::Fill)
|
|
.padding(4)
|
|
.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);
|
|
|
|
results = results.push(iced::widget::horizontal_rule(1));
|
|
results = results.push(text("Chlorine Calibration").size(16));
|
|
if let Some(f) = self.cl_factor {
|
|
results = results.push(text(format!("Cl factor: {:.6} ppm/uA", f)).size(14));
|
|
}
|
|
if let Some(r) = &self.cl_result {
|
|
results = results.push(text(format!("Last free Cl peak: {:.3} uA", r.i_free_ua)).size(14));
|
|
}
|
|
results = results.push(
|
|
row![
|
|
column![
|
|
text("Known Cl ppm").size(12),
|
|
text_input("5", &self.cl_cal_known_ppm)
|
|
.on_input(Message::ClCalKnownPpmChanged).width(80),
|
|
].spacing(2),
|
|
button(text("Set Cl Factor").size(13))
|
|
.style(style_action())
|
|
.padding([6, 16])
|
|
.on_press(Message::ClSetFactor),
|
|
].spacing(10).align_y(iced::Alignment::End)
|
|
);
|
|
|
|
/* pH calibration */
|
|
results = results.push(iced::widget::horizontal_rule(1));
|
|
results = results.push(text("pH Calibration (Q/HQ peak-shift)").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));
|
|
}
|
|
}
|
|
}
|
|
results = results.push(
|
|
row![
|
|
column![
|
|
text("Known pH").size(12),
|
|
text_input("7.00", &self.ph_cal_known)
|
|
.on_input(Message::PhCalKnownChanged).width(80),
|
|
].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));
|
|
}
|
|
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![
|
|
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);
|
|
let mut col = column![
|
|
text(format!("pH: {:.2}", r.ph)).size(28),
|
|
text(format!("OCP: {:.1} mV | Nernst slope: {:.2} mV/pH | Temp: {:.1} C",
|
|
r.v_ocp_mv, nernst_slope, r.temp_c)).size(14),
|
|
].spacing(4);
|
|
if let Some(ref_r) = &self.ph_ref {
|
|
let d_ph = r.ph - ref_r.ph;
|
|
let d_v = r.v_ocp_mv - ref_r.v_ocp_mv;
|
|
col = col.push(text(format!(
|
|
"vs Ref: dpH={:+.3} dOCP={:+.1} mV (ref pH={:.2})",
|
|
d_ph, d_v, ref_r.ph
|
|
)).size(14));
|
|
}
|
|
col.into()
|
|
} else {
|
|
column![
|
|
text("No measurement yet").size(16),
|
|
text("OCP method: V(SE0) - V(RE0) with Nernst correction").size(12),
|
|
].spacing(4).into()
|
|
}
|
|
}
|
|
|
|
fn view_sysinfo(&self) -> Element<'_, Message> {
|
|
container(
|
|
column![
|
|
text("System Info").size(20),
|
|
iced::widget::horizontal_rule(1),
|
|
text(format!("Temperature: {:.1} C", self.temp_c)).size(14),
|
|
text("Syslog: not yet implemented").size(14),
|
|
text("0 / 4,194,304 bytes used").size(14),
|
|
iced::widget::vertical_space().height(20),
|
|
button(text("Close").size(14)).on_press(Message::CloseSysInfo),
|
|
]
|
|
.spacing(8)
|
|
.width(350)
|
|
.padding(20),
|
|
)
|
|
.center(Length::Fill)
|
|
.into()
|
|
}
|
|
|
|
/* ---- Browse tab ---- */
|
|
|
|
fn view_browse_body(&self) -> Element<'_, Message> {
|
|
let left = self.view_browse_sessions();
|
|
let right = self.view_browse_detail();
|
|
row![
|
|
container(left).width(Length::FillPortion(2)).height(Length::Fill),
|
|
iced::widget::vertical_rule(1),
|
|
container(right).width(Length::FillPortion(3)).height(Length::Fill),
|
|
]
|
|
.spacing(8)
|
|
.height(Length::Fill)
|
|
.into()
|
|
}
|
|
|
|
fn view_browse_sessions(&self) -> Element<'_, Message> {
|
|
let mut items = column![
|
|
row![
|
|
text("Sessions").size(16),
|
|
iced::widget::horizontal_space(),
|
|
button(text("Import").size(11))
|
|
.style(style_apply())
|
|
.padding([4, 8])
|
|
.on_press(Message::ImportSession),
|
|
].align_y(iced::Alignment::Center),
|
|
iced::widget::horizontal_rule(1),
|
|
].spacing(4);
|
|
|
|
if self.browse_sessions.is_empty() {
|
|
items = items.push(text("No saved sessions").size(13));
|
|
}
|
|
|
|
for (session, mcount) in &self.browse_sessions {
|
|
let selected = self.browse_selected_session == Some(session.id);
|
|
let bg = if selected {
|
|
Color::from_rgb(0.30, 0.40, 0.55)
|
|
} else {
|
|
Color::from_rgb(0.18, 0.18, 0.20)
|
|
};
|
|
let label = format!(
|
|
"{} ({} meas) {}",
|
|
session.name, mcount, &session.created_at[..10.min(session.created_at.len())]
|
|
);
|
|
let sid = session.id;
|
|
items = items.push(
|
|
button(text(label).size(12))
|
|
.style(btn_style(bg, Color::WHITE))
|
|
.padding([6, 10])
|
|
.width(Length::Fill)
|
|
.on_press(Message::BrowseSelectSession(sid)),
|
|
);
|
|
}
|
|
|
|
scrollable(items.width(Length::Fill))
|
|
.height(Length::Fill)
|
|
.into()
|
|
}
|
|
|
|
fn view_browse_detail(&self) -> Element<'_, Message> {
|
|
let Some(sid) = self.browse_selected_session else {
|
|
return column![
|
|
text("Select a session").size(14),
|
|
].spacing(8).into();
|
|
};
|
|
|
|
let session_name = self.browse_sessions.iter()
|
|
.find(|(s, _)| s.id == sid)
|
|
.map(|(s, _)| s.name.as_str())
|
|
.unwrap_or("?");
|
|
|
|
let mut header = row![
|
|
button(text("Back").size(11))
|
|
.style(style_neutral())
|
|
.padding([4, 10])
|
|
.on_press(Message::BrowseBack),
|
|
text(format!("Measurements in: {}", session_name)).size(14),
|
|
].spacing(8).align_y(iced::Alignment::Center);
|
|
|
|
if let Some(mid) = self.browse_selected_measurement {
|
|
header = header.push(iced::widget::horizontal_space());
|
|
header = header.push(
|
|
button(text("Load").size(11))
|
|
.style(style_action())
|
|
.padding([4, 12])
|
|
.on_press(Message::BrowseLoadAsActive(mid)),
|
|
);
|
|
header = header.push(
|
|
button(text("Load as Ref").size(11))
|
|
.style(style_apply())
|
|
.padding([4, 12])
|
|
.on_press(Message::BrowseLoadAsReference(mid)),
|
|
);
|
|
header = header.push(
|
|
button(text("Delete").size(11))
|
|
.style(style_danger())
|
|
.padding([4, 10])
|
|
.on_press(Message::BrowseDeleteMeasurement(mid)),
|
|
);
|
|
}
|
|
|
|
header = header.push(iced::widget::horizontal_space());
|
|
header = header.push(
|
|
button(text("Export").size(11))
|
|
.style(style_apply())
|
|
.padding([4, 12])
|
|
.on_press(Message::ExportSession(sid)),
|
|
);
|
|
|
|
let mut mlist = column![].spacing(2);
|
|
|
|
if self.browse_measurements.is_empty() {
|
|
mlist = mlist.push(text("No measurements").size(13));
|
|
}
|
|
|
|
for (m, pt_count) in &self.browse_measurements {
|
|
let selected = self.browse_selected_measurement == Some(m.id);
|
|
let bg = if selected {
|
|
Color::from_rgb(0.30, 0.40, 0.55)
|
|
} else {
|
|
Color::from_rgb(0.18, 0.18, 0.20)
|
|
};
|
|
let type_label = match m.mtype.as_str() {
|
|
"eis" => "EIS",
|
|
"lsv" => "LSV",
|
|
"amp" => "Amp",
|
|
"chlorine" => "Cl",
|
|
"ph" => "pH",
|
|
other => other,
|
|
};
|
|
let ts = if m.created_at.len() > 10 { &m.created_at[11..] } else { &m.created_at };
|
|
let label = format!("[{}] {} {} pts", type_label, ts, pt_count);
|
|
let mid = m.id;
|
|
mlist = mlist.push(
|
|
button(text(label).size(12))
|
|
.style(btn_style(bg, Color::WHITE))
|
|
.padding([5, 10])
|
|
.width(Length::Fill)
|
|
.on_press(Message::BrowseSelectMeasurement(mid)),
|
|
);
|
|
}
|
|
|
|
let mut body = column![
|
|
header,
|
|
iced::widget::horizontal_rule(1),
|
|
].spacing(6);
|
|
|
|
body = body.push(
|
|
scrollable(mlist.width(Length::Fill))
|
|
.height(if self.browse_preview.is_empty() { Length::Fill } else { Length::FillPortion(2) }),
|
|
);
|
|
|
|
if !self.browse_preview.is_empty() {
|
|
body = body.push(iced::widget::horizontal_rule(1));
|
|
body = body.push(
|
|
scrollable(
|
|
text(&self.browse_preview)
|
|
.size(12)
|
|
.font(iced::Font::MONOSPACE)
|
|
)
|
|
.height(Length::FillPortion(3)),
|
|
);
|
|
}
|
|
|
|
body.height(Length::Fill).into()
|
|
}
|
|
|
|
fn format_preview(mtype: &str, pts: &[storage::DataPoint]) -> String {
|
|
match mtype {
|
|
"eis" => {
|
|
let decoded: Vec<EisPoint> = pts.iter()
|
|
.filter_map(|dp| serde_json::from_str(&dp.data_json).ok())
|
|
.collect();
|
|
fmt_eis(&decoded)
|
|
}
|
|
"lsv" => {
|
|
let decoded: Vec<LsvPoint> = pts.iter()
|
|
.filter_map(|dp| serde_json::from_str(&dp.data_json).ok())
|
|
.collect();
|
|
fmt_lsv(&decoded)
|
|
}
|
|
"amp" => {
|
|
let decoded: Vec<AmpPoint> = pts.iter()
|
|
.filter_map(|dp| serde_json::from_str(&dp.data_json).ok())
|
|
.collect();
|
|
fmt_amp(&decoded)
|
|
}
|
|
"chlorine" => {
|
|
let decoded: Vec<ClPoint> = pts.iter()
|
|
.filter_map(|dp| serde_json::from_str(&dp.data_json).ok())
|
|
.collect();
|
|
let mut s = fmt_cl(&decoded);
|
|
if let Some(last) = pts.last()
|
|
&& let Ok(wrap) = serde_json::from_str::<serde_json::Value>(&last.data_json)
|
|
&& let Some(r) = wrap.get("result")
|
|
&& let Ok(cr) = serde_json::from_value::<ClResult>(r.clone())
|
|
{
|
|
let _ = writeln!(s, "\nFree: {:.3} uA Total: {:.3} uA",
|
|
cr.i_free_ua, cr.i_total_ua);
|
|
}
|
|
s
|
|
}
|
|
"ph" => {
|
|
if let Some(dp) = pts.first() {
|
|
if let Ok(r) = serde_json::from_str::<PhResult>(&dp.data_json) {
|
|
format!("pH: {:.2} OCP: {:.1} mV Temp: {:.1} C", r.ph, r.v_ocp_mv, r.temp_c)
|
|
} else {
|
|
dp.data_json.clone()
|
|
}
|
|
} else {
|
|
String::new()
|
|
}
|
|
}
|
|
_ => format!("{} data points", pts.len()),
|
|
}
|
|
}
|
|
|
|
fn load_measurement_active(&mut self, mtype: &str, pts: &[storage::DataPoint]) {
|
|
match mtype {
|
|
"eis" => {
|
|
let decoded: Vec<EisPoint> = pts.iter()
|
|
.filter_map(|dp| serde_json::from_str(&dp.data_json).ok())
|
|
.collect();
|
|
self.eis_data = text_editor::Content::with_text(&fmt_eis(&decoded));
|
|
self.eis_points = decoded;
|
|
self.tab = Tab::Eis;
|
|
self.status = format!("Loaded EIS: {} pts", self.eis_points.len());
|
|
}
|
|
"lsv" => {
|
|
let decoded: Vec<LsvPoint> = pts.iter()
|
|
.filter_map(|dp| serde_json::from_str(&dp.data_json).ok())
|
|
.collect();
|
|
self.lsv_data = text_editor::Content::with_text(&fmt_lsv(&decoded));
|
|
self.lsv_points = decoded;
|
|
self.tab = Tab::Lsv;
|
|
self.status = format!("Loaded LSV: {} pts", self.lsv_points.len());
|
|
}
|
|
"amp" => {
|
|
let decoded: Vec<AmpPoint> = pts.iter()
|
|
.filter_map(|dp| serde_json::from_str(&dp.data_json).ok())
|
|
.collect();
|
|
self.amp_data = text_editor::Content::with_text(&fmt_amp(&decoded));
|
|
self.amp_points = decoded;
|
|
self.tab = Tab::Amp;
|
|
self.status = format!("Loaded Amp: {} pts", self.amp_points.len());
|
|
}
|
|
"chlorine" => {
|
|
let cl_pts: Vec<ClPoint> = pts.iter()
|
|
.filter_map(|dp| serde_json::from_str(&dp.data_json).ok())
|
|
.collect();
|
|
let mut result: Option<ClResult> = None;
|
|
if let Some(last) = pts.last()
|
|
&& let Ok(wrap) = serde_json::from_str::<serde_json::Value>(&last.data_json)
|
|
&& let Some(r) = wrap.get("result")
|
|
{
|
|
result = serde_json::from_value(r.clone()).ok();
|
|
}
|
|
self.cl_data = text_editor::Content::with_text(&fmt_cl(&cl_pts));
|
|
self.cl_points = cl_pts;
|
|
self.cl_result = result;
|
|
self.tab = Tab::Chlorine;
|
|
self.status = format!("Loaded Chlorine: {} pts", self.cl_points.len());
|
|
}
|
|
"ph" => {
|
|
if let Some(dp) = pts.first()
|
|
&& let Ok(r) = serde_json::from_str::<PhResult>(&dp.data_json)
|
|
{
|
|
self.status = format!("Loaded pH: {:.2}", r.ph);
|
|
self.ph_result = Some(r);
|
|
self.tab = Tab::Ph;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn load_measurement_reference(&mut self, mtype: &str, pts: &[storage::DataPoint]) {
|
|
match mtype {
|
|
"eis" => {
|
|
let decoded: Vec<EisPoint> = pts.iter()
|
|
.filter_map(|dp| serde_json::from_str(&dp.data_json).ok())
|
|
.collect();
|
|
self.status = format!("EIS ref loaded: {} pts", decoded.len());
|
|
self.eis_ref = Some(decoded);
|
|
self.tab = Tab::Eis;
|
|
}
|
|
"lsv" => {
|
|
let decoded: Vec<LsvPoint> = pts.iter()
|
|
.filter_map(|dp| serde_json::from_str(&dp.data_json).ok())
|
|
.collect();
|
|
self.status = format!("LSV ref loaded: {} pts", decoded.len());
|
|
self.lsv_ref = Some(decoded);
|
|
self.tab = Tab::Lsv;
|
|
}
|
|
"amp" => {
|
|
let decoded: Vec<AmpPoint> = pts.iter()
|
|
.filter_map(|dp| serde_json::from_str(&dp.data_json).ok())
|
|
.collect();
|
|
self.status = format!("Amp ref loaded: {} pts", decoded.len());
|
|
self.amp_ref = Some(decoded);
|
|
self.tab = Tab::Amp;
|
|
}
|
|
"chlorine" => {
|
|
let cl_pts: Vec<ClPoint> = pts.iter()
|
|
.filter_map(|dp| serde_json::from_str(&dp.data_json).ok())
|
|
.collect();
|
|
let mut result = ClResult { i_free_ua: 0.0, i_total_ua: 0.0 };
|
|
if let Some(last) = pts.last()
|
|
&& let Ok(wrap) = serde_json::from_str::<serde_json::Value>(&last.data_json)
|
|
&& let Some(r) = wrap.get("result")
|
|
&& let Ok(cr) = serde_json::from_value::<ClResult>(r.clone())
|
|
{
|
|
result = cr;
|
|
}
|
|
self.status = format!("Cl ref loaded: {} pts", cl_pts.len());
|
|
self.cl_ref = Some((cl_pts, result));
|
|
self.tab = Tab::Chlorine;
|
|
}
|
|
"ph" => {
|
|
if let Some(dp) = pts.first()
|
|
&& let Ok(r) = serde_json::from_str::<PhResult>(&dp.data_json)
|
|
{
|
|
self.status = format!("pH ref loaded: {:.2}", r.ph);
|
|
self.ph_ref = Some(r);
|
|
self.tab = Tab::Ph;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|