cue: measurement browser — load, compare, reference overlay from saved data

This commit is contained in:
jess 2026-03-31 18:18:22 -07:00
parent ae35b1248f
commit b2493ffb54
2 changed files with 468 additions and 6 deletions

View File

@ -1,6 +1,7 @@
use futures::SinkExt;
use iced::widget::{
button, canvas, column, container, pane_grid, pick_list, row, text, text_editor, text_input,
button, canvas, column, container, pane_grid, pick_list, row, scrollable, text, text_editor,
text_input,
};
use iced::widget::button::Style as ButtonStyle;
use iced::{Border, Color, Element, Length, Subscription, Task, Theme};
@ -14,7 +15,7 @@ use crate::protocol::{
self, AmpPoint, ClPoint, ClResult, Electrode, EisMessage, EisPoint, LpRtia, LsvPoint,
PhResult, Rcal, Rtia,
};
use crate::storage::{Session, Storage};
use crate::storage::{self, Session, Storage};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tab {
@ -23,6 +24,7 @@ pub enum Tab {
Amp,
Chlorine,
Ph,
Browse,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -107,6 +109,13 @@ pub enum Message {
SelectSession(Option<i64>),
DeleteSession,
SessionNameInput(String),
/* Browse */
BrowseSelectSession(i64),
BrowseSelectMeasurement(i64),
BrowseLoadAsActive(i64),
BrowseLoadAsReference(i64),
BrowseDeleteMeasurement(i64),
BrowseBack,
/* Misc */
OpenMidiSetup,
RefreshMidi,
@ -128,6 +137,13 @@ pub struct App {
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,
@ -324,6 +340,12 @@ impl App {
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(),
@ -655,7 +677,23 @@ impl App {
}
}
},
Message::TabSelected(t) => self.tab = t,
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);
}
@ -666,7 +704,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::Ph | Tab::Browse => {}
}
}
}
@ -819,6 +857,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 => {}
}
}
/* Global */
@ -875,6 +914,83 @@ impl App {
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::OpenMidiSetup => {
let _ = std::process::Command::new("open")
.arg("-a")
@ -948,6 +1064,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("Browse", Tab::Browse, self.tab == Tab::Browse),
button(text("MIDI Setup").size(13))
.style(style_neutral())
.padding([6, 14])
@ -970,6 +1087,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,
};
let has_data = match self.tab {
Tab::Eis => !self.eis_points.is_empty(),
@ -977,6 +1095,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,
};
let mut ref_row = row![].spacing(4).align_y(iced::Alignment::Center);
@ -1051,6 +1170,8 @@ impl App {
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 {
@ -1289,6 +1410,8 @@ impl App {
.spacing(10)
.align_y(iced::Alignment::End)
.into(),
Tab::Browse => row![].into(),
}
}
@ -1348,7 +1471,7 @@ impl App {
column![result_text, plot].spacing(4).height(Length::Fill).into()
}
}
Tab::Ph => text("").into(),
Tab::Ph | Tab::Browse => text("").into(),
}
}
@ -1358,7 +1481,7 @@ impl App {
Tab::Lsv => &self.lsv_data,
Tab::Amp => &self.amp_data,
Tab::Chlorine => &self.cl_data,
Tab::Ph => return text("").into(),
Tab::Ph | Tab::Browse => return text("").into(),
};
text_editor(content)
.on_action(Message::DataAction)
@ -1412,4 +1535,319 @@ impl App {
.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![
text("Sessions").size(16),
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)),
);
}
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;
}
}
_ => {}
}
}
}

View File

@ -1,6 +1,7 @@
use rusqlite::{Connection, params};
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct Session {
pub id: i64,
pub name: String,
@ -9,6 +10,7 @@ pub struct Session {
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct Measurement {
pub id: i64,
pub session_id: i64,
@ -18,6 +20,7 @@ pub struct Measurement {
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct DataPoint {
pub id: i64,
pub measurement_id: i64,
@ -120,6 +123,27 @@ impl Storage {
rows.collect()
}
pub fn measurement_count(&self, session_id: i64) -> Result<i64, rusqlite::Error> {
self.conn.query_row(
"SELECT COUNT(*) FROM measurements WHERE session_id = ?1",
params![session_id],
|row| row.get(0),
)
}
pub fn data_point_count(&self, measurement_id: i64) -> Result<i64, rusqlite::Error> {
self.conn.query_row(
"SELECT COUNT(*) FROM data_points WHERE measurement_id = ?1",
params![measurement_id],
|row| row.get(0),
)
}
pub fn delete_measurement(&self, id: i64) -> Result<(), rusqlite::Error> {
self.conn.execute("DELETE FROM measurements WHERE id = ?1", params![id])?;
Ok(())
}
pub fn get_data_points(&self, measurement_id: i64) -> Result<Vec<DataPoint>, rusqlite::Error> {
let mut stmt = self.conn.prepare(
"SELECT id, measurement_id, idx, data_json \