diff --git a/docs/GUI_DESIGN.md b/docs/GUI_DESIGN.md index 0ffc424..282c38a 100644 --- a/docs/GUI_DESIGN.md +++ b/docs/GUI_DESIGN.md @@ -92,18 +92,43 @@ Located in the right column, below the info cards. Hidden for CC/CV/CR/CP modes. - Window size is persisted between sessions (saved in settings) - Minimum window size: 400×400 px - Combined mode: voltage scale on left axis, current and power scales on right axis (color-coded) +- **X axis is time-based**, not sample-index based. Points are positioned by timestamp within an + explicit time domain, and the axis is labelled underneath: + - Roll: labels are relative to now (`-1h02m`, `-2m05s`, `-45s`, `0`) + - Infinite: labels are wall-clock (`HH:MM:SS`) + - Full labels are drawn when the plot is at least 320 px wide; narrower plots are labelled at + their ends only. Split ↕ labels the bottom sub-chart only (all sub-charts share one domain). +- Traces are **min/max decimated** to at most two points per horizontal pixel. The full time span + is always drawn — there is no cap on how many samples a plot may cover — and single-sample + transients survive decimation. +- A pause longer than 5 poll intervals (minimum 1.5 s) **breaks the polyline**, so a disconnect or + a paused log renders as a gap instead of a straight line. An isolated reading between two gaps is + drawn as a dot. - Time mode controls (bottom toolbar row, left-aligned): - - **Mode toggle button** shows current mode: `⟳ Roll` or `∞ Infinite`; click to switch - - **Roll mode**: rolling window of the last N seconds; time window input + "Set" button are shown - - **Infinite mode**: all data since app start or last Clear; time window input and "Set" are hidden - - **Clear button** (Infinite mode only): resets graph display start time to now; does NOT delete samples + - **Mode toggle button** shows current mode: `⟳ Roll` or `∞ Infinite`; click to switch. + Switching is a **view-only** change: it never adds to or removes from the sample buffer, so it + is safe to toggle mid-run. + - **Roll mode**: the last N seconds; time window input + "Set" button are shown. The domain is the + window itself, so a partly-filled window draws on the right-hand portion of the canvas rather + than stretching to fill it. The window is clamped to the history retention setting. + - **Infinite mode**: everything retained in the buffer since the last Clear; the window input and + "Set" are hidden. + - **Clear button** (both modes): sets the graph's view epoch to now. Samples before the epoch are + hidden from the graph in **both** modes, but are **not** deleted — a CSV export still contains + them. Pressing Clear is the only action that hides recorded data from the graph. + - **Buffer label**: wall-clock span currently held in the buffer, so a window wider than the + available history is visibly explained rather than silently ignored. - Hide/show toggle button -- Graph data is independent of CSV export (export uses all collected samples) +- The graph and CSV export read the **same** buffer. Retention (Settings → Application → History + retention, default 86400 s = 24 h) bounds both together: the graph can never show something an + export would miss, and vice versa. Clearing the samples buffer also resets the graph's view epoch. + The 24 h default is sized for long CAP runs such as a car battery discharge. ### 6. Samples Panel - Sample count - Last sample summary (V/I/P values) -- Clear button (clears all samples) +- Clear button (deletes all samples — unlike the chart's Clear, this discards data that would + otherwise be exportable; it also resets the chart's view epoch) - Export button (saves CSV with columns: timestamp, voltage, current, power, resistance, mode) ### 7. Connection Panel @@ -160,6 +185,12 @@ Responsive card-based layout. On wide windows (≥720px), cards are arranged in - Language (dropdown) - Poll interval (dropdown: 50/100/200/500/1000/2000 ms; helper text below) - Auto-connect to first EL15 (toggle) + - History retention (text input + "Set"; seconds, clamped to 60–86400, default 86400 = 24 h; + helper text below). Bounds the shared sample buffer used by both the graph and CSV export. + Shrinking it trims the buffer immediately and pulls the Roll window in with it. + A hard backstop of 500 000 samples (~32 MB) also applies: it sits just above 24 h at the + default 200 ms poll, so a *faster* poll reaches the ceiling first and retains proportionally + less wall-clock time (at 50 ms, roughly 7 h). 2. **SCPI Server** - Enable SCPI server (toggle) diff --git a/el15-app/locales/en.json b/el15-app/locales/en.json index 308b7f3..0b4a7e7 100644 --- a/el15-app/locales/en.json +++ b/el15-app/locales/en.json @@ -82,12 +82,15 @@ "graph.infinite": "Infinite", "graph.time_window": "Window", "graph.clear": "Clear", + "graph.buffer": "Buffer", "settings.scpi_section": "SCPI Server", "settings.firmware_section": "Maintenance", "settings.about_section": "About", "settings.repository": "Repository:", "settings.card.application": "Application", "settings.poll_hint": "Lower values increase responsiveness but may increase device traffic.", + "settings.retention": "History retention", + "settings.retention_hint": "How long samples are kept for the graph and CSV export.", "settings.scpi_port_hint": "Used by external tools to control the load over SCPI.", "settings.maintenance_desc": "Device must be in DFU mode for firmware update. See Firmware Update page for instructions.", "settings.auto_saved": "Settings are saved automatically.", diff --git a/el15-app/locales/es.json b/el15-app/locales/es.json index ac4d073..3c9277e 100644 --- a/el15-app/locales/es.json +++ b/el15-app/locales/es.json @@ -82,12 +82,15 @@ "graph.infinite": "Infinito", "graph.time_window": "Ventana", "graph.clear": "Limpiar", + "graph.buffer": "Búfer", "settings.scpi_section": "Servidor SCPI", "settings.firmware_section": "Mantenimiento", "settings.about_section": "Acerca de", "settings.repository": "Repositorio:", "settings.card.application": "Aplicación", "settings.poll_hint": "Valores más bajos mejoran la respuesta pero pueden aumentar el tráfico al dispositivo.", + "settings.retention": "Retención del historial", + "settings.retention_hint": "Cuánto tiempo se conservan las muestras para el gráfico y la exportación CSV.", "settings.scpi_port_hint": "Usado por herramientas externas para controlar la carga por SCPI.", "settings.maintenance_desc": "El dispositivo debe estar en modo DFU para actualizar firmware. Vea la página de actualización para instrucciones.", "settings.auto_saved": "Los ajustes se guardan automáticamente.", diff --git a/el15-app/locales/hi.json b/el15-app/locales/hi.json index 62fd272..b2e1985 100644 --- a/el15-app/locales/hi.json +++ b/el15-app/locales/hi.json @@ -82,12 +82,15 @@ "graph.infinite": "अनंत", "graph.time_window": "विंडो", "graph.clear": "साफ़ करें", + "graph.buffer": "बफ़र", "settings.scpi_section": "SCPI सर्वर", "settings.firmware_section": "रखरखाव", "settings.about_section": "के बारे में", "settings.repository": "रिपॉजिटरी:", "settings.card.application": "एप्लिकेशन", "settings.poll_hint": "कम मान अधिक त्वरित प्रतिक्रिया देते हैं लेकिन डिवाइस का ट्रैफिक बढ़ा सकते हैं।", + "settings.retention": "इतिहास प्रतिधारण", + "settings.retention_hint": "ग्राफ़ और CSV निर्यात के लिए नमूने कितने समय तक रखे जाते हैं।", "settings.scpi_port_hint": "SCPI के माध्यम से लोड को नियंत्रित करने के लिए बाहरी उपकरणों द्वारा उपयोग किया जाता है।", "settings.maintenance_desc": "फर्मवेयर अपडेट के लिए डिवाइस DFU मोड में होना चाहिए। निर्देशों के लिए फर्मवेयर अपडेट पेज देखें।", "settings.auto_saved": "सेटिंग्स स्वचालित रूप से सहेजी जाती हैं।", diff --git a/el15-app/locales/ru.json b/el15-app/locales/ru.json index ac0d27c..3438701 100644 --- a/el15-app/locales/ru.json +++ b/el15-app/locales/ru.json @@ -82,12 +82,15 @@ "graph.infinite": "Полностью", "graph.time_window": "Окно", "graph.clear": "Очистить", + "graph.buffer": "Буфер", "settings.scpi_section": "Сервер SCPI", "settings.firmware_section": "Обслуживание", "settings.about_section": "О программе", "settings.repository": "Репозиторий:", "settings.card.application": "Приложение", "settings.poll_hint": "Меньшие значения повышают отзывчивость, но увеличивают нагрузку на устройство.", + "settings.retention": "Хранение истории", + "settings.retention_hint": "Сколько времени хранятся отсчёты для графика и экспорта в CSV.", "settings.scpi_port_hint": "Используется внешними инструментами для управления нагрузкой через SCPI.", "settings.maintenance_desc": "Устройство должно быть в режиме DFU для обновления прошивки. Инструкции на странице обновления.", "settings.auto_saved": "Настройки сохраняются автоматически.", diff --git a/el15-app/locales/zh.json b/el15-app/locales/zh.json index ae3525c..f48d64a 100644 --- a/el15-app/locales/zh.json +++ b/el15-app/locales/zh.json @@ -82,12 +82,15 @@ "graph.infinite": "无限", "graph.time_window": "窗口", "graph.clear": "清除", + "graph.buffer": "缓冲", "settings.scpi_section": "SCPI 服务器", "settings.firmware_section": "维护", "settings.about_section": "关于", "settings.repository": "代码仓库:", "settings.card.application": "应用程序", "settings.poll_hint": "较小的值提高响应速度,但可能增加设备通信负担。", + "settings.retention": "历史保留时长", + "settings.retention_hint": "采样数据为图表和 CSV 导出保留的时长。", "settings.scpi_port_hint": "外部工具通过 SCPI 控制负载时使用此端口。", "settings.maintenance_desc": "设备需处于 DFU 模式才能更新固件。请参阅固件更新页面的说明。", "settings.auto_saved": "设置会自动保存。", diff --git a/el15-app/src/graph.rs b/el15-app/src/graph.rs index 1ab59c5..a54c1ef 100644 --- a/el15-app/src/graph.rs +++ b/el15-app/src/graph.rs @@ -7,10 +7,21 @@ //! - Combined: all enabled traces overlaid on one chart //! - SplitVertical: stacked sub-charts (one per enabled trace) //! - SplitHorizontal: side-by-side sub-charts +//! +//! # Data ownership +//! +//! The graph owns no data. It renders a *view* of `AppState::samples` — the +//! same buffer CSV export writes out. Roll and Infinite differ only in which +//! slice of that buffer they show and over what time domain they draw it, so +//! toggling between them mid-run never gains or loses a sample. +//! +//! The one thing that removes data from view is the **Clear** button, which +//! sets a view epoch (`graph_start_time`). That epoch is honoured identically +//! in both modes. use std::collections::VecDeque; -use chrono::{DateTime, Local}; +use chrono::{DateTime, Duration, Local}; use iced::widget::canvas::{self, Cache, Frame, Geometry, Path, Stroke}; use iced::widget::{canvas as canvas_widget, container}; use iced::{mouse, Color, Element, Length, Point, Rectangle, Size, Theme}; @@ -18,12 +29,9 @@ use iced::{mouse, Color, Element, Length, Point, Rectangle, Size, Theme}; use crate::gui::{Message, Sample, COLOR_CURRENT, COLOR_POWER, COLOR_VOLTAGE}; use crate::settings::{GraphLayout, GraphTimeMode}; -/// How many samples to display on the graph at once. -const VISIBLE_SAMPLES: usize = 600; - /// Graph margins (px). const MARGIN_TOP: f32 = 12.0; -const MARGIN_BOTTOM: f32 = 20.0; +const MARGIN_BOTTOM: f32 = 30.0; const MARGIN_LEFT: f32 = 55.0; const MARGIN_RIGHT: f32 = 60.0; @@ -34,9 +42,20 @@ const MIN_P_RANGE: f32 = 1.0; const GRID_LINES: usize = 4; - const SUB_GAP: f32 = 4.0; +/// A pause longer than `GAP_FACTOR` poll intervals breaks the polyline, so a +/// disconnect or a paused log reads as a gap instead of a straight line drawn +/// through time when no measurement existed. +const GAP_FACTOR: i64 = 5; + +/// Floor for the gap threshold, so ordinary BLE jitter at a fast poll rate does +/// not shred the trace into fragments. +const GAP_FLOOR_MS: i64 = 1_500; + +/// Below this width the x axis is labelled at its ends only. +const X_AXIS_FULL_LABELS_W: f32 = 320.0; + /// Build the graph panel element with configurable layout and trace visibility. #[allow(clippy::too_many_arguments)] pub fn view_configurable<'a>( @@ -49,6 +68,7 @@ pub fn view_configurable<'a>( time_mode: GraphTimeMode, time_window_s: u32, graph_start_time: Option>, + poll_interval_ms: u64, ) -> Element<'a, Message> { let chart = canvas_widget(GraphCanvas { samples, @@ -60,6 +80,7 @@ pub fn view_configurable<'a>( time_mode, time_window_s, graph_start_time, + poll_interval_ms, }) .width(Length::Fill) .height(Length::Fill); @@ -84,6 +105,7 @@ struct GraphCanvas<'a> { time_mode: GraphTimeMode, time_window_s: u32, graph_start_time: Option>, + poll_interval_ms: u64, } impl<'a> canvas::Program for GraphCanvas<'a> { @@ -127,122 +149,195 @@ impl<'a> GraphCanvas<'a> { Color::from_rgba(0.0, 0.0, 0.0, 0.55) }; - let visible = get_visible(self.samples, self.time_mode, self.time_window_s, self.graph_start_time); + let now = Local::now(); + let cutoff = visible_cutoff(now, self.time_mode, self.time_window_s, self.graph_start_time); + let visible = get_visible(self.samples, cutoff); + + // Roll always has a domain (the window itself) even with no data in it, + // but there is nothing to draw, so fall through to the placeholder. if visible.is_empty() { draw_no_data(frame, size, axis_color); return; } + let (t_min_ms, span_ms) = + time_domain(&visible, now, self.time_mode, self.time_window_s, cutoff); + let gap_ms = gap_threshold_ms(self.poll_interval_ms); + match self.layout { - GraphLayout::Combined => self.render_combined(frame, size, &visible, grid_color, axis_color), - GraphLayout::SplitVertical => self.render_split_vertical(frame, size, &visible, grid_color, axis_color), - GraphLayout::SplitHorizontal => self.render_split_horizontal(frame, size, &visible, grid_color, axis_color), + GraphLayout::Combined => { + self.render_combined(frame, size, &visible, t_min_ms, span_ms, gap_ms, grid_color, axis_color) + } + GraphLayout::SplitVertical => { + self.render_split_vertical(frame, size, &visible, t_min_ms, span_ms, gap_ms, grid_color, axis_color) + } + GraphLayout::SplitHorizontal => { + self.render_split_horizontal(frame, size, &visible, t_min_ms, span_ms, gap_ms, grid_color, axis_color) + } } } - fn render_combined(&self, frame: &mut Frame, size: Size, visible: &[&Sample], grid_color: Color, axis_color: Color) { - let graph_w = (size.width - MARGIN_LEFT - MARGIN_RIGHT).max(1.0); - let graph_h = (size.height - MARGIN_TOP - MARGIN_BOTTOM).max(1.0); - let n = visible.len(); + #[allow(clippy::too_many_arguments)] + fn render_combined( + &self, + frame: &mut Frame, + size: Size, + visible: &Visible<'_>, + t_min_ms: i64, + span_ms: f64, + gap_ms: i64, + grid_color: Color, + axis_color: Color, + ) { + let ctx = PlotCtx { + x_off: MARGIN_LEFT, + y_off: MARGIN_TOP, + graph_w: (size.width - MARGIN_LEFT - MARGIN_RIGHT).max(1.0), + graph_h: (size.height - MARGIN_TOP - MARGIN_BOTTOM).max(1.0), + t_min_ms, + span_ms, + gap_ms, + }; - draw_grid(frame, MARGIN_LEFT, MARGIN_TOP, graph_w, graph_h, grid_color, axis_color); + draw_grid(frame, ctx.x_off, ctx.y_off, ctx.graph_w, ctx.graph_h, grid_color, axis_color); + draw_x_axis(frame, &ctx, self.time_mode, axis_color); // Count how many right-side axes we need to place let mut right_axis_index: usize = 0; if self.show_voltage { let (v_min, v_max) = auto_range(visible.iter().map(|s| s.voltage), MIN_V_RANGE); - draw_trace(frame, visible, n, MARGIN_LEFT, MARGIN_TOP, graph_w, graph_h, |s| s.voltage, v_min, v_max, COLOR_VOLTAGE, 2.0); - draw_y_axis(frame, MARGIN_LEFT, MARGIN_TOP, graph_h, v_min, v_max, COLOR_VOLTAGE); + draw_trace(frame, visible, &ctx, |s| s.voltage, (v_min, v_max), COLOR_VOLTAGE, 2.0); + draw_y_axis(frame, ctx.x_off, ctx.y_off, ctx.graph_h, v_min, v_max, COLOR_VOLTAGE); } if self.show_current { let (i_min, i_max) = auto_range(visible.iter().map(|s| s.current), MIN_I_RANGE); - draw_trace(frame, visible, n, MARGIN_LEFT, MARGIN_TOP, graph_w, graph_h, |s| s.current, i_min, i_max, COLOR_CURRENT, 2.0); + draw_trace(frame, visible, &ctx, |s| s.current, (i_min, i_max), COLOR_CURRENT, 2.0); if !self.show_voltage { - draw_y_axis(frame, MARGIN_LEFT, MARGIN_TOP, graph_h, i_min, i_max, COLOR_CURRENT); + draw_y_axis(frame, ctx.x_off, ctx.y_off, ctx.graph_h, i_min, i_max, COLOR_CURRENT); } else { - draw_y_axis_right(frame, MARGIN_LEFT + graph_w, MARGIN_TOP, graph_h, i_min, i_max, COLOR_CURRENT, right_axis_index); + draw_y_axis_right(frame, ctx.x_off + ctx.graph_w, ctx.y_off, ctx.graph_h, i_min, i_max, COLOR_CURRENT, right_axis_index); right_axis_index += 1; } } if self.show_power { let (p_min, p_max) = auto_range(visible.iter().map(|s| s.power), MIN_P_RANGE); - draw_trace(frame, visible, n, MARGIN_LEFT, MARGIN_TOP, graph_w, graph_h, |s| s.power, p_min, p_max, COLOR_POWER, 1.5); + draw_trace(frame, visible, &ctx, |s| s.power, (p_min, p_max), COLOR_POWER, 1.5); if !self.show_voltage && !self.show_current { - draw_y_axis(frame, MARGIN_LEFT, MARGIN_TOP, graph_h, p_min, p_max, COLOR_POWER); + draw_y_axis(frame, ctx.x_off, ctx.y_off, ctx.graph_h, p_min, p_max, COLOR_POWER); } else { - draw_y_axis_right(frame, MARGIN_LEFT + graph_w, MARGIN_TOP, graph_h, p_min, p_max, COLOR_POWER, right_axis_index); + draw_y_axis_right(frame, ctx.x_off + ctx.graph_w, ctx.y_off, ctx.graph_h, p_min, p_max, COLOR_POWER, right_axis_index); #[allow(unused_assignments)] - { right_axis_index += 1; } + { + right_axis_index += 1; + } } } draw_legend(frame, size, self.show_voltage, self.show_current, self.show_power); } - fn render_split_vertical(&self, frame: &mut Frame, size: Size, visible: &[&Sample], grid_color: Color, axis_color: Color) { + #[allow(clippy::too_many_arguments)] + fn render_split_vertical( + &self, + frame: &mut Frame, + size: Size, + visible: &Visible<'_>, + t_min_ms: i64, + span_ms: f64, + gap_ms: i64, + grid_color: Color, + axis_color: Color, + ) { let traces = self.active_traces(); - if traces.is_empty() { return; } + if traces.is_empty() { + return; + } let total_gap = SUB_GAP * (traces.len() as f32 - 1.0).max(0.0); let sub_h = ((size.height - total_gap) / traces.len() as f32).max(30.0); + let last_idx = traces.len() - 1; for (idx, trace) in traces.iter().enumerate() { - let y_off = idx as f32 * (sub_h + SUB_GAP); - let graph_w = (size.width - MARGIN_LEFT - 8.0).max(1.0); - let inner_h = (sub_h - MARGIN_TOP - MARGIN_BOTTOM).max(1.0); - let n = visible.len(); - - draw_grid(frame, MARGIN_LEFT, y_off + MARGIN_TOP, graph_w, inner_h, grid_color, axis_color); - - let (value_fn, min_range, color): (fn(&Sample) -> f32, f32, Color) = match trace { - TraceKind::Voltage => (|s| s.voltage, MIN_V_RANGE, COLOR_VOLTAGE), - TraceKind::Current => (|s| s.current, MIN_I_RANGE, COLOR_CURRENT), - TraceKind::Power => (|s| s.power, MIN_P_RANGE, COLOR_POWER), + let ctx = PlotCtx { + x_off: MARGIN_LEFT, + y_off: idx as f32 * (sub_h + SUB_GAP) + MARGIN_TOP, + graph_w: (size.width - MARGIN_LEFT - 8.0).max(1.0), + graph_h: (sub_h - MARGIN_TOP - MARGIN_BOTTOM).max(1.0), + t_min_ms, + span_ms, + gap_ms, }; - let (axis_min, axis_max) = auto_range(visible.iter().map(|s| value_fn(s)), min_range); - draw_trace(frame, visible, n, MARGIN_LEFT, y_off + MARGIN_TOP, graph_w, inner_h, value_fn, axis_min, axis_max, color, 2.0); - draw_y_axis(frame, MARGIN_LEFT, y_off + MARGIN_TOP, inner_h, axis_min, axis_max, color); - draw_trace_label(frame, MARGIN_LEFT + 4.0, y_off + MARGIN_TOP + 2.0, trace.label(), color); + draw_grid(frame, ctx.x_off, ctx.y_off, ctx.graph_w, ctx.graph_h, grid_color, axis_color); + // Stacked charts share one time domain — label it once, at the bottom. + if idx == last_idx { + draw_x_axis(frame, &ctx, self.time_mode, axis_color); + } + + let (value_fn, min_range, color) = trace.spec(); + let (axis_min, axis_max) = auto_range(visible.iter().map(value_fn), min_range); + draw_trace(frame, visible, &ctx, value_fn, (axis_min, axis_max), color, 2.0); + draw_y_axis(frame, ctx.x_off, ctx.y_off, ctx.graph_h, axis_min, axis_max, color); + draw_trace_label(frame, ctx.x_off + 4.0, ctx.y_off + 2.0, trace.label(), color); } } - fn render_split_horizontal(&self, frame: &mut Frame, size: Size, visible: &[&Sample], grid_color: Color, axis_color: Color) { + #[allow(clippy::too_many_arguments)] + fn render_split_horizontal( + &self, + frame: &mut Frame, + size: Size, + visible: &Visible<'_>, + t_min_ms: i64, + span_ms: f64, + gap_ms: i64, + grid_color: Color, + axis_color: Color, + ) { let traces = self.active_traces(); - if traces.is_empty() { return; } + if traces.is_empty() { + return; + } let total_gap = SUB_GAP * (traces.len() as f32 - 1.0).max(0.0); let sub_w = ((size.width - total_gap) / traces.len() as f32).max(60.0); for (idx, trace) in traces.iter().enumerate() { - let x_off = idx as f32 * (sub_w + SUB_GAP); let margin_l = 45.0_f32; let margin_r = 6.0_f32; - let graph_w = (sub_w - margin_l - margin_r).max(1.0); - let graph_h = (size.height - MARGIN_TOP - MARGIN_BOTTOM).max(1.0); - let n = visible.len(); - - draw_grid(frame, x_off + margin_l, MARGIN_TOP, graph_w, graph_h, grid_color, axis_color); - - let (value_fn, min_range, color): (fn(&Sample) -> f32, f32, Color) = match trace { - TraceKind::Voltage => (|s| s.voltage, MIN_V_RANGE, COLOR_VOLTAGE), - TraceKind::Current => (|s| s.current, MIN_I_RANGE, COLOR_CURRENT), - TraceKind::Power => (|s| s.power, MIN_P_RANGE, COLOR_POWER), + let ctx = PlotCtx { + x_off: idx as f32 * (sub_w + SUB_GAP) + margin_l, + y_off: MARGIN_TOP, + graph_w: (sub_w - margin_l - margin_r).max(1.0), + graph_h: (size.height - MARGIN_TOP - MARGIN_BOTTOM).max(1.0), + t_min_ms, + span_ms, + gap_ms, }; - let (axis_min, axis_max) = auto_range(visible.iter().map(|s| value_fn(s)), min_range); - draw_trace(frame, visible, n, x_off + margin_l, MARGIN_TOP, graph_w, graph_h, value_fn, axis_min, axis_max, color, 2.0); - draw_y_axis(frame, x_off + margin_l, MARGIN_TOP, graph_h, axis_min, axis_max, color); - draw_trace_label(frame, x_off + margin_l + 4.0, MARGIN_TOP + 2.0, trace.label(), color); + draw_grid(frame, ctx.x_off, ctx.y_off, ctx.graph_w, ctx.graph_h, grid_color, axis_color); + draw_x_axis(frame, &ctx, self.time_mode, axis_color); + + let (value_fn, min_range, color) = trace.spec(); + let (axis_min, axis_max) = auto_range(visible.iter().map(value_fn), min_range); + draw_trace(frame, visible, &ctx, value_fn, (axis_min, axis_max), color, 2.0); + draw_y_axis(frame, ctx.x_off, ctx.y_off, ctx.graph_h, axis_min, axis_max, color); + draw_trace_label(frame, ctx.x_off + 4.0, ctx.y_off + 2.0, trace.label(), color); } } fn active_traces(&self) -> Vec { let mut v = Vec::with_capacity(3); - if self.show_voltage { v.push(TraceKind::Voltage); } - if self.show_current { v.push(TraceKind::Current); } - if self.show_power { v.push(TraceKind::Power); } + if self.show_voltage { + v.push(TraceKind::Voltage); + } + if self.show_current { + v.push(TraceKind::Current); + } + if self.show_power { + v.push(TraceKind::Power); + } v } } @@ -250,7 +345,11 @@ impl<'a> GraphCanvas<'a> { // ---- Trace enum --------------------------------------------------------- #[derive(Clone, Copy)] -enum TraceKind { Voltage, Current, Power } +enum TraceKind { + Voltage, + Current, + Power, +} impl TraceKind { fn label(self) -> &'static str { @@ -260,37 +359,264 @@ impl TraceKind { Self::Power => "P", } } + + /// Value accessor, minimum axis span and colour for this trace. + fn spec(self) -> (fn(&Sample) -> f32, f32, Color) { + match self { + Self::Voltage => (|s| s.voltage, MIN_V_RANGE, COLOR_VOLTAGE), + Self::Current => (|s| s.current, MIN_I_RANGE, COLOR_CURRENT), + Self::Power => (|s| s.power, MIN_P_RANGE, COLOR_POWER), + } + } } -// ---- Shared drawing helpers --------------------------------------------- +// ---- Plot geometry ------------------------------------------------------ + +/// Everything a trace needs to map (time, value) onto the canvas. +/// +/// Bundled into a struct rather than threaded through as loose arguments — +/// `draw_trace` would otherwise take a dozen parameters. +#[derive(Clone, Copy, Debug)] +struct PlotCtx { + x_off: f32, + y_off: f32, + graph_w: f32, + graph_h: f32, + /// Left edge of the time domain, in epoch milliseconds. + t_min_ms: i64, + /// Width of the time domain in milliseconds; always >= 1.0. + span_ms: f64, + /// Sample spacing above which the polyline is broken. + gap_ms: i64, +} + +impl PlotCtx { + /// Fraction of the domain at which `t_ms` falls, clamped to `0..=1`. + fn frac_of(&self, t_ms: i64) -> f64 { + (((t_ms - self.t_min_ms) as f64) / self.span_ms).clamp(0.0, 1.0) + } + + /// Canvas x for an epoch-millisecond timestamp. + fn x_of(&self, t_ms: i64) -> f32 { + self.x_off + self.frac_of(t_ms) as f32 * self.graph_w + } +} + +fn gap_threshold_ms(poll_interval_ms: u64) -> i64 { + (poll_interval_ms as i64).saturating_mul(GAP_FACTOR).max(GAP_FLOOR_MS) +} -fn get_visible( - samples: &VecDeque, +// ---- Visible-slice selection -------------------------------------------- + +/// Oldest timestamp the graph may draw, or `None` for "everything retained". +/// +/// `graph_start_time` is the epoch set by the **Clear** button. It is honoured +/// in *both* modes deliberately: it is the single, mode-independent answer to +/// "which samples has the user asked to stop seeing". Because Roll additionally +/// bounds by its window, switching Roll -> Infinite mid-run always reveals at +/// least as much as Roll showed, and never less. +fn visible_cutoff( + now: DateTime, time_mode: GraphTimeMode, time_window_s: u32, graph_start_time: Option>, -) -> Vec<&Sample> { - let filtered: Vec<&Sample> = match time_mode { +) -> Option> { + match time_mode { GraphTimeMode::Roll => { - let cutoff = Local::now() - chrono::Duration::seconds(time_window_s as i64); - samples.iter().filter(|s| s.when >= cutoff).collect() + let window_start = now - Duration::seconds(time_window_s as i64); + Some(match graph_start_time { + Some(epoch) if epoch > window_start => epoch, + _ => window_start, + }) } - GraphTimeMode::Infinite => { - if let Some(start) = graph_start_time { - samples.iter().filter(|s| s.when >= start).collect() - } else { - samples.iter().collect() - } + GraphTimeMode::Infinite => graph_start_time, + } +} + +/// A re-iterable borrow of the retained samples at or after the view cutoff. +/// +/// Deliberately *not* a `Vec<&Sample>`: with a 24 h buffer the visible slice +/// runs to hundreds of thousands of samples, and materialising a pointer vector +/// on every redraw would churn megabytes per frame to no purpose. The buffer is +/// append-only, so a start index is the whole of the state needed. +#[derive(Clone, Copy)] +struct Visible<'a> { + samples: &'a VecDeque, + start: usize, +} + +impl<'a> Visible<'a> { + fn iter(&self) -> std::collections::vec_deque::Iter<'a, Sample> { + self.samples.range(self.start..) + } + + fn len(&self) -> usize { + self.samples.len() - self.start + } + + fn is_empty(&self) -> bool { + self.len() == 0 + } + + fn first(&self) -> Option<&'a Sample> { + self.samples.get(self.start) + } + + /// The visible slice always runs to the end of the buffer. + fn last(&self) -> Option<&'a Sample> { + self.samples.back() + } +} + +/// Borrow the samples at or after `cutoff`. +/// +/// The buffer is append-only in timestamp order, so the cut point is found by +/// binary search rather than by scanning — this runs on every redraw. +fn get_visible(samples: &VecDeque, cutoff: Option>) -> Visible<'_> { + let start = match cutoff { + None => 0, + Some(c) => samples.partition_point(|s| s.when < c), + }; + Visible { samples, start } +} + +/// The time domain to draw over, as `(t_min_ms, span_ms)`. +/// +/// In Roll the domain is the *window*, anchored to now — not the extent of the +/// data that happens to be in it. A half-filled window therefore draws on the +/// right-hand half of the canvas instead of stretching to fill it, which is what +/// makes changing the window size visible. +fn time_domain( + visible: &Visible<'_>, + now: DateTime, + time_mode: GraphTimeMode, + time_window_s: u32, + cutoff: Option>, +) -> (i64, f64) { + let (t_min, t_max) = match time_mode { + GraphTimeMode::Roll => { + let start = cutoff.unwrap_or(now - Duration::seconds(time_window_s as i64)); + // Guard against a sample stamped slightly ahead of `now`. + let end = visible.last().map_or(now, |s| s.when.max(now)); + (start, end) } + // Infinite: the domain is exactly the retained span, so the plot holds + // still when data stops arriving instead of creeping leftwards. + GraphTimeMode::Infinite => ( + visible.first().map_or(now, |s| s.when), + visible.last().map_or(now, |s| s.when), + ), }; - let total = filtered.len(); - if total <= VISIBLE_SAMPLES { - filtered - } else { - filtered.into_iter().skip(total - VISIBLE_SAMPLES).collect() + let t_min_ms = t_min.timestamp_millis(); + let span_ms = (t_max.timestamp_millis() - t_min_ms) as f64; + (t_min_ms, if span_ms >= 1.0 { span_ms } else { 1000.0 }) +} + +// ---- Decimation --------------------------------------------------------- + +/// One pixel column's worth of samples, reduced to its extremes. +#[derive(Clone, Copy)] +struct Bucket { + col: usize, + lo: (f32, f32), + hi: (f32, f32), + lo_t: i64, + hi_t: i64, +} + +impl Bucket { + fn new(col: usize, x: f32, v: f32, t: i64) -> Self { + Self { col, lo: (x, v), hi: (x, v), lo_t: t, hi_t: t } + } + + fn add(&mut self, x: f32, v: f32, t: i64) { + if v < self.lo.1 { + self.lo = (x, v); + self.lo_t = t; + } + if v > self.hi.1 { + self.hi = (x, v); + self.hi_t = t; + } + } + + /// Emit the column's extremes in timestamp order. + fn flush_into(&self, out: &mut Vec<(f32, f32)>) { + if self.lo_t == self.hi_t { + out.push(self.lo); + } else if self.lo_t < self.hi_t { + out.push(self.lo); + out.push(self.hi); + } else { + out.push(self.hi); + out.push(self.lo); + } + } +} + +/// Reduce `visible` to at most two points per horizontal pixel, keeping each +/// column's minimum and maximum. +/// +/// This replaces the previous "draw only the newest N samples" cap, which +/// silently discarded history: with truncation, a window wider than N samples +/// simply showed nothing extra. Min/max decimation keeps the whole span on +/// screen at a bounded vertex count and preserves single-sample transients that +/// stride-sampling would step over. +/// +/// The returned polylines are split wherever consecutive samples are more than +/// `ctx.gap_ms` apart, so a disconnect is drawn as a gap, not as a line. +fn decimate( + visible: &Visible<'_>, + ctx: &PlotCtx, + value_fn: &impl Fn(&Sample) -> f32, +) -> Vec> { + let cols = ctx.graph_w.max(1.0) as usize; + let mut segments: Vec> = Vec::new(); + let mut seg: Vec<(f32, f32)> = Vec::new(); + let mut bucket: Option = None; + let mut prev_t: Option = None; + + for s in visible.iter() { + let t = s.when.timestamp_millis(); + let broken = prev_t.is_some_and(|p| t - p > ctx.gap_ms); + prev_t = Some(t); + + if broken { + if let Some(b) = bucket.take() { + b.flush_into(&mut seg); + } + if !seg.is_empty() { + segments.push(std::mem::take(&mut seg)); + } + } + + let frac = ctx.frac_of(t); + let col = ((frac * cols as f64) as usize).min(cols - 1); + let x = ctx.x_off + frac as f32 * ctx.graph_w; + let v = value_fn(s); + + match bucket.as_mut() { + Some(b) if b.col == col => b.add(x, v, t), + _ => { + if let Some(b) = bucket.take() { + b.flush_into(&mut seg); + } + bucket = Some(Bucket::new(col, x, v, t)); + } + } + } + + if let Some(b) = bucket.take() { + b.flush_into(&mut seg); + } + if !seg.is_empty() { + segments.push(seg); } + segments } +// ---- Shared drawing helpers --------------------------------------------- + fn draw_no_data(frame: &mut Frame, size: Size, axis_color: Color) { frame.fill_text(canvas::Text { content: "Waiting for data…".into(), @@ -358,6 +684,58 @@ fn draw_y_axis_right(frame: &mut Frame, x_off: f32, y_off: f32, graph_h: f32, mi } } +/// Label the time domain under the plot. +/// +/// Roll is labelled relative to now (`-60s` … `0`) because the window is what +/// the user set; Infinite is labelled with wall-clock times because its span is +/// whatever has been recorded. Without these labels the two modes are visually +/// indistinguishable, which is what made the window setting look inert. +fn draw_x_axis(frame: &mut Frame, ctx: &PlotCtx, time_mode: GraphTimeMode, color: Color) { + let ticks = if ctx.graph_w >= X_AXIS_FULL_LABELS_W { GRID_LINES } else { 1 }; + let y = ctx.y_off + ctx.graph_h + 4.0; + for i in 0..=ticks { + let frac = i as f64 / ticks as f64; + let t_ms = ctx.t_min_ms + (ctx.span_ms * frac) as i64; + let content = match time_mode { + GraphTimeMode::Roll => { + let behind_ms = (ctx.span_ms * (1.0 - frac)) as i64; + fmt_offset(behind_ms / 1000) + } + GraphTimeMode::Infinite => DateTime::from_timestamp_millis(t_ms) + .map(|t| t.with_timezone(&Local).format("%H:%M:%S").to_string()) + .unwrap_or_default(), + }; + let (x, align) = if i == 0 { + (ctx.x_off, iced::alignment::Horizontal::Left) + } else if i == ticks { + (ctx.x_off + ctx.graph_w, iced::alignment::Horizontal::Right) + } else { + (ctx.x_of(t_ms), iced::alignment::Horizontal::Center) + }; + frame.fill_text(canvas::Text { + content, + position: Point::new(x, y), + color, + size: 9.0.into(), + align_x: align.into(), + ..Default::default() + }); + } +} + +/// Compact "how long ago" label, e.g. `-1h02m`, `-2m05s`, `-45s`, `0`. +fn fmt_offset(secs: i64) -> String { + if secs <= 0 { + "0".to_string() + } else if secs >= 3600 { + format!("-{}h{:02}m", secs / 3600, (secs % 3600) / 60) + } else if secs >= 60 { + format!("-{}m{:02}s", secs / 60, secs % 60) + } else { + format!("-{secs}s") + } +} + fn draw_trace_label(frame: &mut Frame, x: f32, y: f32, label: &str, color: Color) { frame.fill_text(canvas::Text { content: label.into(), @@ -368,36 +746,46 @@ fn draw_trace_label(frame: &mut Frame, x: f32, y: f32, label: &str, color: Color }); } -#[allow(clippy::too_many_arguments)] fn draw_trace( frame: &mut Frame, - visible: &[&Sample], - n: usize, - x_off: f32, - y_off: f32, - graph_w: f32, - graph_h: f32, + visible: &Visible<'_>, + ctx: &PlotCtx, value_fn: impl Fn(&Sample) -> f32, - axis_min: f32, - axis_max: f32, + axis: (f32, f32), color: Color, width: f32, ) { - if n < 2 { return; } + if visible.len() < 2 { + return; + } + let (axis_min, axis_max) = axis; let range = (axis_max - axis_min).max(1e-9); - let path = Path::new(|builder| { - for (i, s) in visible.iter().enumerate() { - let x = x_off + (i as f32 / (n - 1) as f32) * graph_w; - let norm = ((value_fn(s) - axis_min) / range).clamp(0.0, 1.0); - let y = y_off + graph_h - norm * graph_h; - if i == 0 { - builder.move_to(Point::new(x, y)); - } else { - builder.line_to(Point::new(x, y)); - } + let y_of = |v: f32| { + let norm = ((v - axis_min) / range).clamp(0.0, 1.0); + ctx.y_off + ctx.graph_h - norm * ctx.graph_h + }; + let stroke = Stroke::default().with_color(color).with_width(width); + + for seg in decimate(visible, ctx, &value_fn) { + // A run isolated between two gaps has no line to draw — mark it so a + // lone reading after a dropout is still visible. + if seg.len() == 1 { + let (x, v) = seg[0]; + frame.fill(&Path::circle(Point::new(x, y_of(v)), width * 0.6), color); + continue; } - }); - frame.stroke(&path, Stroke::default().with_color(color).with_width(width)); + let path = Path::new(|builder| { + for (i, &(x, v)) in seg.iter().enumerate() { + let p = Point::new(x, y_of(v)); + if i == 0 { + builder.move_to(p); + } else { + builder.line_to(p); + } + } + }); + frame.stroke(&path, stroke); + } } fn draw_legend(frame: &mut Frame, size: Size, show_v: bool, show_i: bool, show_p: bool) { @@ -438,3 +826,300 @@ fn auto_range(values: impl Iterator, min_span: f32) -> (f32, f32) { let pad = span * 0.05; (lo - pad, hi + pad) } + +// ---- tests -------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_at(when: DateTime, voltage: f32) -> Sample { + Sample { + when, + voltage, + current: 1.0, + power: voltage, + resistance: voltage, + temperature: 25.0, + runtime_s: 0, + mode: "CC".into(), + load_on: true, + } + } + + /// `n` samples ending at `now`, spaced `step_ms` apart. + fn buffer(now: DateTime, n: usize, step_ms: i64) -> VecDeque { + (0..n) + .map(|i| { + let back = (n - 1 - i) as i64 * step_ms; + sample_at(now - Duration::milliseconds(back), i as f32) + }) + .collect() + } + + fn ctx_for(t_min_ms: i64, span_ms: f64, graph_w: f32) -> PlotCtx { + PlotCtx { + x_off: 0.0, + y_off: 0.0, + graph_w, + graph_h: 100.0, + t_min_ms, + span_ms, + gap_ms: 1_000, + } + } + + // ---- visible slice --------------------------------------------------- + + #[test] + fn roll_keeps_only_the_window() { + let now = Local::now(); + // 10 minutes of samples, one per second. + let samples = buffer(now, 600, 1_000); + let cutoff = visible_cutoff(now, GraphTimeMode::Roll, 60, None); + let visible = get_visible(&samples, cutoff); + // 60 s window at 1 Hz: 61 samples including both boundaries. + assert!((60..=61).contains(&visible.len()), "got {}", visible.len()); + } + + #[test] + fn roll_boundary_sample_is_included() { + let now = Local::now(); + let mut samples = VecDeque::new(); + // Exactly on the cutoff — must be kept, the filter is inclusive. + samples.push_back(sample_at(now - Duration::seconds(60), 1.0)); + samples.push_back(sample_at(now, 2.0)); + let cutoff = visible_cutoff(now, GraphTimeMode::Roll, 60, None); + assert_eq!(get_visible(&samples, cutoff).len(), 2); + } + + #[test] + fn infinite_shows_the_whole_buffer_without_a_clear() { + let now = Local::now(); + let samples = buffer(now, 5_000, 200); + let cutoff = visible_cutoff(now, GraphTimeMode::Infinite, 60, None); + assert!(cutoff.is_none()); + assert_eq!(get_visible(&samples, cutoff).len(), 5_000); + } + + /// The regression this whole change exists for: the old code truncated the + /// visible slice to the newest 600 samples, so Infinite could never show + /// more than ~2 minutes of a CAP run. + #[test] + fn infinite_is_not_capped_at_a_fixed_sample_count() { + let now = Local::now(); + let samples = buffer(now, 50_000, 200); + let visible = get_visible(&samples, None); + assert_eq!(visible.len(), 50_000); + } + + #[test] + fn switching_modes_never_loses_samples() { + let now = Local::now(); + let samples = buffer(now, 3_000, 200); // 10 minutes at 5 Hz + + let roll = get_visible(&samples, visible_cutoff(now, GraphTimeMode::Roll, 60, None)); + let infinite = get_visible(&samples, visible_cutoff(now, GraphTimeMode::Infinite, 60, None)); + + // Infinite is a superset of Roll, and the buffer itself is untouched. + assert!(infinite.len() > roll.len()); + assert_eq!(infinite.len(), samples.len()); + assert!(roll.iter().all(|s| infinite.iter().any(|i| i.when == s.when))); + } + + // ---- the clear epoch ------------------------------------------------- + + #[test] + fn clear_is_the_only_thing_that_hides_data_in_infinite() { + let now = Local::now(); + let samples = buffer(now, 600, 1_000); // 10 minutes at 1 Hz + + // No clear pressed: everything retained is visible, whatever the window. + for window in [5_u32, 60, 3600, 86_400] { + let cutoff = visible_cutoff(now, GraphTimeMode::Infinite, window, None); + assert_eq!(get_visible(&samples, cutoff).len(), samples.len()); + } + + // Clear pressed 60 s ago: only samples after the epoch remain visible. + let epoch = now - Duration::seconds(60); + let cutoff = visible_cutoff(now, GraphTimeMode::Infinite, 60, Some(epoch)); + let visible = get_visible(&samples, cutoff); + assert!(visible.len() < samples.len()); + assert!(visible.iter().all(|s| s.when >= epoch)); + } + + #[test] + fn clear_epoch_applies_in_roll_mode_too() { + let now = Local::now(); + let samples = buffer(now, 600, 1_000); + let epoch = now - Duration::seconds(10); + + // Epoch is newer than the window start, so it wins. + let cutoff = visible_cutoff(now, GraphTimeMode::Roll, 60, Some(epoch)); + assert_eq!(cutoff, Some(epoch)); + assert!(get_visible(&samples, cutoff).iter().all(|s| s.when >= epoch)); + } + + #[test] + fn old_clear_epoch_does_not_widen_the_roll_window() { + let now = Local::now(); + let epoch = now - Duration::seconds(600); + // Epoch is older than the window start — the window still bounds the view. + let cutoff = visible_cutoff(now, GraphTimeMode::Roll, 60, Some(epoch)).unwrap(); + assert!(cutoff > epoch); + assert!((now - cutoff).num_seconds() <= 61); + } + + // ---- time domain ----------------------------------------------------- + + #[test] + fn roll_domain_is_the_window_not_the_data() { + let now = Local::now(); + // Only 10 s of data in a 60 s window. + let samples = buffer(now, 10, 1_000); + let visible = get_visible(&samples, None); + let cutoff = visible_cutoff(now, GraphTimeMode::Roll, 60, None); + let (t_min_ms, span_ms) = time_domain(&visible, now, GraphTimeMode::Roll, 60, cutoff); + + // Domain spans the full 60 s window... + assert!((span_ms - 60_000.0).abs() < 1_500.0, "span {span_ms}"); + // ...so the oldest sample sits in the right-hand portion of the canvas, + // rather than being stretched to the left edge as the old index-based + // mapping did. + let ctx = ctx_for(t_min_ms, span_ms, 600.0); + let first_x = ctx.x_of(visible.first().unwrap().when.timestamp_millis()); + assert!(first_x > 400.0, "first sample at x={first_x}, expected right side"); + } + + #[test] + fn wider_roll_window_moves_the_data_right() { + let now = Local::now(); + let samples = buffer(now, 30, 1_000); // 30 s of data + let visible = get_visible(&samples, None); + + let x_at = |window: u32| { + let cutoff = visible_cutoff(now, GraphTimeMode::Roll, window, None); + let (t_min_ms, span_ms) = time_domain(&visible, now, GraphTimeMode::Roll, window, cutoff); + ctx_for(t_min_ms, span_ms, 600.0).x_of(visible.first().unwrap().when.timestamp_millis()) + }; + + // Changing the window must actually change the picture — under the old + // index-based x mapping both of these were identical. + assert!(x_at(600) > x_at(60), "{} vs {}", x_at(600), x_at(60)); + } + + #[test] + fn infinite_domain_spans_the_retained_data() { + let now = Local::now(); + let samples = buffer(now, 100, 1_000); // 99 s span + let visible = get_visible(&samples, None); + let (_, span_ms) = time_domain(&visible, now, GraphTimeMode::Infinite, 60, None); + assert!((span_ms - 99_000.0).abs() < 100.0, "span {span_ms}"); + } + + #[test] + fn degenerate_domain_does_not_divide_by_zero() { + let now = Local::now(); + let samples: VecDeque = std::iter::once(sample_at(now, 1.0)).collect(); + let visible = get_visible(&samples, None); + let (t_min_ms, span_ms) = time_domain(&visible, now, GraphTimeMode::Infinite, 60, None); + assert!(span_ms >= 1.0); + let ctx = ctx_for(t_min_ms, span_ms, 600.0); + assert!(ctx.x_of(now.timestamp_millis()).is_finite()); + } + + // ---- decimation ------------------------------------------------------ + + #[test] + fn decimation_bounds_vertex_count() { + let now = Local::now(); + let samples = buffer(now, 100_000, 100); + let visible = get_visible(&samples, None); + let span = (visible.last().unwrap().when - visible.first().unwrap().when).num_milliseconds() as f64; + let ctx = PlotCtx { + gap_ms: i64::MAX, // one continuous run + ..ctx_for(visible.first().unwrap().when.timestamp_millis(), span, 800.0) + }; + + let segs = decimate(&visible, &ctx, &|s: &Sample| s.voltage); + let total: usize = segs.iter().map(|s| s.len()).sum(); + assert_eq!(segs.len(), 1); + // At most min + max per pixel column. + assert!(total <= 2 * 800, "emitted {total} points for 800 px"); + assert!(total > 100, "decimation collapsed the trace to {total} points"); + } + + #[test] + fn decimation_preserves_a_single_sample_spike() { + let now = Local::now(); + let mut samples = buffer(now, 10_000, 100); + // Flatten everything, then plant one spike in the middle. + for s in samples.iter_mut() { + s.voltage = 1.0; + } + samples[5_000].voltage = 42.0; + + let visible = get_visible(&samples, None); + let span = (visible.last().unwrap().when - visible.first().unwrap().when).num_milliseconds() as f64; + let ctx = PlotCtx { + gap_ms: i64::MAX, + ..ctx_for(visible.first().unwrap().when.timestamp_millis(), span, 400.0) + }; + + let segs = decimate(&visible, &ctx, &|s: &Sample| s.voltage); + let peak = segs + .iter() + .flatten() + .map(|&(_, v)| v) + .fold(f32::MIN, f32::max); + // Truncation or stride-sampling would drop this; min/max keeps it. + assert_eq!(peak, 42.0); + } + + #[test] + fn decimation_passes_sparse_data_through_unchanged() { + let now = Local::now(); + let samples = buffer(now, 50, 200); + let visible = get_visible(&samples, None); + let span = (visible.last().unwrap().when - visible.first().unwrap().when).num_milliseconds() as f64; + let ctx = ctx_for(visible.first().unwrap().when.timestamp_millis(), span, 800.0); + + let segs = decimate(&visible, &ctx, &|s: &Sample| s.voltage); + let total: usize = segs.iter().map(|s| s.len()).sum(); + assert_eq!(total, 50, "fewer samples than pixels must not be altered"); + } + + #[test] + fn gaps_break_the_polyline() { + let now = Local::now(); + let mut samples = VecDeque::new(); + for i in 0..10 { + samples.push_back(sample_at(now - Duration::seconds(100 - i), i as f32)); + } + // 30 s dropout, then data resumes. + for i in 0..10 { + samples.push_back(sample_at(now - Duration::seconds(60 - i), i as f32)); + } + let visible = get_visible(&samples, None); + let span = (visible.last().unwrap().when - visible.first().unwrap().when).num_milliseconds() as f64; + let ctx = ctx_for(visible.first().unwrap().when.timestamp_millis(), span, 800.0); + + let segs = decimate(&visible, &ctx, &|s: &Sample| s.voltage); + assert_eq!(segs.len(), 2, "dropout must not be drawn as a straight line"); + } + + #[test] + fn gap_threshold_has_a_floor() { + // A 50 ms poll must not break the trace on ordinary BLE jitter. + assert_eq!(gap_threshold_ms(50), GAP_FLOOR_MS); + assert_eq!(gap_threshold_ms(1_000), 5_000); + } + + #[test] + fn offset_labels_are_compact() { + assert_eq!(fmt_offset(0), "0"); + assert_eq!(fmt_offset(45), "-45s"); + assert_eq!(fmt_offset(125), "-2m05s"); + assert_eq!(fmt_offset(3_720), "-1h02m"); + } +} diff --git a/el15-app/src/gui.rs b/el15-app/src/gui.rs index 09f3470..0916897 100644 --- a/el15-app/src/gui.rs +++ b/el15-app/src/gui.rs @@ -40,7 +40,10 @@ use crate::cli::Cli; use crate::i18n::{self, t}; use crate::settings::{self, GraphLayout, GraphTimeMode, ModeKind, Settings, Theme as AppTheme}; -const MAX_SAMPLES: usize = 7200; +/// Initial allocation for the shared sample buffer. The buffer may grow to +/// `settings::sample_capacity()`, which at a long retention is far larger than +/// most sessions need — preallocating that up front would waste tens of MB. +const SAMPLES_PREALLOC: usize = 8_192; // ---- colors (EL15 device palette) --------------------------------------- pub const COLOR_VOLTAGE: Color = Color::from_rgb(0.20, 0.85, 0.35); // green @@ -60,7 +63,11 @@ pub struct Sample { pub resistance: f32, pub temperature: f32, pub runtime_s: u32, - pub mode: String, + /// Mode name as reported by the device. `Arc` rather than `String`: + /// the buffer now holds hundreds of thousands of samples and the mode name + /// is identical across nearly all of them, so consecutive samples share one + /// allocation instead of allocating on every status packet. + pub mode: Arc, pub load_on: bool, } @@ -157,6 +164,8 @@ pub enum Message { ToggleGraphTimeMode, GraphTimeWindowChanged(String), ApplyGraphTimeWindow, + GraphRetentionChanged(String), + ApplyGraphRetention, ClearGraph, WindowResized(f32, f32), OpenRepo, @@ -217,6 +226,10 @@ pub struct AppState { graph_cache: Cache, chart_height: f32, graph_time_input: String, + graph_retention_input: String, + /// Epoch set by the graph's **Clear** button: samples older than this are + /// hidden from the graph but kept in the buffer for CSV export. Set here + /// and nowhere else, so nothing but Clear can hide recorded data. graph_start_time: Option>, cells_combo_state: combo_box::State, @@ -281,6 +294,7 @@ impl AppState { fn new(args: Cli, settings: Settings) -> (Self, Task) { let setpoint_default = format_setpoint(settings.last_mode, &settings.defaults); let time_window_str = settings.graph.time_window_s.to_string(); + let retention_str = settings.graph.history_retention_s.to_string(); // Initialize the global event channel + device slot. let (tx, rx) = unbounded_channel(); let _ = GLOBAL_TX.set(tx); @@ -295,7 +309,7 @@ impl AppState { device: None, connecting: false, last_status: None, - samples: VecDeque::with_capacity(MAX_SAMPLES), + samples: VecDeque::with_capacity(SAMPLES_PREALLOC), setpoint_input: setpoint_default, last_command_ok: true, show_settings: false, @@ -305,6 +319,7 @@ impl AppState { graph_cache: Cache::new(), chart_height: 160.0, graph_time_input: time_window_str, + graph_retention_input: retention_str, graph_start_time: None, cells_combo_state: combo_box::State::new((1u8..=20).map(|n| n.to_string()).collect()), show_flash_page: false, @@ -359,6 +374,40 @@ impl AppState { format!("{} v{}", t!("app.title"), env!("CARGO_PKG_VERSION")) } + /// Upper bound on the shared sample buffer at the current poll rate. + fn sample_capacity(&self) -> usize { + settings::sample_capacity( + self.settings.poll_interval_ms, + self.settings.graph.history_retention_s, + ) + } + + /// Drop samples that have aged out of the retention window, so the buffer + /// stays bounded before the next sample is appended. + /// + /// This is the *only* place samples expire on their own. Retention bounds + /// the graph and CSV export together: the two read the same buffer, so the + /// graph can never show something an export would miss, or vice versa. + fn trim_samples(&mut self, now: DateTime) { + let cutoff = now - chrono::Duration::seconds(self.settings.graph.history_retention_s as i64); + while self.samples.front().is_some_and(|s| s.when < cutoff) { + self.samples.pop_front(); + } + // Memory backstop, in case the device reports faster than the poll rate. + let cap = self.sample_capacity(); + while self.samples.len() >= cap { + self.samples.pop_front(); + } + } + + /// Wall-clock span currently held in the buffer, in seconds. + fn buffered_span_s(&self) -> i64 { + match (self.samples.front(), self.samples.back()) { + (Some(first), Some(last)) => (last.when - first.when).num_seconds().max(0), + _ => 0, + } + } + fn theme(&self) -> Theme { match self.settings.theme { AppTheme::Light => Theme::Light, @@ -370,6 +419,12 @@ impl AppState { debug!("gui msg: {:?}", msg); match msg { Message::Tick => { + // Roll's time domain is anchored to "now", so the window has to + // keep scrolling even while no samples are arriving. Infinite's + // domain is the data itself and only changes when data does. + if self.settings.graph.time_mode == GraphTimeMode::Roll { + self.graph_cache.clear(); + } // ---- Mode-switch timeout & retry guard -------------------------- if let Some((target, started)) = self.pending_mode_switch { let elapsed = started.elapsed(); @@ -660,7 +715,13 @@ impl AppState { f32::INFINITY }; if !self.settings.logging_paused { - if self.samples.len() == MAX_SAMPLES { self.samples.pop_front(); } + self.trim_samples(now); + // Reuse the previous sample's mode allocation — the name + // only changes on an actual mode switch. + let mode = match self.samples.back() { + Some(prev) if *prev.mode == *st.mode_name => Arc::clone(&prev.mode), + _ => Arc::from(st.mode_name.as_str()), + }; self.samples.push_back(Sample { when: now, voltage: st.voltage, @@ -669,7 +730,7 @@ impl AppState { resistance, temperature: st.temperature, runtime_s: st.runtime_s, - mode: st.mode_name.clone(), + mode, load_on: st.load_on, }); self.graph_cache.clear(); @@ -969,6 +1030,9 @@ impl AppState { Message::ExportDone(Err(e)) => warn!("CSV export failed: {e}"), Message::ClearSamples => { self.samples.clear(); + // The buffer is gone, so a view epoch into it is meaningless — + // drop it rather than let it outlive the data it referred to. + self.graph_start_time = None; self.graph_cache.clear(); } Message::ToggleAutoConnect => { @@ -1040,6 +1104,8 @@ impl AppState { self.graph_cache.clear(); } Message::ToggleGraphTimeMode => { + // Deliberately a view-only switch: it must never touch + // `self.samples`, so toggling mid-run cannot lose data. self.settings.graph.time_mode = match self.settings.graph.time_mode { GraphTimeMode::Roll => GraphTimeMode::Infinite, GraphTimeMode::Infinite => GraphTimeMode::Roll, @@ -1052,14 +1118,39 @@ impl AppState { } Message::ApplyGraphTimeWindow => { if let Ok(secs) = self.graph_time_input.parse::() { - let secs = secs.clamp(5, 86400); + // A window wider than retention can never be filled, so + // clamp to what the buffer can actually hold. + let max_window = self.settings.graph.history_retention_s.max(5); + let secs = secs.clamp(5, max_window); self.settings.graph.time_window_s = secs; self.graph_time_input = secs.to_string(); let _ = settings::save(&self.settings); self.graph_cache.clear(); } } + Message::GraphRetentionChanged(v) => { + self.graph_retention_input = v; + } + Message::ApplyGraphRetention => { + if let Ok(secs) = self.graph_retention_input.parse::() { + let secs = secs.clamp(60, 86_400); + self.settings.graph.history_retention_s = secs; + self.graph_retention_input = secs.to_string(); + // Keep the roll window inside the new retention. + if self.settings.graph.time_window_s > secs { + self.settings.graph.time_window_s = secs; + self.graph_time_input = secs.to_string(); + } + // Shrinking retention takes effect immediately. + self.trim_samples(Local::now()); + let _ = settings::save(&self.settings); + self.graph_cache.clear(); + } + } Message::ClearGraph => { + // The one and only way to drop data out of the graph's view. + // The buffer is left intact, so a CSV export still contains + // everything recorded before the clear. self.graph_start_time = Some(Local::now()); self.graph_cache.clear(); } @@ -1092,6 +1183,7 @@ impl AppState { } Message::CapRecordClear => { self.samples.clear(); + self.graph_start_time = None; self.graph_cache.clear(); } Message::CapChemistryChanged(v) => { @@ -1293,7 +1385,7 @@ impl AppState { let status_bar = container( row![ badge( - &format!("{}: {}", t!("label.bluetooth"), &conn_label), + &format!("{}: {}", t!("label.bluetooth"), conn_label), conn_color, ), Space::new().width(12.0), @@ -1413,6 +1505,7 @@ impl AppState { graph_settings.time_mode, graph_settings.time_window_s, self.graph_start_time, + self.settings.poll_interval_ms, ); let v_toggle = toggle_btn("V", graph_settings.show_voltage, Message::ToggleGraphVoltage, COLOR_VOLTAGE); let i_toggle = toggle_btn("I", graph_settings.show_current, Message::ToggleGraphCurrent, COLOR_CURRENT); @@ -1445,12 +1538,24 @@ impl AppState { button(text(t!("btn.set")).size(11)).padding([2, 6]).on_press(Message::ApplyGraphTimeWindow) ); } - if graph_settings.time_mode == settings::GraphTimeMode::Infinite { - time_controls = time_controls.push(Space::new().width(6.0)); - time_controls = time_controls.push( - button(text(t!("graph.clear")).size(11)).padding([2, 6]).on_press(Message::ClearGraph) - ); - } + // Clear is available in both modes: it sets one view epoch that both + // modes honour, so its effect no longer depends on which mode + // happened to be active when it was pressed. + time_controls = time_controls.push(Space::new().width(6.0)); + time_controls = time_controls.push( + button(text(t!("graph.clear")).size(11)).padding([2, 6]).on_press(Message::ClearGraph) + ); + // How much history actually exists, so a window larger than the + // buffer is visibly explained rather than silently ignored. + time_controls = time_controls.push(Space::new().width(8.0)); + time_controls = time_controls.push( + text(format!( + "{}: {}", + t!("graph.buffer"), + fmt_duration_short(self.buffered_span_s()) + )) + .size(11), + ); let resize_row = row![ v_toggle, i_toggle, @@ -2059,6 +2164,12 @@ impl AppState { .padding([4, 12]) .on_press(Message::ToggleAutoConnect); + let retention_input = text_input("86400", &self.graph_retention_input) + .width(Length::Fixed(70.0)) + .on_input(Message::GraphRetentionChanged) + .on_submit(Message::ApplyGraphRetention) + .size(12); + let app_card = container( column![ text(t!("settings.card.application")).size(15), @@ -2085,6 +2196,19 @@ impl AppState { Space::new().width(Length::Fill), auto_connect_toggle, ].align_y(iced::Alignment::Center), + row![ + column![ + text(t!("settings.retention")).size(13), + text(t!("settings.retention_hint")).size(11), + ].spacing(2).width(Length::Fill), + retention_input, + Space::new().width(4.0), + text("s").size(12), + Space::new().width(6.0), + button(text(t!("btn.set")).size(12)) + .padding([4, 10]) + .on_press(Message::ApplyGraphRetention), + ].align_y(iced::Alignment::Center), ] .spacing(8), ) @@ -2368,6 +2492,18 @@ fn mode_btn_tip<'a>( .into() } +/// Compact duration for UI labels: `2h05m`, `12m34s`, `45s`. +fn fmt_duration_short(secs: i64) -> String { + let s = secs.max(0); + if s >= 3600 { + format!("{}h{:02}m", s / 3600, (s % 3600) / 60) + } else if s >= 60 { + format!("{}m{:02}s", s / 60, s % 60) + } else { + format!("{s}s") + } +} + fn samples_summary(samples: &VecDeque) -> String { if samples.is_empty() { return "(no samples yet — connect a device)".to_string(); @@ -2498,7 +2634,7 @@ fn write_csv(path: &std::path::Path, samples: &[Sample]) -> Result<()> { r, format!("{:.2}", s.temperature), s.runtime_s.to_string(), - s.mode.clone(), + s.mode.to_string(), if s.load_on { "1".to_string() } else { "0".to_string() }, ])?; } @@ -2524,6 +2660,7 @@ mod tests { let settings = Settings::default(); let setpoint_default = format_setpoint(settings.last_mode, &settings.defaults); let time_window_str = settings.graph.time_window_s.to_string(); + let retention_str = settings.graph.history_retention_s.to_string(); AppState { args: Cli { no_gui: false, @@ -2549,7 +2686,7 @@ mod tests { device: None, connecting: false, last_status: None, - samples: VecDeque::with_capacity(MAX_SAMPLES), + samples: VecDeque::with_capacity(SAMPLES_PREALLOC), setpoint_input: setpoint_default, last_command_ok: true, show_settings: false, @@ -2559,6 +2696,7 @@ mod tests { graph_cache: Cache::new(), chart_height: 160.0, graph_time_input: time_window_str, + graph_retention_input: retention_str, graph_start_time: None, cells_combo_state: combo_box::State::new( (1u8..=20).map(|n| n.to_string()).collect(), @@ -2660,7 +2798,7 @@ mod tests { resistance: 12.0, temperature: 25.0, runtime_s: 0, - mode: "CC".to_string(), + mode: "CC".into(), load_on: true, }); assert!(!state.samples.is_empty()); @@ -2668,6 +2806,176 @@ mod tests { assert!(state.samples.is_empty()); } + fn sample_at(when: DateTime) -> Sample { + Sample { + when, + voltage: 12.0, + current: 1.0, + power: 12.0, + resistance: 12.0, + temperature: 25.0, + runtime_s: 0, + mode: "CC".into(), + load_on: true, + } + } + + /// Fill the buffer with `n` samples ending now, one second apart. + fn fill(state: &mut AppState, n: usize) { + let now = Local::now(); + for i in 0..n { + state + .samples + .push_back(sample_at(now - chrono::Duration::seconds((n - i) as i64))); + } + } + + #[test] + fn toggle_time_mode_never_touches_the_buffer() { + let mut state = test_state(); + fill(&mut state, 500); + let before = state.samples.len(); + let first = state.samples.front().unwrap().when; + + // Toggle back and forth mid-"run". + for _ in 0..4 { + let _ = state.update(Message::ToggleGraphTimeMode); + assert_eq!(state.samples.len(), before); + assert_eq!(state.samples.front().unwrap().when, first); + // And it must not smuggle in a clear epoch either. + assert!(state.graph_start_time.is_none()); + } + } + + #[test] + fn changing_the_window_never_touches_the_buffer() { + let mut state = test_state(); + fill(&mut state, 500); + let before = state.samples.len(); + + state.graph_time_input = "600".to_string(); + let _ = state.update(Message::ApplyGraphTimeWindow); + assert_eq!(state.samples.len(), before); + assert!(state.graph_start_time.is_none()); + } + + /// Clear affects the graph's view only — the buffer behind CSV export is + /// untouched, so an export after a clear still contains the whole run. + #[test] + fn clear_graph_hides_data_but_keeps_it_exportable() { + let mut state = test_state(); + fill(&mut state, 500); + let before = state.samples.len(); + + let _ = state.update(Message::ClearGraph); + assert!(state.graph_start_time.is_some()); + assert_eq!(state.samples.len(), before); + } + + /// Clearing the buffer must also drop the view epoch, so no stale cutoff + /// outlives the data it referred to. + #[test] + fn clearing_samples_resets_the_graph_epoch() { + let mut state = test_state(); + fill(&mut state, 10); + let _ = state.update(Message::ClearGraph); + assert!(state.graph_start_time.is_some()); + + let _ = state.update(Message::ClearSamples); + assert!(state.graph_start_time.is_none()); + + let _ = state.update(Message::ClearGraph); + let _ = state.update(Message::CapRecordClear); + assert!(state.graph_start_time.is_none()); + } + + #[test] + fn window_is_clamped_to_retention() { + let mut state = test_state(); + state.settings.graph.history_retention_s = 600; + + // The old code clamped to 86400 regardless of how much was buffered. + state.graph_time_input = "86400".to_string(); + let _ = state.update(Message::ApplyGraphTimeWindow); + assert_eq!(state.settings.graph.time_window_s, 600); + assert_eq!(state.graph_time_input, "600"); + + state.graph_time_input = "1".to_string(); + let _ = state.update(Message::ApplyGraphTimeWindow); + assert_eq!(state.settings.graph.time_window_s, 5); + } + + #[test] + fn shrinking_retention_pulls_the_window_in_with_it() { + let mut state = test_state(); + state.settings.graph.time_window_s = 3600; + + state.graph_retention_input = "300".to_string(); + let _ = state.update(Message::ApplyGraphRetention); + assert_eq!(state.settings.graph.history_retention_s, 300); + assert_eq!(state.settings.graph.time_window_s, 300); + assert_eq!(state.graph_time_input, "300"); + } + + #[test] + fn trim_drops_only_samples_past_retention() { + let mut state = test_state(); + state.settings.graph.history_retention_s = 60; + let now = Local::now(); + // One well inside retention, one well outside it. + state.samples.push_back(sample_at(now - chrono::Duration::seconds(600))); + state.samples.push_back(sample_at(now - chrono::Duration::seconds(10))); + + state.trim_samples(now); + assert_eq!(state.samples.len(), 1); + assert!((now - state.samples.front().unwrap().when).num_seconds() < 60); + } + + #[test] + fn buffer_capacity_follows_poll_rate_and_retention() { + // 1 h at 5 Hz — the old fixed 7200 cap held only ~24 min of this. + assert_eq!(settings::sample_capacity(200, 3600), 18_000); + // Slow poll, short retention: floored so the graph still has data. + assert_eq!(settings::sample_capacity(2000, 60), settings::MIN_BUFFERED_SAMPLES); + // A poll slower than 1 s must not truncate to a zero sample rate. + assert_eq!(settings::sample_capacity(2000, 86_400), 43_200); + // Very fast poll, long retention: capped for memory. + assert_eq!(settings::sample_capacity(50, 86_400), settings::MAX_BUFFERED_SAMPLES); + } + + /// The 24 h default exists for long CAP runs (car battery discharge), so the + /// memory backstop must not quietly cut it short at the default poll rate. + #[test] + fn default_retention_is_reachable_at_the_default_poll_rate() { + let s = Settings::default(); + assert_eq!(s.graph.history_retention_s, 86_400); + + let cap = settings::sample_capacity(s.poll_interval_ms, s.graph.history_retention_s); + let held_s = cap as u64 * s.poll_interval_ms / 1000; + assert_eq!(held_s, 86_400, "backstop truncates the default retention"); + assert!(cap < settings::MAX_BUFFERED_SAMPLES); + } + + /// Guards the memory budget behind `MAX_BUFFERED_SAMPLES`: at 64 bytes a + /// sample the full buffer is ~32 MB. Adding an owned `String` here would + /// silently multiply that, which is why `mode` is an `Arc`. + #[test] + fn sample_stays_small() { + assert!( + std::mem::size_of::() <= 64, + "Sample grew to {} bytes", + std::mem::size_of::() + ); + } + + #[test] + fn duration_labels_are_compact() { + assert_eq!(fmt_duration_short(0), "0s"); + assert_eq!(fmt_duration_short(45), "45s"); + assert_eq!(fmt_duration_short(754), "12m34s"); + assert_eq!(fmt_duration_short(7_500), "2h05m"); + } + #[test] fn graph_layout_cycle() { let mut state = test_state(); @@ -2696,10 +3004,12 @@ mod tests { state.pending_mode_switch = Some((ModeKind::CP, std::time::Instant::now())); // Incoming status packet still reports CC — should be ignored. - let mut st = EL15Status::default(); - st.valid = true; - st.mode_byte = 0x01; // CC - st.mode_name = "CC".to_string(); + let mut st = EL15Status { + valid: true, + mode_byte: 0x01, // CC + mode_name: "CC".to_string(), + ..Default::default() + }; let _ = state.update(Message::DeviceEvent(DeviceEvent::Status(st.clone()))); // Mode must remain CP (not overridden by status). assert_eq!(state.settings.last_mode, ModeKind::CP); diff --git a/el15-app/src/settings.rs b/el15-app/src/settings.rs index 2f156b7..fc05380 100644 --- a/el15-app/src/settings.rs +++ b/el15-app/src/settings.rs @@ -54,6 +54,44 @@ pub struct GraphSettings { pub show_power: bool, pub time_mode: GraphTimeMode, pub time_window_s: u32, + /// How long raw samples are retained in the in-memory buffer, in seconds. + /// + /// That buffer is shared: the graph reads it and CSV export writes it out. + /// Retention therefore bounds both — nothing the graph can show is missing + /// from an export, and nothing exportable is hidden from the graph. + /// + /// `#[serde(default)]` is required: without it, a `settings.toml` written by + /// an older build fails to deserialize and confy silently resets *every* + /// setting to its default. + #[serde(default = "default_retention_s")] + pub history_retention_s: u32, +} + +/// 24 hours. Sized for the longest real runs — a car battery discharged in CAP +/// mode can log for the better part of a day, and a shorter default would cut +/// the head off exactly the measurement that needs the whole curve. +/// +/// At the default 200 ms poll this is 432 000 samples, roughly 28 MB. +fn default_retention_s() -> u32 { + 86_400 +} + +/// Hard upper bound on the shared sample buffer, independent of retention. +/// +/// A `Sample` is about 64 bytes, so this caps the buffer near 32 MB. It sits +/// just above 24 h at the default 200 ms poll; a faster poll hits this ceiling +/// first and retains proportionally less wall-clock time (at 50 ms, ~7 h). +pub const MAX_BUFFERED_SAMPLES: usize = 500_000; + +/// Lower bound, so a very short retention still leaves a usable graph. +pub const MIN_BUFFERED_SAMPLES: usize = 600; + +/// Number of samples to retain for `retention_s` seconds at the given poll rate. +pub fn sample_capacity(poll_interval_ms: u64, retention_s: u32) -> usize { + // Scale before dividing: at a poll slower than 1 s, a samples-per-second + // rate would truncate to zero. + let wanted = (retention_s as u64).saturating_mul(1000) / poll_interval_ms.max(1); + (wanted as usize).clamp(MIN_BUFFERED_SAMPLES, MAX_BUFFERED_SAMPLES) } impl Default for GraphSettings { @@ -65,6 +103,7 @@ impl Default for GraphSettings { show_power: true, time_mode: GraphTimeMode::Roll, time_window_s: 60, + history_retention_s: default_retention_s(), } } }