From 77e593e664ba71d0b150d683f5d0afbe3d30c251 Mon Sep 17 00:00:00 2001 From: Zachary Friss Date: Wed, 22 Apr 2026 12:59:41 -0400 Subject: [PATCH 1/6] Add async loading and performance caches --- .github/workflows/pr-verify.yml | 24 + Cargo.lock | 287 +++++++ crates/i3rs-app/Cargo.toml | 7 + crates/i3rs-app/src/app.rs | 541 +++++++++++-- crates/i3rs-app/src/background_jobs.rs | 590 ++++++++++++++ crates/i3rs-app/src/default_layouts.rs | 62 +- crates/i3rs-app/src/lib.rs | 2 + crates/i3rs-app/src/panels/fft.rs | 62 +- crates/i3rs-app/src/panels/gauge.rs | 8 +- crates/i3rs-app/src/panels/graph.rs | 801 +++++++++++++++++--- crates/i3rs-app/src/panels/histogram.rs | 10 +- crates/i3rs-app/src/panels/math_editor.rs | 253 +++++-- crates/i3rs-app/src/panels/mixture_map.rs | 23 +- crates/i3rs-app/src/panels/report.rs | 20 +- crates/i3rs-app/src/panels/scatter.rs | 22 +- crates/i3rs-app/src/panels/track_map.rs | 401 ++++++++-- crates/i3rs-app/src/panels/utils.rs | 85 ++- crates/i3rs-app/src/perf_metrics.rs | 115 +++ crates/i3rs-app/src/platform.rs | 34 +- crates/i3rs-app/src/state.rs | 415 +++++++++- crates/i3rs-app/src/workspace.rs | 75 +- crates/i3rs-core/Cargo.toml | 7 + crates/i3rs-core/benches/perf_benches.rs | 131 ++++ crates/i3rs-core/src/export.rs | 50 +- crates/i3rs-core/src/ld_parser.rs | 23 + crates/i3rs-core/src/lib.rs | 2 +- crates/i3rs-core/src/math_engine.rs | 788 +++++++++++-------- crates/i3rs-core/src/track.rs | 216 +++++- crates/i3rs-core/tests/integration_tests.rs | 52 ++ docs/performance-regression.md | 67 ++ 30 files changed, 4304 insertions(+), 869 deletions(-) create mode 100644 crates/i3rs-app/src/background_jobs.rs create mode 100644 crates/i3rs-app/src/perf_metrics.rs create mode 100644 crates/i3rs-core/benches/perf_benches.rs create mode 100644 docs/performance-regression.md diff --git a/.github/workflows/pr-verify.yml b/.github/workflows/pr-verify.yml index c9d8984..8d4f45d 100644 --- a/.github/workflows/pr-verify.yml +++ b/.github/workflows/pr-verify.yml @@ -34,6 +34,30 @@ jobs: run: | cargo publish -p i3rs-core --dry-run + perf-benches: + name: Perf Benches (report only) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + lfs: true + + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 + + - name: Run perf benches + run: cargo bench -p i3rs-core --bench perf_benches -- --output-format bencher | tee perf-bench-output.txt + + - name: Upload perf bench report + if: always() + uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4 + with: + name: perf-bench-output + path: | + perf-bench-output.txt + target/criterion + build-matrix: name: Build (${{ matrix.name }}) runs-on: ${{ matrix.os }} diff --git a/Cargo.lock b/Cargo.lock index bd72aaa..6fce0f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -133,6 +133,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -173,6 +182,18 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + [[package]] name = "anyhow" version = "1.0.102" @@ -553,6 +574,12 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.2.59" @@ -586,6 +613,58 @@ dependencies = [ "libc", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "clipboard-win" version = "5.4.1" @@ -693,6 +772,70 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -930,6 +1073,12 @@ dependencies = [ "emath", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "emath" version = "0.34.1" @@ -1177,6 +1326,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + [[package]] name = "futures-core" version = "0.3.32" @@ -1278,6 +1436,18 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "glow" version = "0.17.0" @@ -1450,13 +1620,16 @@ checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" name = "i3rs-app" version = "0.1.1" dependencies = [ + "crossbeam-channel", "eframe", "egui", "egui_dock", "egui_plot", + "gloo-timers", "i3rs-core", "js-sys", "log", + "pollster", "rfd", "serde", "serde_json", @@ -1476,6 +1649,7 @@ dependencies = [ name = "i3rs-core" version = "0.1.1" dependencies = [ + "criterion", "half", "memmap2", "quick-xml 0.39.2", @@ -1618,6 +1792,26 @@ dependencies = [ "serde_core", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -2289,6 +2483,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "orbclient" version = "0.3.51" @@ -2473,6 +2673,34 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "png" version = "0.18.1" @@ -2667,6 +2895,26 @@ dependencies = [ "objc2-quartz-core 0.3.2", ] +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "read-fonts" version = "0.37.0" @@ -2704,6 +2952,35 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "renderdoc-sys" version = "1.1.0" @@ -3220,6 +3497,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" diff --git a/crates/i3rs-app/Cargo.toml b/crates/i3rs-app/Cargo.toml index bc1cb67..4e0743f 100644 --- a/crates/i3rs-app/Cargo.toml +++ b/crates/i3rs-app/Cargo.toml @@ -12,6 +12,10 @@ publish = false [lib] crate-type = ["cdylib", "rlib"] +[features] +default = [] +perf_metrics = [] + [dependencies] i3rs-core = { workspace = true } eframe = { workspace = true } @@ -21,10 +25,13 @@ egui-dock = { workspace = true } rfd = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +crossbeam-channel = "0.5.15" +pollster = "0.4.0" log = "0.4.29" js-sys = "0.3.94" [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen = "0.2.117" wasm-bindgen-futures = "0.4.67" +gloo-timers = { version = "0.3.0", features = ["futures"] } web-sys = { version = "0.3.94", features = ["Document", "Element", "HtmlCanvasElement", "Storage", "Window"] } diff --git a/crates/i3rs-app/src/app.rs b/crates/i3rs-app/src/app.rs index f623ef4..acc8178 100644 --- a/crates/i3rs-app/src/app.rs +++ b/crates/i3rs-app/src/app.rs @@ -2,13 +2,17 @@ use eframe::egui; use egui_dock::{DockArea, DockState}; -use i3rs_core::{ExportChannel, LdFile, LdxFile, detect_laps, export_csv, find_ldx_for_ld}; +use i3rs_core::{ExportChannel, LdFile, LdxFile, detect_laps, export_csv}; #[cfg(target_arch = "wasm32")] use std::any::Any; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; +use crate::background_jobs::{ + BackgroundJobs, JobRequest, JobResult, LoadSessionSource, LoadedSession, + load_session_from_bytes, load_session_from_path, +}; use crate::panels::fft::FftPanel; use crate::panels::gauge::GaugePanel; use crate::panels::graph::GraphPanel; @@ -59,6 +63,12 @@ pub struct LoadedSessionSummary { pub lap_count: usize, } +struct PendingSessionLoad { + request_id: u64, + workspace_snapshot: Option, + file_label: String, +} + fn is_startup_default_layout(worksheets: &[Worksheet]) -> bool { if worksheets.len() != 1 { return false; @@ -125,6 +135,15 @@ pub struct App { compare_session_path: Option, session_summary_cache: HashMap, load_error: Option, + background_jobs: BackgroundJobs, + pending_session_load: Option, + pending_workspace_restore: Option, + next_session_id: u64, + next_request_id: u64, + #[cfg(not(target_arch = "wasm32"))] + native_pick_tx: std::sync::mpsc::Sender, + #[cfg(not(target_arch = "wasm32"))] + native_pick_rx: std::sync::mpsc::Receiver, #[cfg(target_arch = "wasm32")] web_load_tx: std::sync::mpsc::Sender, #[cfg(target_arch = "wasm32")] @@ -135,6 +154,8 @@ impl App { pub fn new(cc: &eframe::CreationContext) -> Self { #[cfg(target_arch = "wasm32")] let (web_load_tx, web_load_rx) = crate::platform::web_load_channel(); + #[cfg(not(target_arch = "wasm32"))] + let (native_pick_tx, native_pick_rx) = crate::platform::native_pick_channel(); let preferences = crate::preferences::load_preferences(); let mut shared = SharedState::new(); shared.channel_preferences = preferences.channel_preferences.clone(); @@ -163,6 +184,15 @@ impl App { compare_session_path: None, session_summary_cache: HashMap::new(), load_error: None, + background_jobs: BackgroundJobs::new(), + pending_session_load: None, + pending_workspace_restore: None, + next_session_id: 1, + next_request_id: 1, + #[cfg(not(target_arch = "wasm32"))] + native_pick_tx, + #[cfg(not(target_arch = "wasm32"))] + native_pick_rx, #[cfg(target_arch = "wasm32")] web_load_tx, #[cfg(target_arch = "wasm32")] @@ -230,21 +260,27 @@ impl App { self.active_worksheet = active_worksheet.min(self.worksheets.len().saturating_sub(1)); } + fn restore_workspace_when_ready(&mut self, workspace: crate::workspace::WorkspaceFile) { + if self.shared.are_math_channels_settled() { + self.apply_workspace_snapshot(workspace); + } else { + self.pending_workspace_restore = Some(workspace); + } + } + + fn maybe_apply_pending_workspace_restore(&mut self) { + if self.shared.are_math_channels_settled() + && let Some(workspace) = self.pending_workspace_restore.take() + { + self.apply_workspace_snapshot(workspace); + } + } + pub fn open_file(&mut self, path: PathBuf) { let workspace_snapshot = self.workspace_snapshot_for_session_reload(); - let file_name = path - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_default(); - let ldx = find_ldx_for_ld(&path); - match LdFile::open(&path) { - Ok(ld) => { - self.load_session(ld, file_name, Some(path), ldx); - if let Some(workspace) = workspace_snapshot { - self.apply_workspace_snapshot(workspace); - } - } - Err(e) => self.load_error = Some(format!("Failed to open file: {e}")), + match load_session_from_path(path) { + Ok(loaded) => self.install_loaded_session(loaded, workspace_snapshot), + Err(err) => self.load_error = Some(format!("Failed to open file: {err}")), } } @@ -255,35 +291,33 @@ impl App { ldx: Option, ) -> Result<(), String> { let workspace_snapshot = self.workspace_snapshot_for_session_reload(); - let ld = LdFile::from_bytes(bytes)?; - self.load_session(ld, file_name, None, ldx); - if let Some(workspace) = workspace_snapshot { - self.apply_workspace_snapshot(workspace); - } + let loaded = load_session_from_bytes(file_name, bytes, ldx)?; + self.install_loaded_session(loaded, workspace_snapshot); Ok(()) } - fn load_session( + fn install_loaded_session( &mut self, - ld: LdFile, - file_name: String, - ld_path: Option, - ldx: Option, + loaded: LoadedSession, + workspace_snapshot: Option, ) { self.load_error = None; - self.shared.file_name = file_name; - self.shared.ldx = ldx; - - let ld = Arc::new(ld); - self.shared.laps = detect_laps(&ld); - let data_duration = ld.duration_secs(); - self.shared.data_duration = Some(data_duration); + self.pending_session_load = None; + self.pending_workspace_restore = None; + self.shared.session_id = self.next_session_id; + self.next_session_id += 1; + self.shared.file_name = loaded.file_name; + self.shared.ldx = loaded.ldx; + + let ld = Arc::new(loaded.ld); + self.shared.laps = loaded.laps; + self.shared.data_duration = Some(loaded.data_duration); self.shared.ld_file = Some(ld); - self.shared.ld_path = ld_path; + self.shared.ld_path = loaded.ld_path; self.shared.selected_lap = None; self.shared.cursor_time = None; - self.shared.zoom_range = Some((0.0, data_duration)); - self.shared.invalidate_derived_caches(); + self.shared.zoom_range = Some((0.0, loaded.data_duration)); + self.shared.invalidate_session_caches(); // Clear all panels' channels and caches across all worksheets for ws in &mut self.worksheets { @@ -312,8 +346,10 @@ impl App { if is_empty_default { let ld_ref = self.shared.ld_file.clone().unwrap(); - let defaults = - crate::default_layouts::create_default_worksheets(&ld_ref, &mut self.shared); + let defaults = { + let _perf = crate::perf_metrics::scope("default layout creation"); + crate::default_layouts::create_default_worksheets(&ld_ref, &mut self.shared) + }; if !defaults.is_empty() { self.worksheets = defaults .into_iter() @@ -323,11 +359,67 @@ impl App { } } + if let Some(workspace) = workspace_snapshot { + self.restore_workspace_when_ready(workspace); + } + if let Some(path) = self.shared.ld_path.clone() { self.register_project_session(&path); } } + fn submit_load_session( + &mut self, + source: LoadSessionSource, + file_label: String, + ctx: &egui::Context, + ) { + let request_id = self.next_request_id; + self.next_request_id += 1; + self.load_error = None; + self.pending_workspace_restore = None; + self.pending_session_load = Some(PendingSessionLoad { + request_id, + workspace_snapshot: self.workspace_snapshot_for_session_reload(), + file_label, + }); + + if let Err(err) = self + .background_jobs + .submit(JobRequest::LoadSession { request_id, source }, ctx) + { + self.pending_session_load = None; + self.load_error = Some(err); + } + } + + fn open_file_async(&mut self, path: PathBuf, ctx: &egui::Context) { + let file_label = path + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| path.to_string_lossy().to_string()); + self.submit_load_session(LoadSessionSource::Path(path), file_label, ctx); + } + + #[cfg(target_arch = "wasm32")] + fn open_bytes_async( + &mut self, + file_name: String, + bytes: Vec, + ldx: Option, + ctx: &egui::Context, + ) { + self.submit_load_session( + LoadSessionSource::Bytes { + file_name: file_name.clone(), + bytes, + ldx, + }, + file_name, + ctx, + ); + } + fn start_open_session(&mut self, ctx: &egui::Context) { #[cfg(target_arch = "wasm32")] { @@ -336,13 +428,20 @@ impl App { #[cfg(not(target_arch = "wasm32"))] { - if let Some(path) = crate::platform::begin_pick_session(ctx) { - self.open_file(path); - } + crate::platform::begin_pick_session(self.native_pick_tx.clone(), ctx.clone()); } } - fn process_platform_events(&mut self, _ctx: &egui::Context) { + fn process_platform_events(&mut self, ctx: &egui::Context) { + #[cfg(not(target_arch = "wasm32"))] + while let Ok(event) = self.native_pick_rx.try_recv() { + match event { + crate::platform::NativePickEvent::SessionPath(path) => { + self.open_file_async(path, ctx); + } + } + } + #[cfg(target_arch = "wasm32")] while let Ok(event) = self.web_load_rx.try_recv() { match event { @@ -365,9 +464,8 @@ impl App { None => None, }; - if let Err(err) = self.open_bytes(file_name, ld_bytes, ldx) { - self.load_error = Some(format!("Failed to open file: {err}")); - } else if let Some(err) = ignored_ldx_error { + self.open_bytes_async(file_name, ld_bytes, ldx, ctx); + if let Some(err) = ignored_ldx_error { self.load_error = Some(err); } } @@ -376,9 +474,217 @@ impl App { } } } + + while let Some(result) = self.background_jobs.try_recv() { + match result { + JobResult::LoadSession { request_id, result } => { + let Some(pending) = self.pending_session_load.take() else { + continue; + }; + if pending.request_id != request_id { + self.pending_session_load = Some(pending); + continue; + } + + match result { + Ok(loaded) => { + self.install_loaded_session(loaded, pending.workspace_snapshot); + } + Err(err) => { + self.load_error = Some(format!("Failed to open file: {err}")); + } + } + } + JobResult::DecodePhysicalChannel { + session_id, result, .. + } => { + if self.shared.session_id != session_id { + continue; + } + + match result { + Ok(decoded) => { + self.shared.store_decoded_physical_channel( + decoded.channel_idx, + decoded.data, + decoded.stats, + decoded.freq, + ); + if !self.shared.math_channels.is_empty() { + math_editor::evaluate_all_math_channels(&mut self.shared); + } + self.maybe_apply_pending_workspace_restore(); + } + Err(err) => { + self.load_error = Some(format!("Failed to decode channel: {err}")); + } + } + } + JobResult::BuildTrackData { + session_id, + track_data, + } => { + if self.shared.session_id != session_id { + continue; + } + self.shared.store_track_data(track_data); + } + JobResult::EvaluateMathChannel { + session_id, + math_id, + expression, + result, + } => { + if self.shared.session_id != session_id { + continue; + } + self.shared.complete_math_channel_evaluation(math_id); + if math_editor::apply_math_evaluation_result( + &mut self.shared, + math_id, + &expression, + result, + ) { + self.shared.invalidate_derived_caches(); + if !self.shared.math_channels.is_empty() { + math_editor::evaluate_all_math_channels(&mut self.shared); + } + } + self.maybe_apply_pending_workspace_restore(); + } + JobResult::BuildDownsampledSeries { + session_id, + key, + points, + } => { + if self.shared.session_id != session_id { + continue; + } + self.shared.store_downsampled_series(key, points); + } + } + } + } + + fn submit_requested_channel_decodes(&mut self, ctx: &egui::Context) { + let Some(ld) = self.shared.ld_file.clone() else { + return; + }; + + for channel_idx in self.shared.take_requested_physical_channel_decodes() { + let request_id = self.next_request_id; + self.next_request_id += 1; + + if let Err(err) = self.background_jobs.submit( + JobRequest::DecodePhysicalChannel { + request_id, + session_id: self.shared.session_id, + ld: ld.clone(), + channel_idx, + }, + ctx, + ) { + self.shared.cancel_physical_channel_decode(channel_idx); + self.load_error = Some(err); + } + } + } + + fn submit_requested_math_channel_evaluations(&mut self, ctx: &egui::Context) { + let requested = self.shared.take_requested_math_channel_evaluations(); + if requested.is_empty() { + return; + } + + let topo_order = math_editor::topological_eval_order(&self.shared); + let mut order_pos = HashMap::new(); + for (position, idx) in topo_order.into_iter().enumerate() { + if let Some(math_id) = self.shared.math_channels.get(idx).map(|mc| mc.id) { + order_pos.insert(math_id, position); + } + } + + let mut requested = requested; + requested.sort_by_key(|math_id| order_pos.get(math_id).copied().unwrap_or(usize::MAX)); + + for math_id in requested { + let Some(job) = math_editor::build_math_evaluation_job(&mut self.shared, math_id) + else { + self.shared.cancel_math_channel_evaluation(math_id); + continue; + }; + + let request_id = self.next_request_id; + self.next_request_id += 1; + + if let Err(err) = self.background_jobs.submit( + JobRequest::EvaluateMathChannel { + request_id, + session_id: self.shared.session_id, + math_id: job.math_id, + expression: job.expression, + aliases: job.aliases, + channel_data: job.channel_data, + }, + ctx, + ) { + self.shared.cancel_math_channel_evaluation(math_id); + self.load_error = Some(err); + } + } + } + + fn submit_requested_track_data_build(&mut self, ctx: &egui::Context) { + let Some(ld) = self.shared.ld_file.clone() else { + return; + }; + + if !self.shared.take_requested_track_data_build() { + return; + } + + let request_id = self.next_request_id; + self.next_request_id += 1; + + if let Err(err) = self.background_jobs.submit( + JobRequest::BuildTrackData { + request_id, + session_id: self.shared.session_id, + ld, + }, + ctx, + ) { + self.shared.cancel_track_data_build(); + self.load_error = Some(err); + } + } + + fn submit_requested_downsampled_series(&mut self, ctx: &egui::Context) { + for request in self.shared.take_requested_downsampled_series() { + let request_id = self.next_request_id; + self.next_request_id += 1; + + if let Err(err) = self.background_jobs.submit( + JobRequest::BuildDownsampledSeries { + request_id, + session_id: self.shared.session_id, + key: request.key.clone(), + data: request.data, + freq: request.freq, + start_sample: request.start_sample, + end_sample: request.end_sample, + target_width: request.target_width, + }, + ctx, + ) { + self.shared.cancel_downsampled_series(&request.key); + self.load_error = Some(err); + } + } } fn clear_loaded_session(&mut self) { + self.pending_workspace_restore = None; self.shared.ld_file = None; self.shared.ld_path = None; self.shared.file_name.clear(); @@ -388,7 +694,7 @@ impl App { self.shared.cursor_time = None; self.shared.zoom_range = None; self.shared.data_duration = None; - self.shared.invalidate_derived_caches(); + self.shared.invalidate_session_caches(); for ws in &mut self.worksheets { for (_path, tab) in ws.dock_state.iter_all_tabs_mut() { @@ -614,14 +920,13 @@ impl App { // Load math channels from project workspace. self.shared.math_channels.clear(); for config in &project.workspace.math_channels { - self.shared - .math_channels - .push(crate::state::MathChannelDef::new( - config.name.clone(), - config.expression.clone(), - config.unit.clone(), - config.dec_places, - )); + let math_channel = self.shared.create_math_channel_def( + config.name.clone(), + config.expression.clone(), + config.unit.clone(), + config.dec_places, + ); + self.shared.math_channels.push(math_channel); } math_editor::evaluate_all_math_channels(&mut self.shared); self.shared.invalidate_derived_caches(); @@ -630,7 +935,8 @@ impl App { let workspace = project.workspace; self.sync_project_sessions_from_workspace(&workspace); - self.apply_workspace_snapshot(workspace); + self.pending_workspace_restore = None; + self.restore_workspace_when_ready(workspace); } Err(e) => eprintln!("Failed to parse project: {}", e), }, @@ -1171,14 +1477,13 @@ impl App { // Load math channels from workspace self.shared.math_channels.clear(); for config in &workspace.math_channels { - self.shared - .math_channels - .push(crate::state::MathChannelDef::new( - config.name.clone(), - config.expression.clone(), - config.unit.clone(), - config.dec_places, - )); + let math_channel = self.shared.create_math_channel_def( + config.name.clone(), + config.expression.clone(), + config.unit.clone(), + config.dec_places, + ); + self.shared.math_channels.push(math_channel); } math_editor::evaluate_all_math_channels(&mut self.shared); self.shared.invalidate_derived_caches(); @@ -1187,7 +1492,8 @@ impl App { self.shared.reference_lap = workspace.reference_lap; self.sync_project_sessions_from_workspace(&workspace); - self.apply_workspace_snapshot(workspace); + self.pending_workspace_restore = None; + self.restore_workspace_when_ready(workspace); } Err(e) => eprintln!("Failed to parse workspace: {}", e), }, @@ -1500,7 +1806,12 @@ impl App { impl eframe::App for App { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + crate::perf_metrics::maybe_log_summary(); self.process_platform_events(ui.ctx()); + self.submit_requested_channel_decodes(ui.ctx()); + self.submit_requested_math_channel_evaluations(ui.ctx()); + self.submit_requested_track_data_build(ui.ctx()); + self.submit_requested_downsampled_series(ui.ctx()); self.handle_shortcuts(ui.ctx()); // Handle file drops @@ -1516,7 +1827,7 @@ impl eframe::App for App { }) }); if let Some(path) = dropped_path { - self.open_file(path); + self.open_file_async(path, ui.ctx()); } // Swap channel registries: move current frame's data to display buffer, @@ -1553,6 +1864,15 @@ impl eframe::App for App { self.load_error = None; } + if let Some(pending) = &self.pending_session_load { + egui::Panel::top("load_status").show_inside(ui, |ui| { + ui.horizontal_wrapped(|ui| { + ui.spinner(); + ui.label(format!("Loading {}…", pending.file_label)); + }); + }); + } + #[cfg(target_arch = "wasm32")] if self.shared.ld_file.is_none() { egui::Panel::top("web_open_hint").show_inside(ui, |ui| { @@ -1744,6 +2064,10 @@ impl eframe::App for App { self.show_channel_preferences_window(ui.ctx()); self.show_session_details_window(ui.ctx()); + self.submit_requested_channel_decodes(ui.ctx()); + self.submit_requested_math_channel_evaluations(ui.ctx()); + self.submit_requested_track_data_build(ui.ctx()); + self.submit_requested_downsampled_series(ui.ctx()); // Clear per-frame flags self.shared.zoom_from_timeline = false; @@ -1759,7 +2083,7 @@ impl eframe::App for App { mod tests { use super::*; use crate::panels::histogram::HistogramPanel; - use crate::state::{ChannelId, PlottedChannel, YAxis}; + use crate::state::{ChannelId, MathEvaluationState, PlottedChannel, YAxis}; const TEST_LD: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../test_data/VIR_LAP.ld"); @@ -1771,6 +2095,8 @@ mod tests { } fn test_app(shared: SharedState, worksheets: Vec) -> App { + #[cfg(not(target_arch = "wasm32"))] + let (native_pick_tx, native_pick_rx) = crate::platform::native_pick_channel(); App { shared, worksheets, @@ -1790,6 +2116,15 @@ mod tests { compare_session_path: None, session_summary_cache: HashMap::new(), load_error: None, + background_jobs: BackgroundJobs::new(), + pending_session_load: None, + pending_workspace_restore: None, + next_session_id: 1, + next_request_id: 1, + #[cfg(not(target_arch = "wasm32"))] + native_pick_tx, + #[cfg(not(target_arch = "wasm32"))] + native_pick_rx, #[cfg(target_arch = "wasm32")] web_load_tx: crate::platform::web_load_channel().0, #[cfg(target_arch = "wasm32")] @@ -1818,7 +2153,7 @@ mod tests { graph.plotted_channels.push(PlottedChannel { channel_id: ChannelId::Physical(0), color: egui::Color32::WHITE, - data: Arc::new(vec![1.0]), + data: Arc::from(vec![1.0]), tile_group: 0, y_axis: YAxis::Left, display_scale: 1.0, @@ -1842,7 +2177,7 @@ mod tests { graph.plotted_channels.push(PlottedChannel { channel_id: ChannelId::Physical(0), color: egui::Color32::WHITE, - data: Arc::new(vec![1.0]), + data: Arc::from(vec![1.0]), tile_group: 0, y_axis: YAxis::Left, display_scale: 1.0, @@ -1926,4 +2261,78 @@ mod tests { assert_eq!(docked_track_maps, 0); assert_eq!(app.popped_out_track_maps.len(), 1); } + + #[test] + fn workspace_restore_waits_for_math_channels_to_finish() { + let mut shared = SharedState::new(); + let mut math_channel = + shared.create_math_channel_def("Derived".into(), "1".into(), String::new(), 2); + math_channel.data = Some(Arc::from(vec![1.0])); + math_channel.freq = 1; + math_channel.evaluation_state = MathEvaluationState::Ready; + shared.math_channels.push(math_channel); + + let mut graph = GraphPanel::new(1, "Graph"); + graph.plotted_channels.push(PlottedChannel { + channel_id: ChannelId::Math(0), + color: egui::Color32::WHITE, + data: Arc::from(vec![1.0]), + tile_group: 0, + y_axis: YAxis::Left, + display_scale: 1.0, + display_offset: 0.0, + display_unit: None, + cached_min: 1.0, + cached_max: 1.0, + cached_avg: 1.0, + }); + + let worksheet = worksheet_with_tabs(vec![PanelTab::Graph(graph)]); + let mut app = test_app(shared, vec![worksheet]); + let workspace = app + .workspace_snapshot_for_session_reload() + .expect("custom layout should snapshot"); + + app.worksheets = vec![worksheet_with_tabs(vec![PanelTab::Graph(GraphPanel::new( + 2, "Empty", + ))])]; + app.shared.math_channels[0].data = None; + app.shared.math_channels[0].error = Some("Waiting for source channels...".into()); + app.shared.math_channels[0].evaluation_state = MathEvaluationState::WaitingForInputs; + + app.restore_workspace_when_ready(workspace); + + assert!(app.pending_workspace_restore.is_some()); + let pending_graph = app.worksheets[0] + .dock_state + .iter_all_tabs() + .find_map(|(_, tab)| match tab { + PanelTab::Graph(graph) => Some(graph), + _ => None, + }) + .expect("graph tab should exist"); + assert!(pending_graph.plotted_channels.is_empty()); + + app.shared.math_channels[0].data = Some(Arc::from(vec![2.0])); + app.shared.math_channels[0].freq = 1; + app.shared.math_channels[0].error = None; + app.shared.math_channels[0].evaluation_state = MathEvaluationState::Ready; + + app.maybe_apply_pending_workspace_restore(); + + assert!(app.pending_workspace_restore.is_none()); + let restored_graph = app.worksheets[0] + .dock_state + .iter_all_tabs() + .find_map(|(_, tab)| match tab { + PanelTab::Graph(graph) => Some(graph), + _ => None, + }) + .expect("graph tab should exist"); + assert_eq!(restored_graph.plotted_channels.len(), 1); + assert!(matches!( + restored_graph.plotted_channels[0].channel_id, + ChannelId::Math(0) + )); + } } diff --git a/crates/i3rs-app/src/background_jobs.rs b/crates/i3rs-app/src/background_jobs.rs new file mode 100644 index 0000000..bec4ba8 --- /dev/null +++ b/crates/i3rs-app/src/background_jobs.rs @@ -0,0 +1,590 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use crate::state::{ChannelStats, DownsampleSeriesKey, compute_channel_stats}; +use i3rs_core::{ + ChannelData, DownsampledPoint, Lap, LdFile, LdxFile, TrackData, detect_laps, downsample_minmax, + evaluate_expression_with_aliases, extract_gps_track, find_ldx_for_ld, +}; + +pub struct LoadedSession { + pub file_name: String, + pub ld_path: Option, + pub ldx: Option, + pub ld: LdFile, + pub laps: Vec, + pub data_duration: f64, +} + +pub enum LoadSessionSource { + Path(PathBuf), + Bytes { + file_name: String, + bytes: Vec, + ldx: Option, + }, +} + +#[allow(dead_code)] +pub enum JobRequest { + LoadSession { + request_id: u64, + source: LoadSessionSource, + }, + DecodePhysicalChannel { + request_id: u64, + session_id: u64, + ld: Arc, + channel_idx: usize, + }, + EvaluateMathChannel { + request_id: u64, + session_id: u64, + math_id: u64, + expression: String, + aliases: std::collections::HashMap, + channel_data: std::collections::HashMap, + }, + BuildTrackData { + request_id: u64, + session_id: u64, + ld: Arc, + }, + BuildDownsampledSeries { + request_id: u64, + session_id: u64, + key: DownsampleSeriesKey, + data: Arc<[f64]>, + freq: u16, + start_sample: usize, + end_sample: usize, + target_width: usize, + }, +} + +pub struct DecodedPhysicalChannel { + pub channel_idx: usize, + pub data: Vec, + pub stats: ChannelStats, + pub freq: u16, +} + +pub struct EvaluatedMathChannel { + pub samples: Vec, + pub freq: u16, + pub stats: ChannelStats, +} + +pub enum JobResult { + LoadSession { + request_id: u64, + result: Result, + }, + DecodePhysicalChannel { + session_id: u64, + result: Result, + }, + BuildTrackData { + session_id: u64, + track_data: Option, + }, + EvaluateMathChannel { + session_id: u64, + math_id: u64, + expression: String, + result: Result, + }, + BuildDownsampledSeries { + session_id: u64, + key: DownsampleSeriesKey, + points: Vec, + }, +} + +fn perform_load_session(source: LoadSessionSource) -> Result { + let _perf = crate::perf_metrics::scope("session open"); + match source { + LoadSessionSource::Path(path) => { + let file_name = path + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| path.to_string_lossy().to_string()); + let ldx = find_ldx_for_ld(&path); + let ld = LdFile::open(&path)?; + let data_duration = ld.duration_secs(); + let ld = std::sync::Arc::new(ld); + let laps = { + let _perf = crate::perf_metrics::scope("lap detection"); + detect_laps(&ld) + }; + + let ld = std::sync::Arc::into_inner(ld) + .ok_or_else(|| "internal error while finalizing loaded session".to_string())?; + + Ok(LoadedSession { + file_name, + ld_path: Some(path), + ldx, + ld, + laps, + data_duration, + }) + } + LoadSessionSource::Bytes { + file_name, + bytes, + ldx, + } => { + let ld = LdFile::from_bytes(bytes)?; + let data_duration = ld.duration_secs(); + let ld = std::sync::Arc::new(ld); + let laps = { + let _perf = crate::perf_metrics::scope("lap detection"); + detect_laps(&ld) + }; + + let ld = std::sync::Arc::into_inner(ld) + .ok_or_else(|| "internal error while finalizing loaded session".to_string())?; + + Ok(LoadedSession { + file_name, + ld_path: None, + ldx, + ld, + laps, + data_duration, + }) + } + } +} + +fn perform_decode_physical_channel( + ld: Arc, + channel_idx: usize, +) -> Result { + let channel = ld + .channels + .get(channel_idx) + .ok_or_else(|| format!("channel index {channel_idx} out of range"))?; + let _perf = crate::perf_metrics::scope("channel decode"); + let data = ld + .read_channel_data(channel) + .ok_or_else(|| format!("failed to decode channel {}", channel.name))?; + let stats = compute_channel_stats(&data); + Ok(DecodedPhysicalChannel { + channel_idx, + data, + stats, + freq: channel.freq, + }) +} + +fn perform_build_track_data(ld: Arc) -> Option { + let _perf = crate::perf_metrics::scope("track-map draw"); + extract_gps_track(&ld) +} + +fn perform_evaluate_math_channel( + expression: String, + channel_data: std::collections::HashMap, + aliases: std::collections::HashMap, +) -> Result { + let _perf = crate::perf_metrics::scope("math evaluation"); + let (samples, freq) = evaluate_expression_with_aliases(&expression, &channel_data, &aliases) + .map_err(|err| err.to_string())?; + let stats = compute_channel_stats(&samples); + Ok(EvaluatedMathChannel { + samples, + freq, + stats, + }) +} + +fn perform_build_downsampled_series( + data: Arc<[f64]>, + freq: u16, + start_sample: usize, + end_sample: usize, + target_width: usize, +) -> Vec { + let visible_end = end_sample.min(data.len()); + let visible_start = start_sample.min(visible_end); + if visible_start >= visible_end { + return Vec::new(); + } + downsample_minmax( + &data[visible_start..visible_end], + freq, + visible_start, + target_width, + ) +} + +#[cfg(target_arch = "wasm32")] +const WASM_COOPERATIVE_CHUNK: usize = 4_096; + +#[cfg(target_arch = "wasm32")] +async fn yield_to_browser() { + gloo_timers::future::TimeoutFuture::new(0).await; +} + +#[cfg(target_arch = "wasm32")] +fn find_gps_channel<'a>(ld: &'a LdFile, suffixes: &[&str]) -> Option<&'a i3rs_core::ChannelMeta> { + ld.channels.iter().find(|channel| { + let normalized = i3rs_core::normalize_channel_name(&channel.name); + normalized.contains("gps") && suffixes.iter().any(|suffix| normalized.contains(suffix)) + }) +} + +#[cfg(target_arch = "wasm32")] +async fn perform_build_track_data_cooperative(ld: Arc) -> Option { + let _perf = crate::perf_metrics::scope("track-map draw"); + let lat_ch = find_gps_channel(&ld, &["latitude", "lat"])?; + let lon_ch = find_gps_channel(&ld, &["longitude", "lon", "long"])?; + + yield_to_browser().await; + let lat_data = ld.read_channel_data(lat_ch)?; + yield_to_browser().await; + let lon_data = ld.read_channel_data(lon_ch)?; + + let n = lat_data.len().min(lon_data.len()); + if n == 0 { + return None; + } + + let freq = lat_ch.freq; + let mut lat_sum = 0.0; + let mut lat_count = 0usize; + let mut lon_sum = 0.0; + let mut lon_count = 0usize; + + for start in (0..n).step_by(WASM_COOPERATIVE_CHUNK) { + let end = (start + WASM_COOPERATIVE_CHUNK).min(n); + for idx in start..end { + let lat = lat_data[idx]; + if lat.is_finite() { + lat_sum += lat; + lat_count += 1; + } + let lon = lon_data[idx]; + if lon.is_finite() { + lon_sum += lon; + lon_count += 1; + } + } + yield_to_browser().await; + } + + let mean_lat = lat_sum / lat_count.max(1) as f64; + let mean_lon = lon_sum / lon_count.max(1) as f64; + let cos_lat = mean_lat.to_radians().cos(); + + let mut x = Vec::with_capacity(n); + let mut y = Vec::with_capacity(n); + let mut time = Vec::with_capacity(n); + + for start in (0..n).step_by(WASM_COOPERATIVE_CHUNK) { + let end = (start + WASM_COOPERATIVE_CHUNK).min(n); + for idx in start..end { + let lat = lat_data[idx]; + let lon = lon_data[idx]; + if lat.is_finite() && lon.is_finite() { + x.push((lon - mean_lon) * cos_lat); + y.push(lat - mean_lat); + } else if let (Some(&prev_x), Some(&prev_y)) = (x.last(), y.last()) { + x.push(prev_x); + y.push(prev_y); + } else { + x.push(0.0); + y.push(0.0); + } + time.push(idx as f64 / freq as f64); + } + yield_to_browser().await; + } + + Some(TrackData::from_normalized_parts(x, y, time, freq)) +} + +#[cfg(target_arch = "wasm32")] +async fn perform_build_downsampled_series_cooperative( + data: Arc<[f64]>, + freq: u16, + start_sample: usize, + end_sample: usize, + target_width: usize, +) -> Vec { + let visible_end = end_sample.min(data.len()); + let visible_start = start_sample.min(visible_end); + if visible_start >= visible_end || target_width == 0 || freq == 0 { + return Vec::new(); + } + + let samples = &data[visible_start..visible_end]; + let freq_f = freq as f64; + if samples.len() <= target_width.saturating_mul(2) { + let mut points = Vec::with_capacity(samples.len()); + for start in (0..samples.len()).step_by(WASM_COOPERATIVE_CHUNK) { + let end = (start + WASM_COOPERATIVE_CHUNK).min(samples.len()); + for (offset, &value) in samples[start..end].iter().enumerate() { + let idx = start + offset; + points.push(DownsampledPoint { + time: (visible_start + idx) as f64 / freq_f, + min: value, + max: value, + }); + } + yield_to_browser().await; + } + return points; + } + + let mut result = Vec::with_capacity(target_width); + let bucket_size_f = samples.len() as f64 / target_width as f64; + for bucket in 0..target_width { + let start = (bucket as f64 * bucket_size_f) as usize; + let end = (((bucket + 1) as f64) * bucket_size_f) as usize; + let end = end.min(samples.len()); + if start >= end { + continue; + } + + let mut min_v = samples[start]; + let mut max_v = samples[start]; + for &value in &samples[start + 1..end] { + if value < min_v { + min_v = value; + } + if value > max_v { + max_v = value; + } + } + + let mid_sample = visible_start + (start + end) / 2; + result.push(DownsampledPoint { + time: mid_sample as f64 / freq_f, + min: min_v, + max: max_v, + }); + + if bucket % 256 == 255 { + yield_to_browser().await; + } + } + + result +} + +#[cfg(target_arch = "wasm32")] +async fn run_wasm_job(request: JobRequest) -> JobResult { + match request { + JobRequest::LoadSession { request_id, source } => { + yield_to_browser().await; + let result = perform_load_session(source); + yield_to_browser().await; + JobResult::LoadSession { request_id, result } + } + JobRequest::DecodePhysicalChannel { + request_id: _, + session_id, + ld, + channel_idx, + } => { + yield_to_browser().await; + let result = perform_decode_physical_channel(ld, channel_idx); + yield_to_browser().await; + JobResult::DecodePhysicalChannel { session_id, result } + } + JobRequest::BuildTrackData { + request_id: _, + session_id, + ld, + } => JobResult::BuildTrackData { + session_id, + track_data: perform_build_track_data_cooperative(ld).await, + }, + JobRequest::EvaluateMathChannel { + request_id: _, + session_id, + math_id, + expression, + aliases, + channel_data, + } => { + yield_to_browser().await; + let result = perform_evaluate_math_channel(expression.clone(), channel_data, aliases); + yield_to_browser().await; + JobResult::EvaluateMathChannel { + session_id, + math_id, + expression, + result, + } + } + JobRequest::BuildDownsampledSeries { + request_id: _, + session_id, + key, + data, + freq, + start_sample, + end_sample, + target_width, + } => JobResult::BuildDownsampledSeries { + session_id, + key, + points: perform_build_downsampled_series_cooperative( + data, + freq, + start_sample, + end_sample, + target_width, + ) + .await, + }, + } +} + +#[cfg(not(target_arch = "wasm32"))] +pub struct BackgroundJobs { + request_tx: crossbeam_channel::Sender<(JobRequest, egui::Context)>, + result_rx: crossbeam_channel::Receiver, +} + +#[cfg(not(target_arch = "wasm32"))] +impl BackgroundJobs { + pub fn new() -> Self { + let (request_tx, request_rx) = + crossbeam_channel::bounded::<(JobRequest, egui::Context)>(64); + let (result_tx, result_rx) = crossbeam_channel::bounded::(64); + + std::thread::spawn(move || { + while let Ok((request, ctx)) = request_rx.recv() { + let result = match request { + JobRequest::LoadSession { request_id, source } => JobResult::LoadSession { + request_id, + result: perform_load_session(source), + }, + JobRequest::DecodePhysicalChannel { + request_id: _, + session_id, + ld, + channel_idx, + } => JobResult::DecodePhysicalChannel { + session_id, + result: perform_decode_physical_channel(ld, channel_idx), + }, + JobRequest::BuildTrackData { + request_id: _, + session_id, + ld, + } => JobResult::BuildTrackData { + session_id, + track_data: perform_build_track_data(ld), + }, + JobRequest::EvaluateMathChannel { + request_id: _, + session_id, + math_id, + expression, + aliases, + channel_data, + } => JobResult::EvaluateMathChannel { + session_id, + math_id, + expression: expression.clone(), + result: perform_evaluate_math_channel(expression, channel_data, aliases), + }, + JobRequest::BuildDownsampledSeries { + request_id: _, + session_id, + key, + data, + freq, + start_sample, + end_sample, + target_width, + } => JobResult::BuildDownsampledSeries { + session_id, + key, + points: perform_build_downsampled_series( + data, + freq, + start_sample, + end_sample, + target_width, + ), + }, + }; + + if result_tx.send(result).is_err() { + break; + } + ctx.request_repaint(); + } + }); + + Self { + request_tx, + result_rx, + } + } + + pub fn submit(&self, request: JobRequest, ctx: &egui::Context) -> Result<(), String> { + self.request_tx + .send((request, ctx.clone())) + .map_err(|err| format!("failed to submit background job: {err}")) + } + + pub fn try_recv(&self) -> Option { + self.result_rx.try_recv().ok() + } +} + +#[cfg(target_arch = "wasm32")] +pub struct BackgroundJobs { + result_tx: std::sync::mpsc::Sender, + result_rx: std::sync::mpsc::Receiver, +} + +#[cfg(target_arch = "wasm32")] +impl BackgroundJobs { + pub fn new() -> Self { + let (result_tx, result_rx) = std::sync::mpsc::channel(); + Self { + result_tx, + result_rx, + } + } + + pub fn submit(&self, request: JobRequest, ctx: &egui::Context) -> Result<(), String> { + let tx = self.result_tx.clone(); + let ctx = ctx.clone(); + wasm_bindgen_futures::spawn_local(async move { + let result = run_wasm_job(request).await; + let _ = tx.send(result); + ctx.request_repaint(); + }); + Ok(()) + } + + pub fn try_recv(&self) -> Option { + self.result_rx.try_recv().ok() + } +} + +pub fn load_session_from_path(path: PathBuf) -> Result { + perform_load_session(LoadSessionSource::Path(path)) +} + +pub fn load_session_from_bytes( + file_name: String, + bytes: Vec, + ldx: Option, +) -> Result { + perform_load_session(LoadSessionSource::Bytes { + file_name, + bytes, + ldx, + }) +} diff --git a/crates/i3rs-app/src/default_layouts.rs b/crates/i3rs-app/src/default_layouts.rs index 8d58430..ae4be69 100644 --- a/crates/i3rs-app/src/default_layouts.rs +++ b/crates/i3rs-app/src/default_layouts.rs @@ -15,9 +15,8 @@ use crate::panels::graph::GraphPanel; use crate::panels::histogram::HistogramPanel; use crate::panels::mixture_map::MixtureMapPanel; use crate::panels::scatter::ScatterPanel; -use crate::state::{ - CHANNEL_COLORS, ChannelId, PlottedChannel, SharedState, YAxis, compute_channel_stats, -}; +use crate::panels::utils::create_plotted_channel; +use crate::state::{CHANNEL_COLORS, ChannelId, PlottedChannel, SharedState, YAxis}; /// A graph-centric default worksheet. struct GraphWorksheetTemplate { @@ -313,15 +312,7 @@ fn channel_name_matches(actual: &str, candidate: &str) -> bool { } fn normalize_channel_name(name: &str) -> String { - name.chars() - .map(|ch| { - if ch.is_ascii_alphanumeric() { - ch.to_ascii_lowercase() - } else { - ' ' - } - }) - .collect::() + i3rs_core::normalize_channel_name(name) .split_whitespace() .collect::>() .join(" ") @@ -329,12 +320,8 @@ fn normalize_channel_name(name: &str) -> String { /// Find a channel index by name (case-insensitive, partial match with prefix). fn find_channel(ld: &LdFile, name: &str) -> Option { - if let Some(idx) = ld - .channels - .iter() - .position(|ch| ch.name.eq_ignore_ascii_case(name)) - { - return Some(idx); + if let Some(channel) = ld.find_channel_by_name(name) { + return Some(channel.index); } ld.channels @@ -378,27 +365,16 @@ fn next_panel_id(shared: &mut SharedState) -> u64 { } fn make_plotted_channel( - ld: &LdFile, + shared: &SharedState, channel_idx: usize, color: egui::Color32, tile_group: usize, ) -> Option { - let channel = ld.channels.get(channel_idx)?; - let data = ld.read_channel_data(channel)?; - let (cached_min, cached_max, cached_avg, _) = compute_channel_stats(&data); - Some(PlottedChannel { - channel_id: ChannelId::Physical(channel_idx), - color, - data: Arc::new(data), - tile_group, - y_axis: YAxis::Left, - display_scale: 1.0, - display_offset: 0.0, - display_unit: None, - cached_min, - cached_max, - cached_avg, - }) + let mut channel = create_plotted_channel(ChannelId::Physical(channel_idx), shared, tile_group)?; + channel.color = color; + channel.tile_group = tile_group; + channel.y_axis = YAxis::Left; + Some(channel) } fn build_graph_panel( @@ -429,7 +405,7 @@ fn build_graph_panel( }) .unwrap_or(i); if let Some(pc) = make_plotted_channel( - ld, + shared, chan_idx, CHANNEL_COLORS[i % CHANNEL_COLORS.len()], tile_group, @@ -485,7 +461,7 @@ fn build_histogram_panel( for (i, candidates) in channel_names.iter().enumerate() { if let Some(idx) = find_first_channel(ld, candidates) && let Some(pc) = - make_plotted_channel(ld, idx, CHANNEL_COLORS[i % CHANNEL_COLORS.len()], i) + make_plotted_channel(shared, idx, CHANNEL_COLORS[i % CHANNEL_COLORS.len()], i) { panel.channels.push(pc); } @@ -507,9 +483,9 @@ fn build_mixture_map_panel( let value_idx = find_first_channel(ld, value_names)?; let mut panel = MixtureMapPanel::new(next_panel_id(shared), title); panel.bins = bins; - panel.x_channel = make_plotted_channel(ld, x_idx, CHANNEL_COLORS[0], 0); - panel.y_channel = make_plotted_channel(ld, y_idx, CHANNEL_COLORS[1], 1); - panel.value_channel = make_plotted_channel(ld, value_idx, CHANNEL_COLORS[2], 2); + panel.x_channel = make_plotted_channel(shared, x_idx, CHANNEL_COLORS[0], 0); + panel.y_channel = make_plotted_channel(shared, y_idx, CHANNEL_COLORS[1], 1); + panel.value_channel = make_plotted_channel(shared, value_idx, CHANNEL_COLORS[2], 2); (panel.x_channel.is_some() && panel.y_channel.is_some() && panel.value_channel.is_some()) .then_some(panel) } @@ -606,8 +582,8 @@ fn build_oil_pressure_worksheet( panel.point_size = 1.75; panel.bounds_padding_frac = 0.10; panel.lock_bounds = true; - panel.x_channel = make_plotted_channel(ld, x_idx, CHANNEL_COLORS[0], 0); - panel.y_channel = make_plotted_channel(ld, y_idx, CHANNEL_COLORS[4], 1); + panel.x_channel = make_plotted_channel(shared, x_idx, CHANNEL_COLORS[0], 0); + panel.y_channel = make_plotted_channel(shared, y_idx, CHANNEL_COLORS[4], 1); (panel.x_channel.is_some() && panel.y_channel.is_some()).then_some(panel) } _ => None, @@ -615,7 +591,7 @@ fn build_oil_pressure_worksheet( let graph = y_idx.and_then(|idx| { let mut panel = GraphPanel::new(next_panel_id(shared), "Engine Oil Pressure"); - if let Some(pc) = make_plotted_channel(ld, idx, CHANNEL_COLORS[0], 0) { + if let Some(pc) = make_plotted_channel(shared, idx, CHANNEL_COLORS[0], 0) { panel.plotted_channels.push(pc); Some(panel) } else { diff --git a/crates/i3rs-app/src/lib.rs b/crates/i3rs-app/src/lib.rs index b39c3b1..404ce51 100644 --- a/crates/i3rs-app/src/lib.rs +++ b/crates/i3rs-app/src/lib.rs @@ -1,8 +1,10 @@ //! Shared library entry points for the native and web i3rs app. mod app; +mod background_jobs; mod default_layouts; mod panels; +mod perf_metrics; mod platform; mod preferences; mod project; diff --git a/crates/i3rs-app/src/panels/fft.rs b/crates/i3rs-app/src/panels/fft.rs index 4202237..3d1ba60 100644 --- a/crates/i3rs-app/src/panels/fft.rs +++ b/crates/i3rs-app/src/panels/fft.rs @@ -1,14 +1,15 @@ //! FFT panel: frequency spectrum analysis for vibration diagnosis. -use std::sync::Arc; - use eframe::egui; -use egui_plot::{Legend, Line, Plot, PlotPoints}; +use egui_plot::{Legend, Line, Plot, PlotPoint, PlotPoints}; use i3rs_core::{FftPlanner, compute_fft_with_planner}; use crate::state::{ChannelId, PlottedChannel, SharedState}; -use super::utils::{build_plotted_channel_info, create_plotted_channel, resolve_channel_meta}; +use super::utils::{ + build_plotted_channel_info, create_plotted_channel, refresh_plotted_channel, + resolve_channel_meta, +}; /// Return the sub-slice of data visible in the current zoom range (no copy). fn visible_subslice<'a>(data: &'a [f64], freq: u16, shared: &SharedState) -> &'a [f64] { @@ -24,9 +25,8 @@ fn visible_subslice<'a>(data: &'a [f64], freq: u16, shared: &SharedState) -> &'a /// Cached FFT result. struct FftCache { /// Fingerprint: (data pointer, data length, zoom range, channel_id). - fingerprint: (usize, usize, Option<(u64, u64)>, ChannelId), - frequencies: Vec, - magnitudes: Vec, + fingerprint: (usize, usize, Option<(u64, u64)>, ChannelId, bool), + plot_points: Vec, } pub struct FftPanel { @@ -80,6 +80,8 @@ impl FftPanel { } pub fn ui(&mut self, ui: &mut egui::Ui, shared: &mut SharedState) { + let _perf = crate::perf_metrics::scope("FFT draw"); + // Handle drop from channel browser if shared.dragging_channel.is_some() && ui.input(|i| i.pointer.any_released()) @@ -98,6 +100,10 @@ impl FftPanel { } } + for channel in &mut self.channels { + refresh_plotted_channel(channel, shared); + } + if self.channels.is_empty() { ui.centered_and_justified(|ui| { ui.label("Drag a channel here for frequency analysis"); @@ -142,7 +148,7 @@ impl FftPanel { } // Pre-compute FFT data outside the plot closure - let mut fft_lines: Vec<(Vec<[f64; 2]>, egui::Color32, String)> = Vec::new(); + let mut fft_lines: Vec<(usize, egui::Color32, String)> = Vec::new(); for (i, pc) in self.channels.iter().enumerate() { let (name, _, freq, _) = resolve_channel_meta(pc.channel_id, shared); @@ -150,9 +156,9 @@ impl FftPanel { continue; } - let ptr = Arc::as_ptr(&pc.data) as usize; + let ptr = pc.data.as_ptr() as usize; let len = pc.data.len(); - let fingerprint = (ptr, len, zoom_key, pc.channel_id); + let fingerprint = (ptr, len, zoom_key, pc.channel_id, log_scale); let cache: &mut Option = &mut self.caches[i]; let needs_recompute = cache.as_ref().is_none_or(|c| c.fingerprint != fingerprint); @@ -160,30 +166,29 @@ impl FftPanel { if needs_recompute { let data_slice = visible_subslice(&pc.data, freq, shared); let result = compute_fft_with_planner(data_slice, freq as f64, &mut self.planner); - *cache = Some(FftCache { - fingerprint, - frequencies: result.frequencies, - magnitudes: result.magnitudes, - }); - } - - if let Some(c) = cache.as_ref() { - let points: Vec<[f64; 2]> = c + let plot_points = result .frequencies .iter() - .zip(c.magnitudes.iter()) - .skip(1) // skip DC component - .map(|(&f, &m): (&f64, &f64)| { + .zip(result.magnitudes.iter()) + .skip(1) + .map(|(&f, &m)| { let y = if log_scale { (m.max(1e-12)).log10() * 20.0 } else { m }; - [f, y] + PlotPoint::new(f, y) }) .collect(); + *cache = Some(FftCache { + fingerprint, + plot_points, + }); + } - fft_lines.push((points, pc.color, name)); + if let Some(c) = cache.as_ref() { + let _ = c; + fft_lines.push((i, pc.color, name)); } } @@ -199,8 +204,13 @@ impl FftPanel { .y_axis_label(y_label) .allow_boxed_zoom(true) .show(ui, |plot_ui| { - for (points, color, name) in &fft_lines { - plot_ui.line(Line::new(name, PlotPoints::new(points.clone())).color(*color)); + for (idx, color, name) in &fft_lines { + if let Some(cache) = self.caches.get(*idx).and_then(|cache| cache.as_ref()) { + plot_ui.line( + Line::new(name, PlotPoints::Borrowed(cache.plot_points.as_slice())) + .color(*color), + ); + } } }); } diff --git a/crates/i3rs-app/src/panels/gauge.rs b/crates/i3rs-app/src/panels/gauge.rs index bd6f865..c2fd960 100644 --- a/crates/i3rs-app/src/panels/gauge.rs +++ b/crates/i3rs-app/src/panels/gauge.rs @@ -6,8 +6,8 @@ use eframe::egui; use crate::state::{ChannelId, PlottedChannel, SharedState}; use super::utils::{ - build_plotted_channel_info, create_plotted_channel, interp_at_time, resolve_channel_meta, - resolve_plotted_channel_display_meta, + build_plotted_channel_info, create_plotted_channel, interp_at_time, refresh_plotted_channel, + resolve_channel_meta, resolve_plotted_channel_display_meta, }; #[derive(Clone, Copy, PartialEq, Eq)] @@ -94,6 +94,10 @@ impl GaugePanel { } } + for gauge in &mut self.gauges { + refresh_plotted_channel(&mut gauge.channel, shared); + } + if self.gauges.is_empty() { ui.centered_and_justified(|ui| { ui.label("Drag channels here to display as gauges"); diff --git a/crates/i3rs-app/src/panels/graph.rs b/crates/i3rs-app/src/panels/graph.rs index cd2243a..65cc1ff 100644 --- a/crates/i3rs-app/src/panels/graph.rs +++ b/crates/i3rs-app/src/panels/graph.rs @@ -11,15 +11,17 @@ use i3rs_core::{ }; use crate::state::{ - CHANNEL_COLORS, ChannelId, ChannelPreference, DistanceAxisCache, GraphMode, GraphXAxis, - PlottedChannel, SharedState, YAxis, channel_preference_key, + CHANNEL_COLORS, ChannelId, ChannelPreference, DistanceAxisCache, DownsampleSeriesKey, + DownsampleSeriesRequest, GraphMode, GraphXAxis, PlottedChannel, SharedState, YAxis, + channel_preference_key, }; use super::gauge::{ GaugeChannel, GaugeDrawContext, GaugeStyle, default_style_for_name, draw_gauge, }; use super::utils::{ - ChannelDisplayMeta, build_plotted_channel_info, create_plotted_channel, interp_at_time, + ChannelDisplayMeta, DisplayTransformFingerprint, build_plotted_channel_info, + create_plotted_channel, display_transform_fingerprint, interp_at_time, refresh_plotted_channel, resolve_channel_meta, resolve_plotted_channel_display_meta, }; @@ -134,13 +136,13 @@ fn transformed_value_for_display( } } -#[derive(Clone)] +#[derive(Clone, Copy)] pub enum OverlaySource { MainSession, External(usize), } -#[derive(Clone)] +#[derive(Clone, Copy)] pub struct LapOverlay { pub source: OverlaySource, pub lap_idx: usize, @@ -149,7 +151,7 @@ pub struct LapOverlay { } pub struct OverlayChannelCacheEntry { - pub data: Arc>, + pub data: Arc<[f64]>, pub freq: u16, } @@ -162,6 +164,59 @@ pub struct OverlaySession { pub channel_cache: HashMap, } +#[derive(Clone, PartialEq, Eq)] +enum GraphAxisFingerprint { + Time, + Distance { + data_ptr: usize, + data_len: usize, + freq: u16, + }, +} + +#[derive(Clone, PartialEq, Eq)] +struct GraphRenderFingerprint { + data_ptr: usize, + data_len: usize, + x_bounds: (u64, u64), + axis: GraphAxisFingerprint, + transform: DisplayTransformFingerprint, + target_width: usize, +} + +struct GraphRenderCacheEntry { + fingerprint: GraphRenderFingerprint, + plot_points: Vec, +} + +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +enum LapRenderCacheKey { + Reference(ChannelId), + Overlay { + channel_id: ChannelId, + overlay_idx: usize, + }, +} + +#[derive(Clone, PartialEq, Eq)] +struct LapRenderFingerprint { + data_ptr: usize, + data_len: usize, + lap_bounds: (u64, u64), + x_bounds: (u64, u64), + axis: GraphAxisFingerprint, + origin_axis_bits: u64, + x_scale_bits: u64, + x_offset_bits: u64, + transform: DisplayTransformFingerprint, + target_width: usize, +} + +struct LapRenderCacheEntry { + fingerprint: LapRenderFingerprint, + plot_points: Vec, +} + /// A single graph panel with its own set of plotted channels. pub struct GraphPanel { pub id: u64, @@ -176,6 +231,8 @@ pub struct GraphPanel { pub overlay_sessions: Vec, /// Set when the first channel is added; consumed on next render to reset zoom. needs_zoom_reset: bool, + render_cache: HashMap, + lap_render_cache: HashMap, } impl GraphPanel { @@ -212,6 +269,8 @@ impl GraphPanel { lap_overlays: Vec::new(), overlay_sessions: Vec::new(), needs_zoom_reset: false, + render_cache: HashMap::new(), + lap_render_cache: HashMap::new(), } } @@ -222,6 +281,8 @@ impl GraphPanel { self.lap_overlays.clear(); self.overlay_sessions.clear(); self.needs_zoom_reset = false; + self.render_cache.clear(); + self.lap_render_cache.clear(); } pub fn add_channel(&mut self, channel_id: ChannelId, shared: &SharedState) { @@ -241,6 +302,15 @@ impl GraphPanel { pub fn remove_channel(&mut self, channel_id: ChannelId) { self.plotted_channels .retain(|pc| pc.channel_id != channel_id); + self.render_cache.remove(&channel_id); + self.lap_render_cache.retain(|key, _| { + !matches!( + key, + LapRenderCacheKey::Reference(id) + | LapRenderCacheKey::Overlay { channel_id: id, .. } + if *id == channel_id + ) + }); } pub fn add_embedded_gauge(&mut self, channel_id: ChannelId, shared: &SharedState) { @@ -301,7 +371,7 @@ impl GraphPanel { laps, distance_axis_cache: derive_distance_axis_cache_for_ld(&ld_file).map(|cache| { DistanceAxisCache { - data: Arc::new(cache.data), + data: Arc::from(cache.data), freq: cache.freq, } }), @@ -326,6 +396,8 @@ impl GraphPanel { /// Render the graph panel UI. pub fn ui(&mut self, ui: &mut egui::Ui, shared: &mut SharedState) { + let _perf = crate::perf_metrics::scope("graph draw"); + // Handle pending channel toggle from browser if let Some(ch_id) = shared.pending_toggle_channel.take() { let was_empty = self.plotted_channels.is_empty(); @@ -348,6 +420,13 @@ impl GraphPanel { } } + for channel in &mut self.plotted_channels { + refresh_plotted_channel(channel, shared); + } + for gauge in &mut self.embedded_gauges { + refresh_plotted_channel(&mut gauge.channel, shared); + } + if self.plotted_channels.is_empty() { ui.centered_and_justified(|ui| { ui.label("Click channels in the browser to plot them, or drag and drop"); @@ -463,6 +542,7 @@ impl GraphPanel { manual_offset: 0.0, stretch_to_reference: false, }); + self.lap_render_cache.clear(); self.needs_zoom_reset = true; } @@ -474,6 +554,7 @@ impl GraphPanel { .clicked() { self.load_external_overlay(); + self.lap_render_cache.clear(); self.needs_zoom_reset = true; } @@ -486,6 +567,7 @@ impl GraphPanel { { self.lap_overlays.clear(); self.overlay_sessions.clear(); + self.lap_render_cache.clear(); self.needs_zoom_reset = true; } @@ -533,6 +615,7 @@ impl GraphPanel { } else { self.lap_overlays.clear(); } + self.lap_render_cache.clear(); self.needs_zoom_reset = true; } @@ -602,6 +685,7 @@ impl GraphPanel { if let Some(idx) = remove_idx { self.lap_overlays.remove(idx); + self.lap_render_cache.clear(); } }); } @@ -775,10 +859,15 @@ impl GraphPanel { .auto_bounds(egui::Vec2b::new(true, true)); } - let laps = shared.laps.clone(); + let laps = shared.laps.as_slice(); let show_markers = shared.show_lap_markers; let cursor_time = shared.cursor_time; let plotted: Vec<&PlottedChannel> = self.plotted_channels.iter().collect(); + let channel_meta: Vec = self + .plotted_channels + .iter() + .map(|pc| resolve_plotted_channel_display_meta(pc, shared)) + .collect(); let mut new_cursor_time = None; let zoom_from_timeline = shared.zoom_from_timeline; @@ -786,6 +875,7 @@ impl GraphPanel { let y_range = Self::compute_y_range(&plotted); let freq_map = build_freq_map(&plotted, shared); + let render_cache = &mut self.render_cache; let response = plot.show(ui, |plot_ui| { if needs_zoom_reset { @@ -804,7 +894,7 @@ impl GraphPanel { plot_ui.set_plot_bounds_y((y_min - padding)..=(y_max + padding)); } - Self::draw_channels(plot_ui, &plotted, &freq_map, x_axis); + Self::draw_channels(plot_ui, &plotted, &freq_map, x_axis, shared, render_cache); if show_markers { Self::draw_lap_markers(plot_ui, &laps, x_axis); @@ -829,7 +919,7 @@ impl GraphPanel { ui, response.response.rect, &plotted, - shared, + &channel_meta, shared.cursor_time, ); @@ -856,7 +946,7 @@ impl GraphPanel { x_axis: &ActiveGraphXAxis, ) { let cursor_group = egui::Id::new("global_cursor_link"); - let laps = shared.laps.clone(); + let laps = shared.laps.as_slice(); let show_markers = shared.show_lap_markers; let cursor_time = shared.cursor_time; let zoom_from_timeline = shared.zoom_from_timeline; @@ -882,13 +972,12 @@ impl GraphPanel { let mut any_hovered_cursor: Option = None; let mut hovered_x_bounds: Option<(f64, f64)> = None; let mut first_x_bounds: Option<(f64, f64)> = None; + let mut responses = Vec::new(); egui::ScrollArea::vertical() .auto_shrink([false, false]) .scroll_source(egui::scroll_area::ScrollSource::SCROLL_BAR) .show(ui, |ui| { - let mut responses = Vec::new(); - for (tile_idx, group) in tile_groups.iter().enumerate() { let plot_id = format!("tile_{}_{}", self.id, tile_idx); @@ -938,7 +1027,14 @@ impl GraphPanel { plot_ui.set_plot_bounds_y((y_min - padding)..=(y_max + padding)); } - Self::draw_channels(plot_ui, &grouped, &freq_map, x_axis); + Self::draw_channels( + plot_ui, + &grouped, + &freq_map, + x_axis, + shared, + &mut self.render_cache, + ); if show_markers { Self::draw_lap_markers(plot_ui, &laps, x_axis); @@ -978,12 +1074,12 @@ impl GraphPanel { responses.push((group.clone(), resp.response)); } - - for (group, resp) in &responses { - self.handle_tile_group_context_menu(resp, group, &channel_meta, shared); - } }); + for (group, resp) in &responses { + self.handle_tile_group_context_menu(resp, group, &channel_meta, shared); + } + if let Some(t) = any_hovered_cursor { shared.cursor_time = Some(t); } @@ -1049,6 +1145,11 @@ impl GraphPanel { shared.data_duration.unwrap_or_default(), ); let plotted: Vec<&PlottedChannel> = self.plotted_channels.iter().collect(); + let channel_meta: Vec = self + .plotted_channels + .iter() + .map(|pc| resolve_plotted_channel_display_meta(pc, shared)) + .collect(); let freq_map = build_freq_map(&plotted, shared); let y_range = Self::compute_y_range(&plotted); let cursor_time = shared.cursor_time; @@ -1057,6 +1158,7 @@ impl GraphPanel { let mut new_cursor_time = None; let response = plot.show(ui, |plot_ui| { + let mut draw_specs = Vec::new(); let (x_min, x_max) = if needs_zoom_reset { viewport.full_range() } else if let Some((z_min, z_max)) = zoom_range { @@ -1076,10 +1178,15 @@ impl GraphPanel { } let target_width = plot_ui.response().rect.width().max(100.0) as usize; + let lap_render_cache = &mut self.lap_render_cache; for pc in &plotted { let freq = freq_map.get(&pc.channel_id).copied().unwrap_or(0); - draw_lap_series( + let reference_key = LapRenderCacheKey::Reference(pc.channel_id); + ensure_lap_series_cache( plot_ui, + shared, + lap_render_cache, + reference_key, &pc.data, freq, &viewport.reference_lap, @@ -1089,14 +1196,20 @@ impl GraphPanel { 0.0, pc.display_scale, pc.display_offset, - pc.color, - 2.0, target_width, ); + draw_specs.push((reference_key, pc.color, 2.0)); for prepared in prepared_overlays.get(&pc.channel_id).into_iter().flatten() { - draw_lap_series( + let overlay_key = LapRenderCacheKey::Overlay { + channel_id: pc.channel_id, + overlay_idx: prepared.overlay_idx, + }; + ensure_lap_series_cache( plot_ui, + shared, + lap_render_cache, + overlay_key, &prepared.data, prepared.freq, &prepared.lap, @@ -1106,13 +1219,17 @@ impl GraphPanel { prepared.offset, pc.display_scale, pc.display_offset, - prepared.color, - prepared.width, target_width, ); + draw_specs.push((overlay_key, prepared.color, prepared.width)); } } + let lap_render_cache = &self.lap_render_cache; + for (cache_key, color, width) in draw_specs { + draw_cached_lap_series(plot_ui, lap_render_cache, &cache_key, color, width); + } + if let Some(cursor_time) = cursor_time { Self::draw_cursor_line(plot_ui, viewport.axis_value_for_time(cursor_time)); } @@ -1139,7 +1256,7 @@ impl GraphPanel { ui, response.response.rect, &plotted, - shared, + &channel_meta, shared.cursor_time, ); @@ -1228,6 +1345,7 @@ impl GraphPanel { let y_range = Self::compute_y_range(&grouped); let mut tile_cursor = None; let resp = plot.show(ui, |plot_ui| { + let mut draw_specs = Vec::new(); let (x_min, x_max) = if needs_zoom_reset { viewport.full_range() } else if let Some((z_min, z_max)) = zoom_range { @@ -1247,10 +1365,15 @@ impl GraphPanel { } let target_width = plot_ui.response().rect.width().max(100.0) as usize; + let lap_render_cache = &mut self.lap_render_cache; for pc in &grouped { let freq = freq_map.get(&pc.channel_id).copied().unwrap_or(0); - draw_lap_series( + let reference_key = LapRenderCacheKey::Reference(pc.channel_id); + ensure_lap_series_cache( plot_ui, + shared, + lap_render_cache, + reference_key, &pc.data, freq, &viewport.reference_lap, @@ -1260,16 +1383,22 @@ impl GraphPanel { 0.0, pc.display_scale, pc.display_offset, - pc.color, - 2.0, target_width, ); + draw_specs.push((reference_key, pc.color, 2.0)); for prepared in prepared_overlays.get(&pc.channel_id).into_iter().flatten() { - draw_lap_series( + let overlay_key = LapRenderCacheKey::Overlay { + channel_id: pc.channel_id, + overlay_idx: prepared.overlay_idx, + }; + ensure_lap_series_cache( plot_ui, + shared, + lap_render_cache, + overlay_key, &prepared.data, prepared.freq, &prepared.lap, @@ -1279,13 +1408,23 @@ impl GraphPanel { prepared.offset, pc.display_scale, pc.display_offset, - prepared.color, - prepared.width, target_width, ); + draw_specs.push((overlay_key, prepared.color, prepared.width)); } } + let lap_render_cache = &self.lap_render_cache; + for (cache_key, color, width) in draw_specs { + draw_cached_lap_series( + plot_ui, + lap_render_cache, + &cache_key, + color, + width, + ); + } + if let Some(cursor_time) = cursor_time { Self::draw_cursor_line( plot_ui, @@ -1385,17 +1524,27 @@ impl GraphPanel { } } - fn draw_channels( - plot_ui: &mut egui_plot::PlotUi, + fn draw_channels<'a>( + plot_ui: &mut egui_plot::PlotUi<'a>, channels: &[&PlottedChannel], freq_map: &HashMap, x_axis: &ActiveGraphXAxis, + shared: &SharedState, + render_cache: &'a mut HashMap, ) { let bounds = plot_ui.plot_bounds(); let x_min = bounds.min()[0]; let x_max = bounds.max()[0]; let pixels_wide = plot_ui.response().rect.width() as usize; let target_width = pixels_wide.max(100); + let axis_fingerprint = match x_axis { + ActiveGraphXAxis::Time => GraphAxisFingerprint::Time, + ActiveGraphXAxis::Distance { data, freq } => GraphAxisFingerprint::Distance { + data_ptr: data.as_ptr() as usize, + data_len: data.len(), + freq: *freq, + }, + }; for pc in channels { let freq = freq_map.get(&pc.channel_id).copied().unwrap_or(0); @@ -1432,20 +1581,154 @@ impl GraphPanel { continue; } - let visible_data = &pc.data[start_sample..end_sample]; - let downsampled = downsample_minmax(visible_data, freq, start_sample, target_width); + let fingerprint = GraphRenderFingerprint { + data_ptr: pc.data.as_ptr() as usize, + data_len: pc.data.len(), + x_bounds: (x_min.to_bits(), x_max.to_bits()), + axis: axis_fingerprint.clone(), + transform: display_transform_fingerprint(pc), + target_width, + }; + + let cache_entry = + render_cache + .entry(pc.channel_id) + .or_insert_with(|| GraphRenderCacheEntry { + fingerprint: fingerprint.clone(), + plot_points: Vec::new(), + }); - let points: Vec<[f64; 2]> = downsampled - .iter() - .map(|p| { - [ - x_axis.axis_value_at_time(p.time), - ((p.min + p.max) / 2.0) * pc.display_scale + pc.display_offset, - ] - }) - .filter(|[x, _]| *x >= x_min && *x <= x_max) - .collect(); - let line = Line::new("", PlotPoints::new(points)) + if cache_entry.fingerprint != fingerprint { + cache_entry.fingerprint = fingerprint.clone(); + cache_entry.plot_points.clear(); + } + + if cache_entry.plot_points.is_empty() { + let visible_sample_count = end_sample - start_sample; + let should_background_downsample = + visible_sample_count > target_width.saturating_mul(4); + let downsampled = match pc.channel_id { + ChannelId::Physical(channel_idx) => { + if let Some(decoded) = shared.decoded_physical_channel_if_ready(channel_idx) + { + decoded + .best_lod_level_for_view(visible_sample_count, target_width) + .map(|level| { + level + .points + .iter() + .copied() + .filter(|point| { + let point_time = point.time; + point_time >= visible_t_min + && point_time <= visible_t_max + }) + .collect::>() + }) + .filter(|points| !points.is_empty()) + .or_else(|| { + if should_background_downsample { + let key = DownsampleSeriesKey { + data_ptr: pc.data.as_ptr() as usize, + data_len: pc.data.len(), + freq, + start_sample, + end_sample, + target_width, + }; + if let Some(points) = + shared.downsampled_series_if_ready(&key) + { + Some(points.iter().copied().collect()) + } else { + shared.request_downsampled_series( + DownsampleSeriesRequest { + key, + data: Arc::clone(&pc.data), + freq, + start_sample, + end_sample, + target_width, + }, + ); + None + } + } else { + let visible_data = &pc.data[start_sample..end_sample]; + Some(downsample_minmax( + visible_data, + freq, + start_sample, + target_width, + )) + } + }) + } else { + shared.request_physical_channel_decode(channel_idx); + None + } + } + ChannelId::Math(_) => { + if should_background_downsample { + let key = DownsampleSeriesKey { + data_ptr: pc.data.as_ptr() as usize, + data_len: pc.data.len(), + freq, + start_sample, + end_sample, + target_width, + }; + if let Some(points) = shared.downsampled_series_if_ready(&key) { + Some(points.iter().copied().collect()) + } else { + shared.request_downsampled_series(DownsampleSeriesRequest { + key, + data: Arc::clone(&pc.data), + freq, + start_sample, + end_sample, + target_width, + }); + None + } + } else { + let visible_data = &pc.data[start_sample..end_sample]; + Some(downsample_minmax( + visible_data, + freq, + start_sample, + target_width, + )) + } + } + }; + + if let Some(downsampled) = downsampled { + cache_entry.plot_points = downsampled + .iter() + .map(|point| { + egui_plot::PlotPoint::new( + x_axis.axis_value_at_time(point.time), + ((point.min + point.max) / 2.0) * pc.display_scale + + pc.display_offset, + ) + }) + .filter(|point| point.x >= x_min && point.x <= x_max) + .collect(); + } + } + } + + let render_cache = &*render_cache; + for pc in channels { + let Some(cache_entry) = render_cache.get(&pc.channel_id) else { + continue; + }; + if cache_entry.plot_points.is_empty() { + continue; + } + + let line = Line::new("", PlotPoints::Borrowed(cache_entry.plot_points.as_slice())) .color(pc.color) .width(1.5); plot_ui.line(line); @@ -1456,15 +1739,16 @@ impl GraphPanel { ui: &egui::Ui, plot_rect: egui::Rect, channels: &[&PlottedChannel], - shared: &SharedState, + channel_meta: &[ChannelDisplayMeta], cursor_time: Option, ) { let line_height = 15.0; let pad = 4.0; for (i, pc) in channels.iter().enumerate() { - let meta = resolve_plotted_channel_display_meta(pc, shared); - let y = plot_rect.top() + pad + i as f32 * line_height; - Self::draw_legend_entry(ui, plot_rect, pc, &meta, cursor_time, y); + if let Some(meta) = channel_meta.get(i) { + let y = plot_rect.top() + pad + i as f32 * line_height; + Self::draw_legend_entry(ui, plot_rect, pc, meta, cursor_time, y); + } } } @@ -1949,7 +2233,8 @@ impl GraphPanel { ); } - if let Some(cache) = derive_distance_axis_cache(shared) { + let (derived_cache, waiting_on_sources) = derive_distance_axis_cache(shared); + if let Some(cache) = derived_cache { shared.distance_axis_cache = Some(cache.clone()); return ( ActiveGraphXAxis::Distance { @@ -1962,7 +2247,14 @@ impl GraphPanel { ( ActiveGraphXAxis::Time, - Some("Distance X-axis unavailable for this session; falling back to time.".into()), + Some( + if waiting_on_sources { + "Distance X-axis is still loading source channels; falling back to time." + } else { + "Distance X-axis unavailable for this session; falling back to time." + } + .into(), + ), ) } @@ -2022,15 +2314,15 @@ impl GraphPanel { .iter() .map(|pc| pc.channel_id) .collect(); - let overlay_specs = self.lap_overlays.clone(); for channel_id in channel_ids { let mut series = Vec::new(); - for (overlay_idx, overlay) in overlay_specs.iter().enumerate() { + for overlay_idx in 0..self.lap_overlays.len() { + let overlay = self.lap_overlays[overlay_idx]; if let Some(rendered) = - self.resolve_overlay_render_data(shared, overlay, channel_id) + self.resolve_overlay_render_data(shared, &overlay, channel_id) && let Some(source_axis) = - self.overlay_axis_for_source(overlay, x_axis, main_session_duration) + self.overlay_axis_for_source(&overlay, x_axis, main_session_duration) && let Some(raw_len) = lap_axis_length( &source_axis.axis, &rendered.lap, @@ -2066,6 +2358,7 @@ impl GraphPanel { offset: axis_offset, color: tint_color(base_color, overlay_idx + 1), width: 1.35, + overlay_idx, }); } } @@ -2178,13 +2471,13 @@ struct OverlayAxisHandle { } struct ResolvedOverlayRenderData { - data: Arc>, + data: Arc<[f64]>, freq: u16, lap: Lap, } struct PreparedLapOverlay { - data: Arc>, + data: Arc<[f64]>, freq: u16, lap: Lap, axis: ActiveGraphXAxis, @@ -2193,12 +2486,13 @@ struct PreparedLapOverlay { offset: f64, color: egui::Color32, width: f32, + overlay_idx: usize, } #[derive(Clone)] enum ActiveGraphXAxis { Time, - Distance { data: Arc>, freq: u16 }, + Distance { data: Arc<[f64]>, freq: u16 }, } impl ActiveGraphXAxis { @@ -2308,10 +2602,24 @@ impl OverlayViewport { } } +fn graph_axis_fingerprint(x_axis: &ActiveGraphXAxis) -> GraphAxisFingerprint { + match x_axis { + ActiveGraphXAxis::Time => GraphAxisFingerprint::Time, + ActiveGraphXAxis::Distance { data, freq } => GraphAxisFingerprint::Distance { + data_ptr: data.as_ptr() as usize, + data_len: data.len(), + freq: *freq, + }, + } +} + #[allow(clippy::too_many_arguments)] -fn draw_lap_series( - plot_ui: &mut egui_plot::PlotUi, - data: &[f64], +fn ensure_lap_series_cache( + plot_ui: &egui_plot::PlotUi, + shared: &SharedState, + render_cache: &mut HashMap, + cache_key: LapRenderCacheKey, + data: &Arc<[f64]>, freq: u16, lap: &Lap, axis: &ActiveGraphXAxis, @@ -2320,8 +2628,6 @@ fn draw_lap_series( x_offset: f64, y_scale: f64, y_offset: f64, - color: egui::Color32, - width: f32, target_width: usize, ) { if freq == 0 || data.is_empty() { @@ -2337,27 +2643,99 @@ fn draw_lap_series( let bounds = plot_ui.plot_bounds(); let x_min = bounds.min()[0]; let x_max = bounds.max()[0]; - let lap_data = &data[start_sample..end_sample]; - let downsampled = downsample_minmax(lap_data, freq, start_sample, target_width); - let points: Vec<[f64; 2]> = downsampled - .iter() - .map(|point| { - let transformed_x = - (axis.axis_value_at_time(point.time) - origin_axis) * x_scale + x_offset; - [ - transformed_x, - ((point.min + point.max) / 2.0) * y_scale + y_offset, - ] - }) - .filter(|[x, _]| *x >= x_min && *x <= x_max) - .collect(); + let fingerprint = LapRenderFingerprint { + data_ptr: data.as_ptr() as usize, + data_len: data.len(), + lap_bounds: (lap.start_time.to_bits(), lap.end_time.to_bits()), + x_bounds: (x_min.to_bits(), x_max.to_bits()), + axis: graph_axis_fingerprint(axis), + origin_axis_bits: origin_axis.to_bits(), + x_scale_bits: x_scale.to_bits(), + x_offset_bits: x_offset.to_bits(), + transform: DisplayTransformFingerprint { + scale_bits: y_scale.to_bits(), + offset_bits: y_offset.to_bits(), + unit: None, + }, + target_width, + }; + + let cache_entry = render_cache + .entry(cache_key) + .or_insert_with(|| LapRenderCacheEntry { + fingerprint: fingerprint.clone(), + plot_points: Vec::new(), + }); - if points.is_empty() { + if cache_entry.fingerprint != fingerprint || cache_entry.plot_points.is_empty() { + let visible_sample_count = end_sample - start_sample; + let should_background_downsample = visible_sample_count > target_width.saturating_mul(4); + let downsampled: Option> = + if should_background_downsample { + let key = DownsampleSeriesKey { + data_ptr: data.as_ptr() as usize, + data_len: data.len(), + freq, + start_sample, + end_sample, + target_width, + }; + if let Some(points) = shared.downsampled_series_if_ready(&key) { + Some(points) + } else { + shared.request_downsampled_series(DownsampleSeriesRequest { + key, + data: Arc::clone(data), + freq, + start_sample, + end_sample, + target_width, + }); + None + } + } else { + Some(Arc::from(downsample_minmax( + &data[start_sample..end_sample], + freq, + start_sample, + target_width, + ))) + }; + + if let Some(downsampled) = downsampled { + cache_entry.fingerprint = fingerprint; + cache_entry.plot_points = downsampled + .iter() + .map(|point| { + let transformed_x = + (axis.axis_value_at_time(point.time) - origin_axis) * x_scale + x_offset; + egui_plot::PlotPoint::new( + transformed_x, + ((point.min + point.max) / 2.0) * y_scale + y_offset, + ) + }) + .filter(|point| point.x >= x_min && point.x <= x_max) + .collect(); + } + } +} + +fn draw_cached_lap_series<'a>( + plot_ui: &mut egui_plot::PlotUi<'a>, + render_cache: &'a HashMap, + cache_key: &LapRenderCacheKey, + color: egui::Color32, + width: f32, +) { + let Some(cache_entry) = render_cache.get(cache_key) else { + return; + }; + if cache_entry.plot_points.is_empty() { return; } plot_ui.line( - Line::new("", PlotPoints::new(points)) + Line::new("", PlotPoints::Borrowed(cache_entry.plot_points.as_slice())) .color(color) .width(width), ); @@ -2398,18 +2776,14 @@ fn tint_color(color: egui::Color32, offset: usize) -> egui::Color32 { fn resolve_overlay_channel_data( session: &mut OverlaySession, requested_name: &str, -) -> Option<(Arc>, u16)> { +) -> Option<(Arc<[f64]>, u16)> { let normalized_requested = normalized_name(requested_name); if let Some(cached) = session.channel_cache.get(&normalized_requested) { return Some((Arc::clone(&cached.data), cached.freq)); } - let channel = session - .ld_file - .channels - .iter() - .find(|channel| normalized_name(&channel.name) == normalized_requested)?; - let data = Arc::new(session.ld_file.read_channel_data(channel)?); + let channel = session.ld_file.find_channel_by_name(requested_name)?; + let data = Arc::from(session.ld_file.read_channel_data(channel)?); session.channel_cache.insert( normalized_requested, OverlayChannelCacheEntry { @@ -2429,18 +2803,26 @@ fn default_overlay_lap_index(laps: &[Lap]) -> Option { .or_else(|| (!laps.is_empty()).then_some(0)) } -fn derive_distance_axis_cache(shared: &SharedState) -> Option { - if let Some(cache) = find_distance_channel(shared) { - return Some(cache); +fn derive_distance_axis_cache(shared: &SharedState) -> (Option, bool) { + let (distance_cache, waiting_on_distance) = find_distance_channel(shared); + if let Some(cache) = distance_cache { + return (Some(cache), false); } - let speed_series = find_speed_channel(shared)?; - integrate_speed_series(&speed_series.0, speed_series.1, &speed_series.2).map(|data| { - DistanceAxisCache { - data: Arc::new(data), - freq: speed_series.1, - } - }) + let (speed_series, waiting_on_speed) = find_speed_channel(shared); + let Some(speed_series) = speed_series else { + return (None, waiting_on_distance || waiting_on_speed); + }; + + ( + integrate_speed_series(&speed_series.0, speed_series.1, &speed_series.2).map(|data| { + DistanceAxisCache { + data: Arc::from(data), + freq: speed_series.1, + } + }), + waiting_on_distance || waiting_on_speed, + ) } struct OwnedDistanceAxisCache { @@ -2460,8 +2842,9 @@ fn derive_distance_axis_cache_for_ld(ld: &LdFile) -> Option Option { +fn find_distance_channel(shared: &SharedState) -> (Option, bool) { let distance_names = ["distance", "lap distance", "distance driven"]; + let mut waiting_on_sources = false; for mc in &shared.math_channels { if normalized_name(&mc.name).as_str().eq_any(&distance_names) @@ -2469,27 +2852,41 @@ fn find_distance_channel(shared: &SharedState) -> Option { && mc.freq > 0 && is_monotonic_non_decreasing(data) { - return Some(DistanceAxisCache { - data: Arc::clone(data), - freq: mc.freq, - }); + return ( + Some(DistanceAxisCache { + data: Arc::clone(data), + freq: mc.freq, + }), + false, + ); + } else if normalized_name(&mc.name).as_str().eq_any(&distance_names) && mc.error.is_none() { + waiting_on_sources = true; } } - let ld = shared.ld_file.as_ref()?; + let Some(ld) = shared.ld_file.as_ref() else { + return (None, waiting_on_sources); + }; for ch in &ld.channels { - if normalized_name(&ch.name).as_str().eq_any(&distance_names) - && let Some(data) = ld.read_channel_data(ch) - && is_monotonic_non_decreasing(&data) - { - return Some(DistanceAxisCache { - data: Arc::new(data), - freq: ch.freq, - }); + if normalized_name(&ch.name).as_str().eq_any(&distance_names) { + if let Some(decoded) = shared.decoded_physical_channel_if_ready(ch.index) { + if is_monotonic_non_decreasing(&decoded.data) { + return ( + Some(DistanceAxisCache { + data: Arc::clone(&decoded.data), + freq: ch.freq, + }), + false, + ); + } + } else { + shared.request_physical_channel_decode(ch.index); + waiting_on_sources = true; + } } } - None + (None, waiting_on_sources) } fn find_distance_channel_in_ld(ld: &LdFile) -> Option { @@ -2510,7 +2907,7 @@ fn find_distance_channel_in_ld(ld: &LdFile) -> Option { None } -fn find_speed_channel(shared: &SharedState) -> Option<(Arc>, u16, String)> { +fn find_speed_channel(shared: &SharedState) -> (Option<(Arc<[f64]>, u16, String)>, bool) { let speed_names = [ "gps speed", "vehicle speed", @@ -2518,26 +2915,36 @@ fn find_speed_channel(shared: &SharedState) -> Option<(Arc>, u16, Strin "ground speed", "speed", ]; + let mut waiting_on_sources = false; for mc in &shared.math_channels { if normalized_name(&mc.name).as_str().eq_any(&speed_names) && let Some(data) = &mc.data && mc.freq > 0 { - return Some((Arc::clone(data), mc.freq, mc.unit.clone())); + return (Some((Arc::clone(data), mc.freq, mc.unit.clone())), false); + } else if normalized_name(&mc.name).as_str().eq_any(&speed_names) && mc.error.is_none() { + waiting_on_sources = true; } } - let ld = shared.ld_file.as_ref()?; + let Some(ld) = shared.ld_file.as_ref() else { + return (None, waiting_on_sources); + }; for ch in &ld.channels { - if normalized_name(&ch.name).as_str().eq_any(&speed_names) - && let Some(data) = ld.read_channel_data(ch) - { - return Some((Arc::new(data), ch.freq, ch.unit.clone())); + if normalized_name(&ch.name).as_str().eq_any(&speed_names) { + if let Some(decoded) = shared.decoded_physical_channel_if_ready(ch.index) { + return ( + Some((Arc::clone(&decoded.data), ch.freq, ch.unit.clone())), + false, + ); + } + shared.request_physical_channel_decode(ch.index); + waiting_on_sources = true; } } - None + (None, waiting_on_sources) } fn find_speed_channel_in_ld(ld: &LdFile) -> Option<(Vec, u16, String)> { @@ -2597,8 +3004,8 @@ fn speed_unit_to_mps(unit: &str) -> Option { } fn normalized_name(name: &str) -> String { - name.to_ascii_lowercase() - .replace(['.', '_', '-'], " ") + i3rs_core::normalize_channel_name(name) + .replace('-', " ") .split_whitespace() .collect::>() .join(" ") @@ -2656,9 +3063,11 @@ impl NormalizedNameExt for str { #[cfg(test)] mod tests { use super::{ - ActiveGraphXAxis, integrate_speed_series, is_monotonic_non_decreasing, overlay_axis_offset, - time_from_monotonic_axis, + ActiveGraphXAxis, GraphAxisFingerprint, GraphPanel, GraphRenderFingerprint, + LapRenderFingerprint, OverlaySource, display_transform_fingerprint, integrate_speed_series, + is_monotonic_non_decreasing, overlay_axis_offset, time_from_monotonic_axis, }; + use crate::state::{ChannelId, PlottedChannel, YAxis}; use i3rs_core::Lap; use std::sync::Arc; @@ -2692,11 +3101,153 @@ mod tests { end_time: 8.0, }; let axis = ActiveGraphXAxis::Distance { - data: Arc::new(vec![0.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0]), + data: Arc::from(vec![0.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0]), freq: 1, }; assert!((overlay_axis_offset(&axis, &lap, 8.0, 1.0) - 10.0).abs() < 1e-6); assert!((overlay_axis_offset(&axis, &lap, 8.0, -1.0) + 10.0).abs() < 1e-6); } + + fn sample_channel(data: Arc<[f64]>) -> PlottedChannel { + PlottedChannel { + channel_id: ChannelId::Physical(0), + color: egui::Color32::WHITE, + data, + tile_group: 0, + y_axis: YAxis::Left, + display_scale: 1.0, + display_offset: 0.0, + display_unit: None, + cached_min: 0.0, + cached_max: 1.0, + cached_avg: 0.5, + } + } + + #[test] + fn graph_render_fingerprint_invalidates_for_zoom_transform_and_data_replacement() { + let channel = sample_channel(Arc::from(vec![0.0, 1.0, 2.0])); + let base = GraphRenderFingerprint { + data_ptr: channel.data.as_ptr() as usize, + data_len: channel.data.len(), + x_bounds: (0.0f64.to_bits(), 5.0f64.to_bits()), + axis: GraphAxisFingerprint::Time, + transform: display_transform_fingerprint(&channel), + target_width: 320, + }; + + let zoom_changed = GraphRenderFingerprint { + x_bounds: (1.0f64.to_bits(), 6.0f64.to_bits()), + ..base.clone() + }; + assert!(base != zoom_changed); + + let mut transformed_channel = sample_channel(Arc::from(vec![0.0, 1.0, 2.0])); + transformed_channel.display_scale = 2.0; + let transform_changed = GraphRenderFingerprint { + transform: display_transform_fingerprint(&transformed_channel), + ..base.clone() + }; + assert!(base != transform_changed); + + let replaced_channel = sample_channel(Arc::from(vec![0.0, 1.0, 2.0])); + let replacement_changed = GraphRenderFingerprint { + data_ptr: replaced_channel.data.as_ptr() as usize, + ..base + }; + assert!(transform_changed != replacement_changed); + } + + #[test] + fn lap_render_fingerprint_invalidates_for_zoom_and_overlay_offset() { + let base = LapRenderFingerprint { + data_ptr: 11, + data_len: 64, + lap_bounds: (1.0f64.to_bits(), 2.0f64.to_bits()), + x_bounds: (0.0f64.to_bits(), 10.0f64.to_bits()), + axis: GraphAxisFingerprint::Time, + origin_axis_bits: 0.0f64.to_bits(), + x_scale_bits: 1.0f64.to_bits(), + x_offset_bits: 0.0f64.to_bits(), + transform: super::DisplayTransformFingerprint { + scale_bits: 1.0f64.to_bits(), + offset_bits: 0.0f64.to_bits(), + unit: None, + }, + target_width: 400, + }; + + let zoom_changed = LapRenderFingerprint { + x_bounds: (2.0f64.to_bits(), 12.0f64.to_bits()), + ..base.clone() + }; + let offset_changed = LapRenderFingerprint { + x_offset_bits: 5.0f64.to_bits(), + ..base.clone() + }; + + assert!(base != zoom_changed); + assert!(base != offset_changed); + } + + #[test] + fn reset_for_new_session_clears_all_render_caches() { + let mut panel = GraphPanel::new(7, "Graph"); + panel.render_cache.insert( + ChannelId::Physical(0), + super::GraphRenderCacheEntry { + fingerprint: GraphRenderFingerprint { + data_ptr: 1, + data_len: 2, + x_bounds: (0, 1), + axis: GraphAxisFingerprint::Time, + transform: super::DisplayTransformFingerprint { + scale_bits: 1.0f64.to_bits(), + offset_bits: 0.0f64.to_bits(), + unit: None, + }, + target_width: 100, + }, + plot_points: Vec::new(), + }, + ); + panel.lap_render_cache.insert( + super::LapRenderCacheKey::Overlay { + channel_id: ChannelId::Physical(0), + overlay_idx: 0, + }, + super::LapRenderCacheEntry { + fingerprint: LapRenderFingerprint { + data_ptr: 1, + data_len: 2, + lap_bounds: (0, 1), + x_bounds: (0, 1), + axis: GraphAxisFingerprint::Time, + origin_axis_bits: 0, + x_scale_bits: 0, + x_offset_bits: 0, + transform: super::DisplayTransformFingerprint { + scale_bits: 1, + offset_bits: 0, + unit: None, + }, + target_width: 10, + }, + plot_points: Vec::new(), + }, + ); + panel.lap_overlays.push(super::LapOverlay { + source: OverlaySource::MainSession, + lap_idx: 0, + manual_offset: 0.0, + stretch_to_reference: false, + }); + + panel.reset_for_new_main_session(); + + assert!(panel.render_cache.is_empty()); + assert!(panel.lap_render_cache.is_empty()); + assert!(panel.lap_overlays.is_empty()); + } } diff --git a/crates/i3rs-app/src/panels/histogram.rs b/crates/i3rs-app/src/panels/histogram.rs index 6ae3575..0e00e17 100644 --- a/crates/i3rs-app/src/panels/histogram.rs +++ b/crates/i3rs-app/src/panels/histogram.rs @@ -1,7 +1,5 @@ //! Histogram panel: distribution of channel values with per-lap breakdown. -use std::sync::Arc; - use eframe::egui; use egui_plot::{Bar, BarChart, Legend, Plot}; @@ -9,7 +7,7 @@ use crate::state::{CHANNEL_COLORS, ChannelId, PlottedChannel, SharedState}; use super::utils::{ DisplayTransformFingerprint, build_plotted_channel_info, create_plotted_channel, - display_transform_fingerprint, get_visible_slice, interp_at_time, + display_transform_fingerprint, get_visible_slice, interp_at_time, refresh_plotted_channel, resolve_plotted_channel_display_meta, segmented_channel_button, show_plotted_channel_display_menu, transform_channel_value, }; @@ -113,6 +111,10 @@ impl HistogramPanel { } } + for channel in &mut self.channels { + refresh_plotted_channel(channel, shared); + } + if self.channels.is_empty() { ui.centered_and_justified(|ui| { ui.label("Drag a channel here to see its distribution"); @@ -220,7 +222,7 @@ impl HistogramPanel { .channels .iter() .map(|pc| HistogramChannelFingerprint { - data_ptr: Arc::as_ptr(&pc.data) as usize, + data_ptr: pc.data.as_ptr() as usize, display_transform: display_transform_fingerprint(pc), }) .collect(); diff --git a/crates/i3rs-app/src/panels/math_editor.rs b/crates/i3rs-app/src/panels/math_editor.rs index 7ea3da2..f02657c 100644 --- a/crates/i3rs-app/src/panels/math_editor.rs +++ b/crates/i3rs-app/src/panels/math_editor.rs @@ -5,7 +5,8 @@ use i3rs_core::math_engine::ChannelData; use std::collections::HashMap; use std::sync::Arc; -use crate::state::{MathChannelDef, SharedState}; +use crate::background_jobs::EvaluatedMathChannel; +use crate::state::{MathChannelDef, MathEvaluationState, SharedState}; /// A predefined math channel template. struct PredefinedCalc { @@ -172,14 +173,14 @@ pub fn show(ui: &mut egui::Ui, shared: &mut SharedState, editor: &mut MathEditor ) .clicked() { - let mut mc = MathChannelDef::new( + let math_channel = shared.create_math_channel_def( editor.new_name.clone(), editor.new_expression.clone(), editor.new_unit.clone(), 2, ); - evaluate_math_channel(&mut mc, shared); - shared.math_channels.push(mc); + shared.math_channels.push(math_channel); + queue_math_channel_evaluation(shared, shared.math_channels.len() - 1); shared.invalidate_derived_caches(); editor.new_name.clear(); editor.new_expression.clear(); @@ -292,11 +293,14 @@ pub fn show(ui: &mut egui::Ui, shared: &mut SharedState, editor: &mut MathEditor }); if let Some(idx) = to_reevaluate { - evaluate_single_math_channel(shared, idx); + queue_math_channel_evaluation(shared, idx); shared.invalidate_derived_caches(); } if let Some(idx) = to_remove { + if let Some(math_id) = shared.math_channels.get(idx).map(|mc| mc.id) { + shared.cancel_math_channel_evaluation(math_id); + } shared.math_channels.remove(idx); shared.invalidate_derived_caches(); } @@ -372,14 +376,20 @@ pub fn show(ui: &mut egui::Ui, shared: &mut SharedState, editor: &mut MathEditor fn build_channel_data_map( shared: &SharedState, expression: &str, - exclude_idx: usize, -) -> HashMap { + exclude_math_id: u64, +) -> EvaluationInputs { let mut channel_data: HashMap = HashMap::new(); + let mut waiting_on_inputs = false; // Parse expression to find which channels are referenced let refs: Vec = match i3rs_core::parse_expression(expression) { Ok(expr) => i3rs_core::referenced_channels(&expr), - Err(_) => return channel_data, + Err(_) => { + return EvaluationInputs { + channel_data, + waiting_on_inputs: false, + }; + } }; // Expand alias targets so we also load channels referenced indirectly @@ -400,88 +410,83 @@ fn build_channel_data_map( || r.replace('_', ".") == ch.name || r.eq_ignore_ascii_case(&ch.name) }); - if needed && let Some(data) = ld.read_channel_data(ch) { + if needed { + if let Some(decoded) = shared.decoded_physical_channel_if_ready(ch.index) { + channel_data.insert( + ch.name.clone(), + ChannelData { + samples: decoded.data.to_vec(), + freq: ch.freq, + }, + ); + } else { + shared.request_physical_channel_decode(ch.index); + waiting_on_inputs = true; + } + } + } + } + + // Add other evaluated math channels that are referenced + for other in &shared.math_channels { + if other.id != exclude_math_id && resolved_refs.iter().any(|r| r == &other.name) { + if let Some(ref data) = other.data { channel_data.insert( - ch.name.clone(), + other.name.clone(), ChannelData { - samples: data, - freq: ch.freq, + samples: data.to_vec(), + freq: other.freq, }, ); + } else if matches!( + other.evaluation_state, + MathEvaluationState::Queued + | MathEvaluationState::WaitingForInputs + | MathEvaluationState::Running + ) { + waiting_on_inputs = true; } } } - // Add other evaluated math channels that are referenced - for (i, other) in shared.math_channels.iter().enumerate() { - if i != exclude_idx - && resolved_refs.iter().any(|r| r == &other.name) - && let Some(ref data) = other.data - { - channel_data.insert( - other.name.clone(), - ChannelData { - samples: (**data).clone(), - freq: other.freq, - }, - ); - } + EvaluationInputs { + channel_data, + waiting_on_inputs, } - - channel_data } -/// Evaluate a single math channel definition (used when adding a new one). -pub fn evaluate_math_channel(mc: &mut MathChannelDef, shared: &SharedState) { - let channel_data = build_channel_data_map(shared, &mc.expression, usize::MAX); - eval_mc(mc, &channel_data, &shared.channel_aliases); +pub struct MathEvaluationJobInput { + pub math_id: u64, + pub expression: String, + pub aliases: HashMap, + pub channel_data: HashMap, } -/// Evaluate a single math channel by index within shared.math_channels. -fn evaluate_single_math_channel(shared: &mut SharedState, idx: usize) { - let expr = shared.math_channels[idx].expression.clone(); - let channel_data = build_channel_data_map(shared, &expr, idx); - let aliases = shared.channel_aliases.clone(); - eval_mc(&mut shared.math_channels[idx], &channel_data, &aliases); -} - -fn eval_mc( +fn mark_math_channel_status( mc: &mut MathChannelDef, - channel_data: &HashMap, - aliases: &HashMap, + evaluation_state: MathEvaluationState, + message: Option<&str>, ) { - match i3rs_core::evaluate_expression_with_aliases(&mc.expression, channel_data, aliases) { - Ok((samples, freq)) => { - let (min, max, avg, _) = crate::state::compute_channel_stats(&samples); - mc.freq = freq; - mc.data = Some(Arc::new(samples)); - mc.error = None; - mc.cached_min = min; - mc.cached_max = max; - mc.cached_avg = avg; - } - Err(e) => { - mc.data = None; - mc.error = Some(e); - } - } + mc.data = None; + mc.freq = 0; + mc.error = message.map(str::to_string); + mc.evaluation_state = evaluation_state; + mc.cached_min = 0.0; + mc.cached_max = 0.0; + mc.cached_avg = 0.0; } /// Evaluate all math channels in dependency order (topological sort). pub fn evaluate_all_math_channels(shared: &mut SharedState) { - let order = topological_eval_order(shared); - let aliases = shared.channel_aliases.clone(); - for i in order { - let expr = shared.math_channels[i].expression.clone(); - let channel_data = build_channel_data_map(shared, &expr, i); - eval_mc(&mut shared.math_channels[i], &channel_data, &aliases); + for idx in topological_eval_order(shared) { + queue_math_channel_evaluation(shared, idx); } shared.invalidate_derived_caches(); } /// Compute a topological evaluation order for math channels based on their dependencies. /// Falls back to original index order for channels involved in cycles. -fn topological_eval_order(shared: &SharedState) -> Vec { +pub fn topological_eval_order(shared: &SharedState) -> Vec { let n = shared.math_channels.len(); if n == 0 { return Vec::new(); @@ -549,6 +554,89 @@ fn topological_eval_order(shared: &SharedState) -> Vec { order } +pub fn queue_math_channel_evaluation(shared: &mut SharedState, idx: usize) { + let Some(mc) = shared.math_channels.get_mut(idx) else { + return; + }; + let math_id = mc.id; + mark_math_channel_status( + mc, + MathEvaluationState::Queued, + Some("Queued for evaluation..."), + ); + shared.request_math_channel_evaluation_by_id(math_id); +} + +pub fn build_math_evaluation_job( + shared: &mut SharedState, + math_id: u64, +) -> Option { + let idx = shared.math_channel_index_by_id(math_id)?; + let expression = shared.math_channels.get(idx)?.expression.clone(); + let inputs = build_channel_data_map(shared, &expression, math_id); + if inputs.waiting_on_inputs { + if let Some(mc) = shared.math_channels.get_mut(idx) { + mark_math_channel_status( + mc, + MathEvaluationState::WaitingForInputs, + Some("Waiting for source channels..."), + ); + } + return None; + } + + if let Some(mc) = shared.math_channels.get_mut(idx) { + mark_math_channel_status(mc, MathEvaluationState::Running, Some("Evaluating...")); + } + + Some(MathEvaluationJobInput { + math_id, + expression, + aliases: shared.channel_aliases.clone(), + channel_data: inputs.channel_data, + }) +} + +pub fn apply_math_evaluation_result( + shared: &mut SharedState, + math_id: u64, + expression: &str, + result: Result, +) -> bool { + let Some(idx) = shared.math_channel_index_by_id(math_id) else { + return false; + }; + let Some(mc) = shared.math_channels.get_mut(idx) else { + return false; + }; + if mc.expression != expression { + return false; + } + + match result { + Ok(evaluated) => { + mc.freq = evaluated.freq; + mc.data = Some(Arc::from(evaluated.samples)); + mc.error = None; + mc.evaluation_state = MathEvaluationState::Ready; + mc.cached_min = evaluated.stats.min; + mc.cached_max = evaluated.stats.max; + mc.cached_avg = evaluated.stats.avg; + } + Err(err) => { + mc.data = None; + mc.freq = 0; + mc.error = Some(err); + mc.evaluation_state = MathEvaluationState::Error; + mc.cached_min = 0.0; + mc.cached_max = 0.0; + mc.cached_avg = 0.0; + } + } + + true +} + /// Save math channels to a JSON file. pub fn save_math_channels(shared: &SharedState) { let configs: Vec = shared @@ -578,7 +666,7 @@ pub fn load_math_channels(shared: &mut SharedState) { match serde_json::from_str::>(&json) { Ok(configs) => { for config in configs { - let mc = MathChannelDef::new( + let mc = shared.create_math_channel_def( config.name, config.expression, config.unit, @@ -623,3 +711,36 @@ fn is_duplicate_name(name: &str, shared: &SharedState, exclude_math_idx: Option< } false } +struct EvaluationInputs { + channel_data: HashMap, + waiting_on_inputs: bool, +} + +#[cfg(test)] +mod tests { + use super::{build_math_evaluation_job, queue_math_channel_evaluation}; + use crate::state::SharedState; + + #[test] + fn math_jobs_follow_channel_ids_after_list_reordering() { + let mut shared = SharedState::new(); + let first = shared.create_math_channel_def("A".into(), "1".into(), String::new(), 2); + let second = shared.create_math_channel_def("B".into(), "2".into(), String::new(), 2); + let second_id = second.id; + shared.math_channels.push(first); + shared.math_channels.push(second); + + queue_math_channel_evaluation(&mut shared, 1); + assert_eq!( + shared.take_requested_math_channel_evaluations(), + vec![second_id] + ); + + shared.math_channels.remove(0); + + let job = build_math_evaluation_job(&mut shared, second_id) + .expect("remaining math channel should still be addressable by id"); + assert_eq!(job.math_id, second_id); + assert_eq!(shared.math_channels[0].name, "B"); + } +} diff --git a/crates/i3rs-app/src/panels/mixture_map.rs b/crates/i3rs-app/src/panels/mixture_map.rs index 55b0322..1582c16 100644 --- a/crates/i3rs-app/src/panels/mixture_map.rs +++ b/crates/i3rs-app/src/panels/mixture_map.rs @@ -4,16 +4,15 @@ //! The panel bins the data into a 2D grid and colors each cell by the //! average value channel reading in that bin. -use std::sync::Arc; - use eframe::egui; use crate::state::{ChannelId, PlottedChannel, SharedState}; use super::utils::{ DisplayTransformFingerprint, build_plotted_channel_info, create_plotted_channel, - display_transform_fingerprint, interp_at_time, resolve_plotted_channel_display_meta, - segmented_channel_button, show_plotted_channel_display_menu, transform_channel_value, + display_transform_fingerprint, interp_at_time, refresh_plotted_channel, + resolve_plotted_channel_display_meta, segmented_channel_button, + show_plotted_channel_display_menu, transform_channel_value, }; #[derive(PartialEq, Eq)] @@ -99,6 +98,16 @@ impl MixtureMapPanel { self.add_channel(ch_id, shared); } + if let Some(channel) = self.x_channel.as_mut() { + refresh_plotted_channel(channel, shared); + } + if let Some(channel) = self.y_channel.as_mut() { + refresh_plotted_channel(channel, shared); + } + if let Some(channel) = self.value_channel.as_mut() { + refresh_plotted_channel(channel, shared); + } + // Toolbar ui.horizontal(|ui| { ui.label("X:"); @@ -213,9 +222,9 @@ impl MixtureMapPanel { let zoom_key = shared.zoom_range.map(|(a, b)| (a.to_bits(), b.to_bits())); let fingerprint = HeatmapFingerprint { - x_data_ptr: Arc::as_ptr(&x_ch.data) as usize, - y_data_ptr: Arc::as_ptr(&y_ch.data) as usize, - value_data_ptr: Arc::as_ptr(&v_ch.data) as usize, + x_data_ptr: x_ch.data.as_ptr() as usize, + y_data_ptr: y_ch.data.as_ptr() as usize, + value_data_ptr: v_ch.data.as_ptr() as usize, zoom_key, bins: self.bins, x_transform: display_transform_fingerprint(x_ch), diff --git a/crates/i3rs-app/src/panels/report.rs b/crates/i3rs-app/src/panels/report.rs index 954cb6a..bd8137e 100644 --- a/crates/i3rs-app/src/panels/report.rs +++ b/crates/i3rs-app/src/panels/report.rs @@ -54,18 +54,18 @@ impl ReportPanel { for cached in &shared.report_cache.stats { let dec = cached.dec_places.max(0) as usize; - let (min, max, avg, stddev) = cached.session; + let session = cached.session; ui.colored_label(cached.color, &cached.name); ui.label("All"); - ui.monospace(format!("{:.prec$}", min, prec = dec)); - ui.monospace(format!("{:.prec$}", max, prec = dec)); - ui.monospace(format!("{:.prec$}", avg, prec = dec)); - ui.monospace(format!("{:.prec$}", stddev, prec = dec)); + ui.monospace(format!("{:.prec$}", session.min, prec = dec)); + ui.monospace(format!("{:.prec$}", session.max, prec = dec)); + ui.monospace(format!("{:.prec$}", session.avg, prec = dec)); + ui.monospace(format!("{:.prec$}", session.stddev, prec = dec)); ui.end_row(); // Per-lap stats - for (lap_name, lmin, lmax, lavg, lstddev) in &cached.per_lap { + for (lap_name, lap_stats) in &cached.per_lap { let is_selected = shared .selected_lap .and_then(|idx| shared.laps.get(idx)) @@ -78,10 +78,10 @@ impl ReportPanel { } else { ui.label(label); } - ui.monospace(format!("{:.prec$}", lmin, prec = dec)); - ui.monospace(format!("{:.prec$}", lmax, prec = dec)); - ui.monospace(format!("{:.prec$}", lavg, prec = dec)); - ui.monospace(format!("{:.prec$}", lstddev, prec = dec)); + ui.monospace(format!("{:.prec$}", lap_stats.min, prec = dec)); + ui.monospace(format!("{:.prec$}", lap_stats.max, prec = dec)); + ui.monospace(format!("{:.prec$}", lap_stats.avg, prec = dec)); + ui.monospace(format!("{:.prec$}", lap_stats.stddev, prec = dec)); ui.end_row(); } } diff --git a/crates/i3rs-app/src/panels/scatter.rs b/crates/i3rs-app/src/panels/scatter.rs index d8695df..e15e175 100644 --- a/crates/i3rs-app/src/panels/scatter.rs +++ b/crates/i3rs-app/src/panels/scatter.rs @@ -1,7 +1,5 @@ //! Scatter/XY plot panel: channel vs channel visualization. -use std::sync::Arc; - use eframe::egui; use egui_plot::{Legend, MarkerShape, Plot, PlotBounds, PlotPoint, PlotPoints, Points}; @@ -9,8 +7,9 @@ use crate::state::{CHANNEL_COLORS, ChannelId, PlottedChannel, SharedState}; use super::utils::{ DisplayTransformFingerprint, build_plotted_channel_info, create_plotted_channel, - display_transform_fingerprint, interp_at_time, resolve_plotted_channel_display_meta, - segmented_channel_button, show_plotted_channel_display_menu, transform_channel_value, + display_transform_fingerprint, interp_at_time, refresh_plotted_channel, + resolve_plotted_channel_display_meta, segmented_channel_button, + show_plotted_channel_display_menu, transform_channel_value, }; #[derive(PartialEq, Eq)] @@ -89,6 +88,8 @@ impl ScatterPanel { } pub fn ui(&mut self, ui: &mut egui::Ui, shared: &mut SharedState) { + let _perf = crate::perf_metrics::scope("scatter draw"); + // Handle drop from channel browser if shared.dragging_channel.is_some() && ui.input(|i| i.pointer.any_released()) @@ -103,6 +104,13 @@ impl ScatterPanel { self.add_channel(ch_id, shared); } + if let Some(channel) = self.x_channel.as_mut() { + refresh_plotted_channel(channel, shared); + } + if let Some(channel) = self.y_channel.as_mut() { + refresh_plotted_channel(channel, shared); + } + // Toolbar ui.horizontal(|ui| { ui.label("X:"); @@ -204,9 +212,9 @@ impl ScatterPanel { let zoom_key = shared.zoom_range.map(|(a, b)| (a.to_bits(), b.to_bits())); let fingerprint = ScatterFingerprint { - x_data_ptr: Arc::as_ptr(&x_ch.data) as usize, + x_data_ptr: x_ch.data.as_ptr() as usize, x_data_len: x_ch.data.len(), - y_data_ptr: Arc::as_ptr(&y_ch.data) as usize, + y_data_ptr: y_ch.data.as_ptr() as usize, y_data_len: y_ch.data.len(), zoom_key, x_transform: display_transform_fingerprint(x_ch), @@ -253,7 +261,7 @@ impl ScatterPanel { plot.show(ui, |plot_ui| { plot_ui.points( - Points::new(&series_name, PlotPoints::Owned(plot_points.clone())) + Points::new(&series_name, PlotPoints::Borrowed(plot_points.as_slice())) .shape(MarkerShape::Circle) .filled(false) .radius(point_size) diff --git a/crates/i3rs-app/src/panels/track_map.rs b/crates/i3rs-app/src/panels/track_map.rs index 86a6c29..a8690be 100644 --- a/crates/i3rs-app/src/panels/track_map.rs +++ b/crates/i3rs-app/src/panels/track_map.rs @@ -3,10 +3,9 @@ use std::sync::Arc; use eframe::egui; -use egui_plot::{Line, MarkerShape, Plot, PlotPoints, Points}; +use egui_plot::{Line, MarkerShape, Plot, PlotPoint, PlotPoints, Points}; use i3rs_core::{ - Sector, SectorTime, TrackData, compute_color_map, compute_sector_times, extract_gps_track, - find_nearest_sample, + Sector, SectorTime, TrackData, compute_color_map, compute_sector_times, find_nearest_sample, }; use crate::state::{CHANNEL_COLORS, SharedState}; @@ -17,8 +16,8 @@ pub struct TrackMapPanel { track_data: Option>, /// Channel index for rainbow coloring (None = solid color). pub color_channel_idx: Option, - /// Cached per-sample RGBA colors (wrapped in Arc to avoid per-frame clone). - cached_colors: Option>>, + /// Cached per-sample RGBA colors. + cached_colors: Option>, cached_color_range: Option<(f64, f64)>, color_channel_name: String, editing_sectors: bool, @@ -27,6 +26,9 @@ pub struct TrackMapPanel { cached_sector_times: Option, /// Fingerprint for track/color cache invalidation. cache_fingerprint: Option<(usize, Option)>, + track_line_cache: Option, + cursor_marker_cache: Option, + sector_marker_cache: Option, /// Search filter for the color channel dropdown. color_filter: String, /// Whether this panel is currently in a popped-out OS window. @@ -43,6 +45,55 @@ struct CachedSectorReport { times: Vec>, } +#[derive(Clone, Copy, PartialEq, Eq)] +struct TrackLineFingerprint { + track_ptr: usize, + track_len: usize, + colors_ptr: usize, + colors_len: usize, +} + +struct CachedTrackLine { + fingerprint: TrackLineFingerprint, + solid_points: Vec, + colored_segments: Vec, +} + +struct CachedColoredTrackSegment { + color: egui::Color32, + points: [PlotPoint; 2], +} + +#[derive(Clone, Copy, PartialEq, Eq)] +struct TrackMarkerFingerprint { + track_ptr: usize, + track_len: usize, + sample_idx: usize, +} + +struct CachedTrackMarker { + fingerprint: TrackMarkerFingerprint, + points: [PlotPoint; 1], +} + +#[derive(Clone, PartialEq, Eq)] +struct SectorMarkersFingerprint { + track_ptr: usize, + track_len: usize, + start_indices: Vec, +} + +struct CachedSectorMarkers { + fingerprint: SectorMarkersFingerprint, + markers: Vec, +} + +struct CachedSectorMarker { + name: String, + color: egui::Color32, + points: [PlotPoint; 1], +} + impl TrackMapPanel { pub fn new(id: u64, title: impl Into) -> Self { Self { @@ -57,6 +108,9 @@ impl TrackMapPanel { pending_sector_start: None, cached_sector_times: None, cache_fingerprint: None, + track_line_cache: None, + cursor_marker_cache: None, + sector_marker_cache: None, color_filter: String::new(), is_popped_out: false, pop_out_requested: false, @@ -72,9 +126,12 @@ impl TrackMapPanel { self.cached_sector_times = None; self.color_channel_idx = None; self.cache_fingerprint = None; + self.clear_render_caches(); } pub fn ui(&mut self, ui: &mut egui::Ui, shared: &mut SharedState) { + let _perf = crate::perf_metrics::scope("track-map draw"); + // If popped out, handle OS window close → dock back if self.is_popped_out && ui.input(|i| i.viewport().close_requested()) { ui.ctx() @@ -86,7 +143,16 @@ impl TrackMapPanel { let Some(track) = &self.track_data else { ui.centered_and_justified(|ui| { - ui.label("No GPS data found (requires GPS Latitude and GPS Longitude channels)"); + if shared.is_track_data_build_pending() { + ui.horizontal(|ui| { + ui.spinner(); + ui.label("Building GPS track..."); + }); + } else { + ui.label( + "No GPS data found (requires GPS Latitude and GPS Longitude channels)", + ); + } }); return; }; @@ -119,18 +185,23 @@ impl TrackMapPanel { .show_axes(false) .show_grid(false); - let colors_ref = self.cached_colors.clone(); - + let colors = self.cached_colors.clone(); let response = plot.show(ui, |plot_ui| { - Self::draw_track_line(plot_ui, &track, colors_ref.as_deref()); + Self::draw_track_line( + plot_ui, + &mut self.track_line_cache, + &track, + colors.as_deref(), + ); - Self::draw_sector_markers(plot_ui, &track, sectors); + Self::draw_sector_markers(plot_ui, &mut self.sector_marker_cache, &track, sectors); if let Some(t) = cursor_time { - Self::draw_cursor_marker(plot_ui, &track, t); + Self::draw_cursor_marker(plot_ui, &mut self.cursor_marker_cache, &track, t); } if let Some(coord) = plot_ui.pointer_coordinate() { + let _hover_perf = crate::perf_metrics::scope("track hover lookup"); let idx = find_nearest_sample(&track, coord.x, coord.y); hover_idx = Some(idx); @@ -181,19 +252,32 @@ impl TrackMapPanel { .unwrap_or(true); if track_stale { - if let Some(ld) = &shared.ld_file { - self.track_data = extract_gps_track(ld).map(Arc::new); + if shared.ld_file.is_some() { + if let Some(track_data) = shared.track_data_if_ready() { + self.track_data = Some(track_data); + } else { + shared.request_track_data_build(); + self.track_data = None; + } } else { self.track_data = None; } self.cached_colors = None; self.cached_color_range = None; self.cached_sector_times = None; + self.clear_render_caches(); + } else if self.track_data.is_none() { + if let Some(track_data) = shared.track_data_if_ready() { + self.track_data = Some(track_data); + } else if shared.ld_file.is_some() { + shared.request_track_data_build(); + } } if self.cache_fingerprint != Some(fingerprint) { self.cached_colors = None; self.cached_color_range = None; + self.track_line_cache = None; } self.cache_fingerprint = Some(fingerprint); @@ -214,82 +298,113 @@ impl TrackMapPanel { let Some(ch) = ld.channels.get(ch_idx) else { return; }; - let Some(data) = ld.read_channel_data(ch) else { + let Some(decoded) = shared.decoded_physical_channel_if_ready(ch_idx) else { + shared.request_physical_channel_decode(ch_idx); return; }; self.color_channel_name = ch.name.clone(); - let (colors, vmin, vmax) = compute_color_map(track, &data, ch.freq); + let (colors, vmin, vmax) = compute_color_map(track, &decoded.data, ch.freq); self.cached_color_range = Some((vmin, vmax)); - self.cached_colors = Some(Arc::new(colors)); + self.cached_colors = Some(Arc::from(colors)); + self.track_line_cache = None; } - fn draw_track_line( - plot_ui: &mut egui_plot::PlotUi, + fn draw_track_line<'a>( + plot_ui: &mut egui_plot::PlotUi<'a>, + cache: &'a mut Option, track: &TrackData, - colors: Option<&Vec<[u8; 4]>>, + colors: Option<&[[u8; 4]]>, ) { - if let Some(colors) = colors { - for (i, c) in colors - .iter() - .enumerate() - .take(track.x.len().saturating_sub(1)) - { - let segment = Line::new( - "", - PlotPoints::new(vec![ - [track.x[i], track.y[i]], - [track.x[i + 1], track.y[i + 1]], - ]), + let fingerprint = TrackLineFingerprint { + track_ptr: track as *const TrackData as usize, + track_len: track.x.len(), + colors_ptr: colors.map_or(0, |colors| colors.as_ptr() as usize), + colors_len: colors.map_or(0, |colors| colors.len()), + }; + + let cached = + cache.get_or_insert_with(|| CachedTrackLine::build(track, colors, fingerprint)); + if cached.fingerprint != fingerprint { + *cached = CachedTrackLine::build(track, colors, fingerprint); + } + + if cached.colored_segments.is_empty() { + if !cached.solid_points.is_empty() { + let line = Line::new( + "Track", + PlotPoints::Borrowed(cached.solid_points.as_slice()), ) - .width(3.0) - .color(egui::Color32::from_rgb(c[0], c[1], c[2])); - plot_ui.line(segment); - } - } else { - let points: Vec<[f64; 2]> = track - .x - .iter() - .zip(track.y.iter()) - .map(|(&x, &y)| [x, y]) - .collect(); - let line = Line::new("Track", PlotPoints::new(points)) .width(2.5) .color(egui::Color32::from_rgb(50, 255, 200)); - plot_ui.line(line); + plot_ui.line(line); + } + } else { + for segment in &cached.colored_segments { + let line = Line::new("", PlotPoints::Borrowed(segment.points.as_slice())) + .width(3.0) + .color(segment.color); + plot_ui.line(line); + } } } - fn draw_cursor_marker(plot_ui: &mut egui_plot::PlotUi, track: &TrackData, time: f64) { + fn draw_cursor_marker<'a>( + plot_ui: &mut egui_plot::PlotUi<'a>, + cache: &'a mut Option, + track: &TrackData, + time: f64, + ) { let sample_idx = (time * track.freq as f64).round() as usize; let sample_idx = sample_idx.min(track.x.len().saturating_sub(1)); - if sample_idx < track.x.len() { - let marker = Points::new( - "cursor", - PlotPoints::new(vec![[track.x[sample_idx], track.y[sample_idx]]]), - ) + if sample_idx >= track.x.len() { + return; + } + + let fingerprint = TrackMarkerFingerprint { + track_ptr: track as *const TrackData as usize, + track_len: track.x.len(), + sample_idx, + }; + let cached = + cache.get_or_insert_with(|| CachedTrackMarker::build(track, sample_idx, fingerprint)); + if cached.fingerprint != fingerprint { + *cached = CachedTrackMarker::build(track, sample_idx, fingerprint); + } + + let marker = Points::new("cursor", PlotPoints::Borrowed(cached.points.as_slice())) .shape(MarkerShape::Circle) .radius(6.0) .color(egui::Color32::from_rgb(255, 255, 0)) .filled(true); - plot_ui.points(marker); - } + plot_ui.points(marker); } - fn draw_sector_markers(plot_ui: &mut egui_plot::PlotUi, track: &TrackData, sectors: &[Sector]) { - for (i, sector) in sectors.iter().enumerate() { - let color = CHANNEL_COLORS[i % CHANNEL_COLORS.len()]; - - if sector.start_index < track.x.len() { - let pt = vec![[track.x[sector.start_index], track.y[sector.start_index]]]; - let marker = Points::new(format!("{} start", sector.name), PlotPoints::new(pt)) - .shape(MarkerShape::Diamond) - .radius(8.0) - .color(color) - .filled(true); - plot_ui.points(marker); - } + fn draw_sector_markers<'a>( + plot_ui: &mut egui_plot::PlotUi<'a>, + cache: &'a mut Option, + track: &TrackData, + sectors: &[Sector], + ) { + let fingerprint = SectorMarkersFingerprint { + track_ptr: track as *const TrackData as usize, + track_len: track.x.len(), + start_indices: sectors.iter().map(|sector| sector.start_index).collect(), + }; + let cached = cache + .get_or_insert_with(|| CachedSectorMarkers::build(track, sectors, fingerprint.clone())); + if cached.fingerprint != fingerprint { + *cached = CachedSectorMarkers::build(track, sectors, fingerprint); + } + + for marker in &cached.markers { + let points = Points::new(&marker.name, PlotPoints::Borrowed(marker.points.as_slice())) + .shape(MarkerShape::Diamond) + .radius(8.0) + .color(marker.color) + .filled(true); + plot_ui.points(points); } } @@ -419,6 +534,13 @@ impl TrackMapPanel { self.cached_colors = None; self.cached_color_range = None; self.cache_fingerprint = None; + self.track_line_cache = None; + } + + fn clear_render_caches(&mut self) { + self.track_line_cache = None; + self.cursor_marker_cache = None; + self.sector_marker_cache = None; } fn draw_color_legend(ui: &mut egui::Ui, vmin: f64, vmax: f64) { @@ -558,6 +680,155 @@ impl TrackMapPanel { } } +impl CachedTrackLine { + fn build( + track: &TrackData, + colors: Option<&[[u8; 4]]>, + fingerprint: TrackLineFingerprint, + ) -> Self { + if let Some(colors) = colors { + let colored_segments = colors + .iter() + .enumerate() + .take(track.x.len().saturating_sub(1)) + .map(|(idx, color)| CachedColoredTrackSegment { + color: egui::Color32::from_rgb(color[0], color[1], color[2]), + points: [ + PlotPoint::new(track.x[idx], track.y[idx]), + PlotPoint::new(track.x[idx + 1], track.y[idx + 1]), + ], + }) + .collect(); + + Self { + fingerprint, + solid_points: Vec::new(), + colored_segments, + } + } else { + let solid_points = track + .x + .iter() + .zip(track.y.iter()) + .map(|(&x, &y)| PlotPoint::new(x, y)) + .collect(); + + Self { + fingerprint, + solid_points, + colored_segments: Vec::new(), + } + } + } +} + +impl CachedTrackMarker { + fn build(track: &TrackData, sample_idx: usize, fingerprint: TrackMarkerFingerprint) -> Self { + Self { + fingerprint, + points: [PlotPoint::new(track.x[sample_idx], track.y[sample_idx])], + } + } +} + +impl CachedSectorMarkers { + fn build(track: &TrackData, sectors: &[Sector], fingerprint: SectorMarkersFingerprint) -> Self { + let markers = sectors + .iter() + .enumerate() + .filter_map(|(idx, sector)| { + (sector.start_index < track.x.len()).then(|| CachedSectorMarker { + name: format!("{} start", sector.name), + color: CHANNEL_COLORS[idx % CHANNEL_COLORS.len()], + points: [PlotPoint::new( + track.x[sector.start_index], + track.y[sector.start_index], + )], + }) + }) + .collect(); + + Self { + fingerprint, + markers, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_panel() -> TrackMapPanel { + TrackMapPanel::new(1, "Track") + } + + #[test] + fn clear_cache_clears_track_render_caches() { + let mut panel = sample_panel(); + panel.track_line_cache = Some(CachedTrackLine { + fingerprint: TrackLineFingerprint { + track_ptr: 1, + track_len: 2, + colors_ptr: 3, + colors_len: 4, + }, + solid_points: vec![PlotPoint::new(0.0, 0.0)], + colored_segments: Vec::new(), + }); + panel.cursor_marker_cache = Some(CachedTrackMarker { + fingerprint: TrackMarkerFingerprint { + track_ptr: 1, + track_len: 2, + sample_idx: 0, + }, + points: [PlotPoint::new(0.0, 0.0)], + }); + panel.sector_marker_cache = Some(CachedSectorMarkers { + fingerprint: SectorMarkersFingerprint { + track_ptr: 1, + track_len: 2, + start_indices: vec![0], + }, + markers: Vec::new(), + }); + + panel.clear_cache(); + + assert!(panel.track_line_cache.is_none()); + assert!(panel.cursor_marker_cache.is_none()); + assert!(panel.sector_marker_cache.is_none()); + } + + #[test] + fn invalidate_color_cache_only_drops_track_line_geometry() { + let mut panel = sample_panel(); + panel.track_line_cache = Some(CachedTrackLine { + fingerprint: TrackLineFingerprint { + track_ptr: 1, + track_len: 2, + colors_ptr: 3, + colors_len: 4, + }, + solid_points: vec![PlotPoint::new(0.0, 0.0)], + colored_segments: Vec::new(), + }); + panel.cursor_marker_cache = Some(CachedTrackMarker { + fingerprint: TrackMarkerFingerprint { + track_ptr: 1, + track_len: 2, + sample_idx: 0, + }, + points: [PlotPoint::new(0.0, 0.0)], + }); + + panel.invalidate_color_cache(); + + assert!(panel.track_line_cache.is_none()); + assert!(panel.cursor_marker_cache.is_some()); + } +} + fn delta_color(delta: f64) -> egui::Color32 { if delta < -0.01 { egui::Color32::from_rgb(100, 255, 100) // green = faster diff --git a/crates/i3rs-app/src/panels/utils.rs b/crates/i3rs-app/src/panels/utils.rs index d61e325..b310a2c 100644 --- a/crates/i3rs-app/src/panels/utils.rs +++ b/crates/i3rs-app/src/panels/utils.rs @@ -7,13 +7,14 @@ use eframe::egui; use crate::state::{ CHANNEL_COLORS, ChannelId, ChannelPreference, PlottedChannel, PlottedChannelInfo, SharedState, - YAxis, channel_preference_key, compute_channel_stats, + YAxis, channel_preference_key, }; pub type ChannelDisplayMeta = (String, String, u16, i16, Arc>); static EMPTY_ENUM_LABELS: LazyLock>> = LazyLock::new(|| Arc::new(HashMap::new())); +static EMPTY_SAMPLES: LazyLock> = LazyLock::new(|| Arc::from(Vec::::new())); #[derive(Clone, Copy)] pub struct DisplayUnitPreset { @@ -187,16 +188,20 @@ pub fn get_visible_slice(data: &[f64], freq: u16, shared: &SharedState) -> Vec Option> { +/// Load raw channel data from a ChannelId, returning the immutable shared sample buffer. +pub fn load_channel_data(channel_id: ChannelId, shared: &SharedState) -> Option> { match channel_id { ChannelId::Physical(idx) => { - let ld = shared.ld_file.as_ref()?; - ld.read_channel_data(ld.channels.get(idx)?) + if let Some(decoded) = shared.decoded_physical_channel_if_ready(idx) { + Some(Arc::clone(&decoded.data)) + } else { + shared.request_physical_channel_decode(idx); + Some(Arc::clone(&EMPTY_SAMPLES)) + } } ChannelId::Math(idx) => { let mc = shared.math_channels.get(idx)?; - Some((**mc.data.as_ref()?).clone()) + Some(Arc::clone(mc.data.as_ref()?)) } } } @@ -208,24 +213,80 @@ pub fn create_plotted_channel( color_idx: usize, ) -> Option { let data = load_channel_data(channel_id, shared)?; - let (min, max, avg, _) = compute_channel_stats(&data); + let (cached_min, cached_max, cached_avg) = match channel_id { + ChannelId::Physical(idx) => { + if let Some(decoded) = shared.decoded_physical_channel_if_ready(idx) { + (decoded.stats.min, decoded.stats.max, decoded.stats.avg) + } else { + (0.0, 0.0, 0.0) + } + } + ChannelId::Math(idx) => { + let mc = shared.math_channels.get(idx)?; + (mc.cached_min, mc.cached_max, mc.cached_avg) + } + }; let mut plotted = PlottedChannel { channel_id, color: CHANNEL_COLORS[color_idx % CHANNEL_COLORS.len()], - data: Arc::new(data), + data, tile_group: color_idx, y_axis: YAxis::Left, display_scale: 1.0, display_offset: 0.0, display_unit: None, - cached_min: min, - cached_max: max, - cached_avg: avg, + cached_min, + cached_max, + cached_avg, }; apply_channel_preferences(&mut plotted, shared); Some(plotted) } +pub fn refresh_plotted_channel(channel: &mut PlottedChannel, shared: &SharedState) -> bool { + match channel.channel_id { + ChannelId::Physical(idx) => { + if let Some(decoded) = shared.decoded_physical_channel_if_ready(idx) { + let changed = channel.data.as_ptr() != decoded.data.as_ptr() + || channel.data.len() != decoded.data.len() + || channel.cached_min.to_bits() != decoded.stats.min.to_bits() + || channel.cached_max.to_bits() != decoded.stats.max.to_bits() + || channel.cached_avg.to_bits() != decoded.stats.avg.to_bits(); + if changed { + channel.data = Arc::clone(&decoded.data); + channel.cached_min = decoded.stats.min; + channel.cached_max = decoded.stats.max; + channel.cached_avg = decoded.stats.avg; + } + changed + } else { + shared.request_physical_channel_decode(idx); + false + } + } + ChannelId::Math(idx) => { + let Some(math_channel) = shared.math_channels.get(idx) else { + return false; + }; + let Some(data) = &math_channel.data else { + return false; + }; + let changed = channel.data.as_ptr() != data.as_ptr() + || channel.data.len() != data.len() + || channel.cached_min.to_bits() != math_channel.cached_min.to_bits() + || channel.cached_max.to_bits() != math_channel.cached_max.to_bits() + || channel.cached_avg.to_bits() != math_channel.cached_avg.to_bits(); + if changed { + channel.data = Arc::clone(data); + channel.cached_min = math_channel.cached_min; + channel.cached_max = math_channel.cached_max; + channel.cached_avg = math_channel.cached_avg; + } + changed + } + } +} + /// Build readout metadata for a plotted channel. pub fn build_plotted_channel_info( channel: &PlottedChannel, @@ -407,7 +468,7 @@ mod tests { let channel = PlottedChannel { channel_id: ChannelId::Physical(0), color: egui::Color32::WHITE, - data: Arc::new(vec![1.0]), + data: Arc::from(vec![1.0]), tile_group: 0, y_axis: YAxis::Left, display_scale: 2.0, diff --git a/crates/i3rs-app/src/perf_metrics.rs b/crates/i3rs-app/src/perf_metrics.rs new file mode 100644 index 0000000..ef1a905 --- /dev/null +++ b/crates/i3rs-app/src/perf_metrics.rs @@ -0,0 +1,115 @@ +#[cfg(all(feature = "perf_metrics", not(target_arch = "wasm32")))] +mod enabled { + use std::collections::{HashMap, VecDeque}; + use std::sync::{LazyLock, Mutex}; + use std::time::{Duration, Instant}; + + const MAX_SAMPLES_PER_SPAN: usize = 512; + const LOG_INTERVAL: Duration = Duration::from_secs(3); + + #[derive(Default)] + struct SpanSamples { + values_ms: VecDeque, + } + + impl SpanSamples { + fn push(&mut self, value_ms: f64) { + if self.values_ms.len() == MAX_SAMPLES_PER_SPAN { + self.values_ms.pop_front(); + } + self.values_ms.push_back(value_ms); + } + + fn percentile(&self, percentile: f64) -> f64 { + if self.values_ms.is_empty() { + return 0.0; + } + + let mut sorted: Vec = self.values_ms.iter().copied().collect(); + sorted.sort_by(f64::total_cmp); + let idx = ((sorted.len() - 1) as f64 * percentile).round() as usize; + sorted[idx.min(sorted.len() - 1)] + } + } + + struct Registry { + spans: HashMap<&'static str, SpanSamples>, + last_log: Instant, + } + + impl Default for Registry { + fn default() -> Self { + Self { + spans: HashMap::new(), + last_log: Instant::now(), + } + } + } + + static REGISTRY: LazyLock> = LazyLock::new(|| Mutex::new(Registry::default())); + + pub struct ScopeGuard { + name: &'static str, + start: Instant, + } + + impl Drop for ScopeGuard { + fn drop(&mut self) { + let elapsed_ms = self.start.elapsed().as_secs_f64() * 1000.0; + if let Ok(mut registry) = REGISTRY.lock() { + registry + .spans + .entry(self.name) + .or_default() + .push(elapsed_ms); + } + } + } + + pub fn scope(name: &'static str) -> ScopeGuard { + ScopeGuard { + name, + start: Instant::now(), + } + } + + pub fn maybe_log_summary() { + let Ok(mut registry) = REGISTRY.lock() else { + return; + }; + + if registry.last_log.elapsed() < LOG_INTERVAL || registry.spans.is_empty() { + return; + } + + let mut names: Vec<_> = registry.spans.keys().copied().collect(); + names.sort_unstable(); + + eprintln!("perf_metrics summary:"); + for name in names { + if let Some(samples) = registry.spans.get(name) { + eprintln!( + " {name}: n={} p50={:.2}ms p95={:.2}ms", + samples.values_ms.len(), + samples.percentile(0.50), + samples.percentile(0.95), + ); + } + } + + registry.last_log = Instant::now(); + } +} + +#[cfg(not(all(feature = "perf_metrics", not(target_arch = "wasm32"))))] +mod enabled { + pub struct ScopeGuard; + + pub fn scope(_name: &'static str) -> ScopeGuard { + ScopeGuard + } + + pub fn maybe_log_summary() {} +} + +pub use enabled::{maybe_log_summary, scope}; diff --git a/crates/i3rs-app/src/platform.rs b/crates/i3rs-app/src/platform.rs index 05c48c2..2841486 100644 --- a/crates/i3rs-app/src/platform.rs +++ b/crates/i3rs-app/src/platform.rs @@ -4,6 +4,10 @@ use std::path::PathBuf; #[cfg(target_arch = "wasm32")] use std::sync::mpsc::{Receiver, Sender, channel}; +#[cfg(not(target_arch = "wasm32"))] +use std::sync::mpsc::{ + Receiver as NativeReceiver, Sender as NativeSender, channel as native_channel, +}; #[cfg(target_arch = "wasm32")] #[derive(Debug)] @@ -16,6 +20,12 @@ pub enum WebLoadEvent { Error(String), } +#[cfg(not(target_arch = "wasm32"))] +#[derive(Debug)] +pub enum NativePickEvent { + SessionPath(PathBuf), +} + #[cfg(not(target_arch = "wasm32"))] fn native_dialog() -> rfd::FileDialog { rfd::FileDialog::new() @@ -124,6 +134,14 @@ pub fn web_load_channel() -> (Sender, Receiver) { channel() } +#[cfg(not(target_arch = "wasm32"))] +pub fn native_pick_channel() -> ( + NativeSender, + NativeReceiver, +) { + native_channel() +} + #[cfg(target_arch = "wasm32")] pub fn begin_pick_session(tx: Sender, ctx: egui::Context) { wasm_bindgen_futures::spawn_local(async move { @@ -168,6 +186,18 @@ pub fn begin_pick_session(tx: Sender, ctx: egui::Context) { } #[cfg(not(target_arch = "wasm32"))] -pub fn begin_pick_session(_ctx: &egui::Context) -> Option { - native_dialog().add_filter("MoTeC Log", &["ld"]).pick_file() +pub fn begin_pick_session(tx: NativeSender, ctx: egui::Context) { + std::thread::spawn(move || { + let picked = pollster::block_on( + rfd::AsyncFileDialog::new() + .set_title("Select a MoTeC .ld file") + .add_filter("MoTeC Log", &["ld"]) + .pick_file(), + ); + + if let Some(handle) = picked { + let _ = tx.send(NativePickEvent::SessionPath(handle.path().to_path_buf())); + ctx.request_repaint(); + } + }); } diff --git a/crates/i3rs-app/src/state.rs b/crates/i3rs-app/src/state.rs index 1d4f7d3..886ff29 100644 --- a/crates/i3rs-app/src/state.rs +++ b/crates/i3rs-app/src/state.rs @@ -1,11 +1,15 @@ //! Shared application state accessible by all panels. use eframe::egui; -use i3rs_core::{Lap, LdFile, LdxFile, Sector, format_state_value, is_state_channel}; +use i3rs_core::{ + DownsampledPoint, Lap, LdFile, LdxFile, Sector, TrackData, downsample_minmax, + format_state_value, is_state_channel, +}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; -use std::sync::Arc; +use std::sync::{Arc, RwLock}; /// Identifies a channel: either a physical channel from the .ld file or a math channel. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -27,7 +31,7 @@ pub enum YAxis { pub struct PlottedChannel { pub channel_id: ChannelId, pub color: egui::Color32, - pub data: Arc>, + pub data: Arc<[f64]>, pub tile_group: usize, pub y_axis: YAxis, pub display_scale: f64, @@ -48,7 +52,7 @@ pub struct PlottedChannelInfo { pub freq: u16, pub dec_places: i16, pub color: egui::Color32, - pub data: Arc>, + pub data: Arc<[f64]>, pub display_scale: f64, pub display_offset: f64, /// Enum/state labels parsed from the .ld file (value → label). @@ -79,23 +83,35 @@ impl PlottedChannelInfo { /// A user-defined math channel. pub struct MathChannelDef { + pub id: u64, pub name: String, pub expression: String, pub unit: String, pub dec_places: i16, pub freq: u16, /// Cached evaluation result. - pub data: Option>>, + pub data: Option>, /// Parse or evaluation error. pub error: Option, + pub evaluation_state: MathEvaluationState, pub cached_min: f64, pub cached_max: f64, pub cached_avg: f64, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MathEvaluationState { + Queued, + WaitingForInputs, + Running, + Ready, + Error, +} + impl MathChannelDef { - pub fn new(name: String, expression: String, unit: String, dec_places: i16) -> Self { + pub fn new(id: u64, name: String, expression: String, unit: String, dec_places: i16) -> Self { Self { + id, name, expression, unit, @@ -103,6 +119,7 @@ impl MathChannelDef { freq: 0, data: None, error: None, + evaluation_state: MathEvaluationState::Queued, cached_min: 0.0, cached_max: 0.0, cached_avg: 0.0, @@ -110,8 +127,104 @@ impl MathChannelDef { } } +#[derive(Clone, Copy, Debug, Default)] +pub struct ChannelStats { + pub min: f64, + pub max: f64, + pub avg: f64, + pub stddev: f64, +} + +pub struct DecodedChannel { + pub data: Arc<[f64]>, + pub stats: ChannelStats, + #[allow(dead_code)] + pub freq: u16, + lod_levels: RwLock>, +} + +#[derive(Clone)] +pub struct LodLevel { + pub points: Arc<[DownsampledPoint]>, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct DownsampleSeriesKey { + pub data_ptr: usize, + pub data_len: usize, + pub freq: u16, + pub start_sample: usize, + pub end_sample: usize, + pub target_width: usize, +} + +#[derive(Clone)] +pub struct DownsampleSeriesRequest { + pub key: DownsampleSeriesKey, + pub data: Arc<[f64]>, + pub freq: u16, + pub start_sample: usize, + pub end_sample: usize, + pub target_width: usize, +} + +impl DecodedChannel { + pub fn best_lod_level_for_view( + &self, + visible_sample_count: usize, + target_width: usize, + ) -> Option { + if visible_sample_count == 0 || target_width == 0 || self.data.is_empty() { + return None; + } + + self.ensure_lod_levels(); + + let desired_visible_points = target_width.saturating_mul(2) as f64; + let visible_fraction = visible_sample_count as f64 / self.data.len().max(1) as f64; + + let levels = self.lod_levels.read().ok()?; + levels + .iter() + .filter_map(|level| { + let estimated_visible_points = level.points.len() as f64 * visible_fraction; + (estimated_visible_points >= desired_visible_points) + .then_some((estimated_visible_points, level.clone())) + }) + .min_by(|a, b| a.0.total_cmp(&b.0)) + .map(|(_, level)| level) + } + + fn ensure_lod_levels(&self) { + if let Ok(levels) = self.lod_levels.read() + && !levels.is_empty() + { + return; + } + + if let Ok(mut levels) = self.lod_levels.write() { + if !levels.is_empty() { + return; + } + + for &target_buckets in &[2_048usize, 8_192, 32_768, 131_072] { + if self.data.len() > target_buckets.saturating_mul(2) { + levels.push(LodLevel { + points: Arc::from(downsample_minmax( + &self.data, + self.freq, + 0, + target_buckets, + )), + }); + } + } + } + } +} + /// Compute min, max, avg, stddev for a slice of finite f64 values. -pub fn compute_channel_stats(data: &[f64]) -> (f64, f64, f64, f64) { +pub fn compute_channel_stats(data: &[f64]) -> ChannelStats { let mut min = f64::MAX; let mut max = f64::MIN; let mut sum = 0.0; @@ -131,7 +244,7 @@ pub fn compute_channel_stats(data: &[f64]) -> (f64, f64, f64, f64) { } if count == 0 { - return (0.0, 0.0, 0.0, 0.0); + return ChannelStats::default(); } let avg = sum / count as f64; @@ -144,7 +257,12 @@ pub fn compute_channel_stats(data: &[f64]) -> (f64, f64, f64, f64) { } let stddev = (var_sum / count as f64).sqrt(); - (min, max, avg, stddev) + ChannelStats { + min, + max, + avg, + stddev, + } } /// Graph display mode. @@ -196,7 +314,7 @@ pub fn channel_preference_key(name: &str) -> String { #[derive(Clone)] pub struct DistanceAxisCache { - pub data: Arc>, + pub data: Arc<[f64]>, pub freq: u16, } @@ -216,10 +334,10 @@ pub struct CachedChannelStats { pub name: String, pub color: egui::Color32, pub dec_places: i16, - /// Full session stats: (min, max, avg, stddev). - pub session: (f64, f64, f64, f64), - /// Per-lap stats: (lap_name, min, max, avg, stddev). - pub per_lap: Vec<(String, f64, f64, f64, f64)>, + /// Full session stats. + pub session: ChannelStats, + /// Per-lap stats. + pub per_lap: Vec<(String, ChannelStats)>, } /// Cache for report panel statistics, invalidated when channels or laps change. @@ -245,7 +363,7 @@ impl ReportCache { return false; } for (i, info) in registry.iter().enumerate() { - let ptr = Arc::as_ptr(&info.data) as usize; + let ptr = info.data.as_ptr() as usize; let (ref name, cached_ptr, cached_len, ref unit, scale_bits, offset_bits) = self.fingerprint[i]; if name != &info.name @@ -268,7 +386,7 @@ impl ReportCache { .map(|info| { ( info.name.clone(), - Arc::as_ptr(&info.data) as usize, + info.data.as_ptr() as usize, info.data.len(), info.unit.clone(), info.display_scale.to_bits(), @@ -281,13 +399,13 @@ impl ReportCache { for info in registry { let mut session = compute_channel_stats(&info.data); - session.0 = info.transform_value(session.0); - session.1 = info.transform_value(session.1); + session.min = info.transform_value(session.min); + session.max = info.transform_value(session.max); if info.display_scale < 0.0 { - std::mem::swap(&mut session.0, &mut session.1); + std::mem::swap(&mut session.min, &mut session.max); } - session.2 = info.transform_value(session.2); - session.3 *= info.display_scale.abs(); + session.avg = info.transform_value(session.avg); + session.stddev *= info.display_scale.abs(); let freq = info.freq; let mut per_lap = Vec::with_capacity(laps.len()); @@ -298,14 +416,14 @@ impl ReportCache { let end = end_sample.min(info.data.len()); if start < end { let mut stats = compute_channel_stats(&info.data[start..end]); - stats.0 = info.transform_value(stats.0); - stats.1 = info.transform_value(stats.1); + stats.min = info.transform_value(stats.min); + stats.max = info.transform_value(stats.max); if info.display_scale < 0.0 { - std::mem::swap(&mut stats.0, &mut stats.1); + std::mem::swap(&mut stats.min, &mut stats.max); } - stats.2 = info.transform_value(stats.2); - stats.3 *= info.display_scale.abs(); - per_lap.push((lap.name.clone(), stats.0, stats.1, stats.2, stats.3)); + stats.avg = info.transform_value(stats.avg); + stats.stddev *= info.display_scale.abs(); + per_lap.push((lap.name.clone(), stats)); } } @@ -322,6 +440,7 @@ impl ReportCache { /// State shared across all panels. pub struct SharedState { + pub session_id: u64, pub ld_file: Option>, pub ld_path: Option, pub file_name: String, @@ -369,14 +488,28 @@ pub struct SharedState { // Next panel ID counter pub next_panel_id: u64, + next_math_channel_id: u64, // Derived channel caches pub distance_axis_cache: Option, + pub decoded_channel_cache: RefCell>>, + pending_channel_decodes: RefCell>, + requested_channel_decodes: RefCell>, + pending_math_evaluations: RefCell>, + requested_math_evaluations: RefCell>, + downsampled_series_cache: RefCell>>, + pending_downsampled_series: RefCell>, + requested_downsampled_series: RefCell>, + track_data_cache: RefCell>>, + pending_track_data_build: RefCell, + requested_track_data_build: RefCell, + resolved_track_data_build: RefCell, } impl SharedState { pub fn new() -> Self { Self { + session_id: 0, ld_file: None, ld_path: None, file_name: String::new(), @@ -401,11 +534,237 @@ impl SharedState { sectors: Vec::new(), reference_lap: None, next_panel_id: 1, + next_math_channel_id: 1, distance_axis_cache: None, + decoded_channel_cache: RefCell::new(HashMap::new()), + pending_channel_decodes: RefCell::new(HashSet::new()), + requested_channel_decodes: RefCell::new(Vec::new()), + pending_math_evaluations: RefCell::new(HashSet::new()), + requested_math_evaluations: RefCell::new(Vec::new()), + downsampled_series_cache: RefCell::new(HashMap::new()), + pending_downsampled_series: RefCell::new(HashSet::new()), + requested_downsampled_series: RefCell::new(Vec::new()), + track_data_cache: RefCell::new(None), + pending_track_data_build: RefCell::new(false), + requested_track_data_build: RefCell::new(false), + resolved_track_data_build: RefCell::new(false), } } pub fn invalidate_derived_caches(&mut self) { self.distance_axis_cache = None; + self.downsampled_series_cache.borrow_mut().clear(); + self.pending_downsampled_series.borrow_mut().clear(); + self.requested_downsampled_series.borrow_mut().clear(); + } + + pub fn invalidate_session_caches(&mut self) { + self.invalidate_derived_caches(); + self.decoded_channel_cache.borrow_mut().clear(); + self.pending_channel_decodes.borrow_mut().clear(); + self.requested_channel_decodes.borrow_mut().clear(); + self.pending_math_evaluations.borrow_mut().clear(); + self.requested_math_evaluations.borrow_mut().clear(); + self.track_data_cache.borrow_mut().take(); + *self.pending_track_data_build.borrow_mut() = false; + *self.requested_track_data_build.borrow_mut() = false; + *self.resolved_track_data_build.borrow_mut() = false; + } + + pub fn downsampled_series_if_ready( + &self, + key: &DownsampleSeriesKey, + ) -> Option> { + self.downsampled_series_cache + .borrow() + .get(key) + .map(Arc::clone) + } + + pub fn request_downsampled_series(&self, request: DownsampleSeriesRequest) { + if self + .downsampled_series_cache + .borrow() + .contains_key(&request.key) + { + return; + } + + let mut pending = self.pending_downsampled_series.borrow_mut(); + if !pending.insert(request.key.clone()) { + return; + } + + self.requested_downsampled_series.borrow_mut().push(request); + } + + pub fn take_requested_downsampled_series(&self) -> Vec { + let mut requested = self.requested_downsampled_series.borrow_mut(); + std::mem::take(&mut *requested) + } + + pub fn request_math_channel_evaluation_by_id(&self, math_id: u64) { + let mut pending = self.pending_math_evaluations.borrow_mut(); + if !pending.insert(math_id) { + return; + } + self.requested_math_evaluations.borrow_mut().push(math_id); + } + + pub fn take_requested_math_channel_evaluations(&self) -> Vec { + let mut requested = self.requested_math_evaluations.borrow_mut(); + std::mem::take(&mut *requested) + } + + pub fn complete_math_channel_evaluation(&self, math_id: u64) { + self.pending_math_evaluations.borrow_mut().remove(&math_id); + } + + pub fn cancel_math_channel_evaluation(&self, math_id: u64) { + self.pending_math_evaluations.borrow_mut().remove(&math_id); + } + + pub fn has_pending_math_evaluations(&self) -> bool { + !self.pending_math_evaluations.borrow().is_empty() + || !self.requested_math_evaluations.borrow().is_empty() + } + + pub fn are_math_channels_settled(&self) -> bool { + !self.has_pending_math_evaluations() + && self.math_channels.iter().all(|mc| { + matches!( + mc.evaluation_state, + MathEvaluationState::Ready | MathEvaluationState::Error + ) + }) + } + + pub fn create_math_channel_def( + &mut self, + name: String, + expression: String, + unit: String, + dec_places: i16, + ) -> MathChannelDef { + let id = self.next_math_channel_id; + self.next_math_channel_id += 1; + MathChannelDef::new(id, name, expression, unit, dec_places) + } + + pub fn math_channel_index_by_id(&self, math_id: u64) -> Option { + self.math_channels.iter().position(|mc| mc.id == math_id) + } + + pub fn store_downsampled_series( + &self, + key: DownsampleSeriesKey, + points: Vec, + ) { + self.pending_downsampled_series.borrow_mut().remove(&key); + self.downsampled_series_cache + .borrow_mut() + .insert(key, Arc::from(points)); + } + + pub fn cancel_downsampled_series(&self, key: &DownsampleSeriesKey) { + self.pending_downsampled_series.borrow_mut().remove(key); + } + + pub fn track_data_if_ready(&self) -> Option> { + self.track_data_cache.borrow().as_ref().map(Arc::clone) + } + + pub fn request_track_data_build(&self) { + if self.track_data_cache.borrow().is_some() + || *self.pending_track_data_build.borrow() + || *self.resolved_track_data_build.borrow() + { + return; + } + + *self.pending_track_data_build.borrow_mut() = true; + *self.requested_track_data_build.borrow_mut() = true; + } + + pub fn take_requested_track_data_build(&self) -> bool { + let mut requested = self.requested_track_data_build.borrow_mut(); + let was_requested = *requested; + *requested = false; + was_requested + } + + pub fn is_track_data_build_pending(&self) -> bool { + *self.pending_track_data_build.borrow() + } + + pub fn store_track_data(&self, track_data: Option) { + *self.pending_track_data_build.borrow_mut() = false; + *self.resolved_track_data_build.borrow_mut() = true; + *self.track_data_cache.borrow_mut() = track_data.map(Arc::new); + } + + pub fn cancel_track_data_build(&self) { + *self.pending_track_data_build.borrow_mut() = false; + } + + pub fn decoded_physical_channel_if_ready( + &self, + channel_idx: usize, + ) -> Option> { + self.decoded_channel_cache + .borrow() + .get(&channel_idx) + .map(Arc::clone) + } + + pub fn request_physical_channel_decode(&self, channel_idx: usize) { + if self + .decoded_channel_cache + .borrow() + .contains_key(&channel_idx) + { + return; + } + + let mut pending = self.pending_channel_decodes.borrow_mut(); + if !pending.insert(channel_idx) { + return; + } + + self.requested_channel_decodes + .borrow_mut() + .push(channel_idx); + } + + pub fn take_requested_physical_channel_decodes(&self) -> Vec { + let mut requested = self.requested_channel_decodes.borrow_mut(); + std::mem::take(&mut *requested) + } + + pub fn store_decoded_physical_channel( + &self, + channel_idx: usize, + data: Vec, + stats: ChannelStats, + freq: u16, + ) { + self.pending_channel_decodes + .borrow_mut() + .remove(&channel_idx); + self.decoded_channel_cache.borrow_mut().insert( + channel_idx, + Arc::new(DecodedChannel { + data: Arc::from(data), + stats, + freq, + lod_levels: RwLock::new(Vec::new()), + }), + ); + } + + pub fn cancel_physical_channel_decode(&self, channel_idx: usize) { + self.pending_channel_decodes + .borrow_mut() + .remove(&channel_idx); } } diff --git a/crates/i3rs-app/src/workspace.rs b/crates/i3rs-app/src/workspace.rs index bad7808..4e12666 100644 --- a/crates/i3rs-app/src/workspace.rs +++ b/crates/i3rs-app/src/workspace.rs @@ -13,7 +13,7 @@ use crate::panels::histogram::HistogramPanel; use crate::panels::mixture_map::MixtureMapPanel; use crate::panels::scatter::ScatterPanel; use crate::panels::track_map::TrackMapPanel; -use crate::panels::utils::apply_channel_preferences; +use crate::panels::utils::{apply_channel_preferences, create_plotted_channel}; use crate::state::{ CHANNEL_COLORS, ChannelId, GraphMode, GraphXAxis, SharedState, compute_channel_stats, }; @@ -258,7 +258,7 @@ fn resolve_saved_plotted_channel( .iter() .position(|mc| mc.name == channel_name)?; let data = shared.math_channels.get(idx)?.data.clone()?; - let (cached_min, cached_max, cached_avg, _) = compute_channel_stats(&data); + let stats = compute_channel_stats(&data); let mut plotted = crate::state::PlottedChannel { channel_id: ChannelId::Math(idx), color, @@ -268,33 +268,25 @@ fn resolve_saved_plotted_channel( display_scale: 1.0, display_offset: 0.0, display_unit: None, - cached_min, - cached_max, - cached_avg, + cached_min: stats.min, + cached_max: stats.max, + cached_avg: stats.avg, }; apply_channel_preferences(&mut plotted, shared); Some(plotted) } else { - let ld = shared.ld_file.as_ref()?; - let channel = ld + let channel_idx = shared + .ld_file + .as_ref()? .channels .iter() - .find(|channel| channel.name == channel_name)?; - let data = ld.read_channel_data(channel)?; - let (cached_min, cached_max, cached_avg, _) = compute_channel_stats(&data); - let mut plotted = crate::state::PlottedChannel { - channel_id: ChannelId::Physical(channel.index), - color, - data: std::sync::Arc::new(data), - tile_group, - y_axis: crate::state::YAxis::Left, - display_scale: 1.0, - display_offset: 0.0, - display_unit: None, - cached_min, - cached_max, - cached_avg, - }; + .find(|channel| channel.name == channel_name)? + .index; + let mut plotted = + create_plotted_channel(ChannelId::Physical(channel_idx), shared, tile_group)?; + plotted.color = color; + plotted.tile_group = tile_group; + plotted.y_axis = crate::state::YAxis::Left; apply_channel_preferences(&mut plotted, shared); Some(plotted) } @@ -630,8 +622,7 @@ pub fn load_workspace( shared.math_channels.iter().position(|mc| mc.name == *name) && let Some(data) = &shared.math_channels[mc_idx].data { - let (cached_min, cached_max, cached_avg, _) = - compute_channel_stats(data); + let stats = compute_channel_stats(data); graph.plotted_channels.push(crate::state::PlottedChannel { channel_id: ChannelId::Math(mc_idx), color, @@ -641,30 +632,26 @@ pub fn load_workspace( display_scale, display_offset, display_unit: display_unit.clone(), - cached_min, - cached_max, - cached_avg, + cached_min: stats.min, + cached_max: stats.max, + cached_avg: stats.avg, }); } } else if let Some(ld) = &shared.ld_file && let Some(ch) = ld.channels.iter().find(|c| &c.name == name) - && let Some(data) = ld.read_channel_data(ch) - { - let (cached_min, cached_max, cached_avg, _) = - compute_channel_stats(&data); - graph.plotted_channels.push(crate::state::PlottedChannel { - channel_id: ChannelId::Physical(ch.index), - color, - data: std::sync::Arc::new(data), + && let Some(mut plotted) = create_plotted_channel( + ChannelId::Physical(ch.index), + shared, tile_group, - y_axis: crate::state::YAxis::Left, - display_scale, - display_offset, - display_unit: display_unit.clone(), - cached_min, - cached_max, - cached_avg, - }); + ) + { + plotted.color = color; + plotted.tile_group = tile_group; + plotted.y_axis = crate::state::YAxis::Left; + plotted.display_scale = display_scale; + plotted.display_offset = display_offset; + plotted.display_unit = display_unit.clone(); + graph.plotted_channels.push(plotted); } } diff --git a/crates/i3rs-core/Cargo.toml b/crates/i3rs-core/Cargo.toml index 37fd0c4..142ae06 100644 --- a/crates/i3rs-core/Cargo.toml +++ b/crates/i3rs-core/Cargo.toml @@ -17,3 +17,10 @@ memmap2 = { workspace = true } quick-xml = { workspace = true } rustfft = { workspace = true } serde = { workspace = true } + +[dev-dependencies] +criterion = "0.5" + +[[bench]] +name = "perf_benches" +harness = false diff --git a/crates/i3rs-core/benches/perf_benches.rs b/crates/i3rs-core/benches/perf_benches.rs new file mode 100644 index 0000000..31bfba8 --- /dev/null +++ b/crates/i3rs-core/benches/perf_benches.rs @@ -0,0 +1,131 @@ +use std::collections::HashMap; + +use criterion::{Criterion, black_box, criterion_group, criterion_main}; +use i3rs_core::{ + ChannelData, FftPlanner, LdFile, TrackData, compute_fft_with_planner, downsample_minmax, + evaluate_expression_with_aliases, find_nearest_sample, +}; + +const TEST_LD: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../test_data/VIR_LAP.ld"); + +fn synthetic_series(len: usize, freq: u16) -> Vec { + (0..len) + .map(|i| { + let t = i as f64 / freq as f64; + (t * 3.1).sin() * 20.0 + (t * 0.37).cos() * 5.0 + (i % 97) as f64 * 0.01 + }) + .collect() +} + +fn synthetic_track(len: usize, freq: u16) -> TrackData { + let mut x = Vec::with_capacity(len); + let mut y = Vec::with_capacity(len); + let mut time = Vec::with_capacity(len); + + for i in 0..len { + let t = i as f64 / len as f64 * std::f64::consts::TAU * 6.0; + let radius = 1.0 + (i as f64 / len as f64) * 0.2; + x.push(radius * t.cos()); + y.push(radius * t.sin()); + time.push(i as f64 / freq as f64); + } + + TrackData::from_normalized_parts(x, y, time, freq) +} + +fn bench_read_channel_data(c: &mut Criterion) { + let ld = LdFile::open(TEST_LD).expect("failed to open VIR_LAP.ld"); + let channel = ld + .channels + .iter() + .find(|channel| channel.name == "Engine Speed") + .expect("Engine Speed channel missing") + .clone(); + + c.bench_function("LdFile::read_channel_data/VIR_LAP/Engine Speed", |b| { + b.iter(|| black_box(ld.read_channel_data(black_box(&channel)).unwrap())) + }); +} + +fn bench_downsample_minmax(c: &mut Criterion) { + let samples = synthetic_series(1_000_000, 200); + c.bench_function("downsample_minmax/synthetic_1m_to_2k", |b| { + b.iter(|| black_box(downsample_minmax(black_box(&samples), 200, 0, 2_048))) + }); +} + +fn bench_evaluate_expression(c: &mut Criterion) { + let mut channels = HashMap::new(); + channels.insert( + "Engine Speed".to_string(), + ChannelData { + samples: synthetic_series(400_000, 100), + freq: 100, + }, + ); + channels.insert( + "Vehicle Speed".to_string(), + ChannelData { + samples: synthetic_series(200_000, 50), + freq: 50, + }, + ); + channels.insert( + "Throttle Position".to_string(), + ChannelData { + samples: synthetic_series(200_000, 50), + freq: 50, + }, + ); + + let aliases = HashMap::from([ + ("RPM".to_string(), "Engine Speed".to_string()), + ("Speed".to_string(), "Vehicle Speed".to_string()), + ]); + let expression = "smooth(RPM, 7) + derivative(Speed) + Throttle_Position * 0.1"; + + c.bench_function("evaluate_expression_with_aliases/synthetic", |b| { + b.iter(|| { + black_box( + evaluate_expression_with_aliases( + black_box(expression), + black_box(&channels), + black_box(&aliases), + ) + .unwrap(), + ) + }) + }); +} + +fn bench_fft(c: &mut Criterion) { + let samples = synthetic_series(32_768, 512); + let mut planner = FftPlanner::new(); + + c.bench_function("compute_fft_with_planner/synthetic_32k", |b| { + b.iter(|| { + black_box(compute_fft_with_planner( + black_box(&samples), + 512.0, + black_box(&mut planner), + )) + }) + }); +} + +fn bench_find_nearest_sample(c: &mut Criterion) { + let track = synthetic_track(200_000, 20); + c.bench_function("find_nearest_sample/synthetic_200k", |b| { + b.iter(|| black_box(find_nearest_sample(black_box(&track), 0.52, -0.41))) + }); +} + +criterion_group!( + perf_benches, + bench_read_channel_data, + bench_downsample_minmax, + bench_evaluate_expression, + bench_fft, + bench_find_nearest_sample +); +criterion_main!(perf_benches); diff --git a/crates/i3rs-core/src/export.rs b/crates/i3rs-core/src/export.rs index d86920b..fc0b066 100644 --- a/crates/i3rs-core/src/export.rs +++ b/crates/i3rs-core/src/export.rs @@ -11,6 +11,12 @@ pub struct ExportChannel<'a> { pub dec_places: i16, } +struct ExportResamplePlan<'a> { + data: &'a [f64], + dec_places: usize, + source_indices: Vec, +} + /// Export channels to a CSV file. /// /// All channels are resampled to the highest frequency via nearest-neighbor. @@ -50,28 +56,42 @@ pub fn export_csv( } writeln!(writer).map_err(|e| e.to_string())?; + let channel_plans: Vec> = channels + .iter() + .map(|channel| { + let dec_places = channel.dec_places.max(0) as usize; + let source_indices = if channel.freq == max_freq { + (0..n_rows).map(|row| start_sample + row).collect() + } else { + let ratio = channel.freq as f64 / max_freq as f64; + (0..n_rows) + .map(|row| ((start_sample + row) as f64 * ratio).round() as usize) + .collect() + }; + ExportResamplePlan { + data: channel.data, + dec_places, + source_indices, + } + }) + .collect(); + // Rows let dt = 1.0 / max_freq as f64; - for i in 0..n_rows { - let time = (start_sample + i) as f64 * dt; + for row in 0..n_rows { + let time = (start_sample + row) as f64 * dt; write!(writer, "{:.6}", time).map_err(|e| e.to_string())?; - for ch in channels { - let src_idx = if ch.freq == max_freq { - start_sample + i - } else { - let t = time; - (t * ch.freq as f64).round() as usize - }; - let val = if src_idx < ch.data.len() { - ch.data[src_idx] - } else if !ch.data.is_empty() { - ch.data[ch.data.len() - 1] + for plan in &channel_plans { + let src_idx = plan.source_indices[row]; + let val = if src_idx < plan.data.len() { + plan.data[src_idx] + } else if !plan.data.is_empty() { + plan.data[plan.data.len() - 1] } else { 0.0 }; - let dec = ch.dec_places.max(0) as usize; - write!(writer, ",{:.prec$}", val, prec = dec).map_err(|e| e.to_string())?; + write!(writer, ",{:.prec$}", val, prec = plan.dec_places).map_err(|e| e.to_string())?; } writeln!(writer).map_err(|e| e.to_string())?; } diff --git a/crates/i3rs-core/src/ld_parser.rs b/crates/i3rs-core/src/ld_parser.rs index 5c5cdb4..c1ac719 100644 --- a/crates/i3rs-core/src/ld_parser.rs +++ b/crates/i3rs-core/src/ld_parser.rs @@ -83,6 +83,10 @@ fn read_string(data: &[u8], offset: usize, len: usize) -> String { decode_string(&data[offset..offset + len]) } +pub fn normalize_channel_name(name: &str) -> String { + name.to_ascii_lowercase().replace(['.', '_'], " ") +} + // --------------------------------------------------------------------------- // Public types // --------------------------------------------------------------------------- @@ -206,6 +210,7 @@ pub struct LdFile { pub session: Session, pub event: Event, pub channels: Vec, + normalized_name_index: HashMap, #[allow(dead_code)] chan_meta_ptr: u32, } @@ -265,12 +270,19 @@ impl LdFile { let event = parse_event(data, event_ptr); let enum_tables = parse_enum_tables(data, chan_data_ptr as usize); let channels = parse_channel_metadata(data, chan_meta_ptr, &enum_tables); + let mut normalized_name_index = HashMap::new(); + for channel in &channels { + normalized_name_index + .entry(normalize_channel_name(&channel.name)) + .or_insert(channel.index); + } Ok(LdFile { backing, session, event, channels, + normalized_name_index, chan_meta_ptr, }) } @@ -288,6 +300,17 @@ impl LdFile { .fold(0.0_f64, f64::max) } + pub fn find_channel_by_name(&self, name: &str) -> Option<&ChannelMeta> { + if let Some(channel) = self.channels.iter().find(|channel| channel.name == name) { + return Some(channel); + } + + let normalized = normalize_channel_name(name); + self.normalized_name_index + .get(&normalized) + .and_then(|idx| self.channels.get(*idx)) + } + /// Read and decode all sample data for a channel, applying MoTeC scaling. /// Returns scaled f64 values, or None if the data type is unknown. pub fn read_channel_data(&self, channel: &ChannelMeta) -> Option> { diff --git a/crates/i3rs-core/src/lib.rs b/crates/i3rs-core/src/lib.rs index 6b46958..0123384 100644 --- a/crates/i3rs-core/src/lib.rs +++ b/crates/i3rs-core/src/lib.rs @@ -18,7 +18,7 @@ pub use downsample::{DownsampledPoint, downsample_minmax}; pub use export::{ExportChannel, export_csv}; pub use fft::{FftResult, compute_fft, compute_fft_with_planner}; pub use lap_detect::{Lap, detect_laps, format_duration}; -pub use ld_parser::{ChannelMeta, DataType, Event, LdFile, Session}; +pub use ld_parser::{ChannelMeta, DataType, Event, LdFile, Session, normalize_channel_name}; pub use ldx_parser::{LdxFile, LdxLap, find_ldx_for_ld}; pub use math_engine::{ ChannelData, MathError, evaluate_expression, evaluate_expression_with_aliases, diff --git a/crates/i3rs-core/src/math_engine.rs b/crates/i3rs-core/src/math_engine.rs index cdb147e..982629b 100644 --- a/crates/i3rs-core/src/math_engine.rs +++ b/crates/i3rs-core/src/math_engine.rs @@ -1,9 +1,8 @@ //! Math channel evaluator: evaluates parsed expressions against channel data. -use std::borrow::Cow; use std::collections::HashMap; use std::fmt; -use std::sync::LazyLock; +use std::sync::{Arc, LazyLock, RwLock}; use crate::math_expr::{BinOp, Expr, parse_expression, referenced_channels}; @@ -36,6 +35,36 @@ impl std::error::Error for MathError {} // Channel name resolution // --------------------------------------------------------------------------- +fn normalize_channel_name(name: &str) -> String { + name.to_ascii_lowercase().replace(['.', '_'], " ") +} + +fn resolve_actual_channel_name<'a>( + reference: &str, + available: &'a HashMap, + normalized_name_index: &HashMap, +) -> Option<&'a str> { + if let Some((key, _)) = available.get_key_value(reference) { + return Some(key); + } + + let with_spaces = reference.replace('_', " "); + if let Some((key, _)) = available.get_key_value(&with_spaces) { + return Some(key); + } + + let with_dots = reference.replace('_', "."); + if let Some((key, _)) = available.get_key_value(&with_dots) { + return Some(key); + } + + let normalized = normalize_channel_name(reference); + normalized_name_index + .get(&normalized) + .and_then(|key| available.get_key_value(key).map(|(key, _)| key.as_str())) +} + +#[allow(dead_code)] /// Resolve a channel reference against available channel names. /// /// Resolution priority: exact → underscore-to-space → underscore-to-dot @@ -45,39 +74,35 @@ fn resolve_channel_name<'a>( available: &'a HashMap, aliases: &HashMap, ) -> Option<&'a str> { - if let Some((k, _)) = available.get_key_value(reference) { - return Some(k); + let mut normalized_name_index = HashMap::new(); + for key in available.keys() { + normalized_name_index + .entry(normalize_channel_name(key)) + .or_insert_with(|| key.clone()); } - let with_spaces = reference.replace('_', " "); - if let Some((k, _)) = available.get_key_value(&with_spaces) { - return Some(k); + if let Some(key) = resolve_actual_channel_name(reference, available, &normalized_name_index) { + return Some(key); } + let with_spaces = reference.replace('_', " "); let with_dots = reference.replace('_', "."); - if let Some((k, _)) = available.get_key_value(&with_dots) { - return Some(k); - } - for variant in [reference, with_spaces.as_str(), with_dots.as_str()] { if let Some(target) = aliases.get(variant) - && let Some((k, _)) = available.get_key_value(target) + && let Some(key) = + resolve_actual_channel_name(target, available, &normalized_name_index) { - return Some(k); - } - } - - for key in available.keys() { - if key.eq_ignore_ascii_case(reference) { return Some(key); } } + let normalized_reference = normalize_channel_name(reference); for (alias, target) in aliases { - if alias.eq_ignore_ascii_case(reference) - && let Some((k, _)) = available.get_key_value(target) + if normalize_channel_name(alias) == normalized_reference + && let Some(key) = + resolve_actual_channel_name(target, available, &normalized_name_index) { - return Some(k); + return Some(key); } } @@ -111,18 +136,12 @@ pub fn resolve_alias_target(reference: &str, aliases: &HashMap) // --------------------------------------------------------------------------- /// Resample a channel to a target frequency using linear interpolation. -/// Returns a borrowed slice when no resampling is needed. -fn resample<'a>( - data: &'a [f64], - src_freq: u16, - target_freq: u16, - target_len: usize, -) -> Cow<'a, [f64]> { +fn resample(data: &[f64], src_freq: u16, target_freq: u16, target_len: usize) -> Vec { if src_freq == target_freq && data.len() == target_len { - return Cow::Borrowed(data); + return data.to_vec(); } if data.is_empty() { - return Cow::Owned(vec![0.0; target_len]); + return vec![0.0; target_len]; } let mut out = Vec::with_capacity(target_len); @@ -141,7 +160,7 @@ fn resample<'a>( }; out.push(val); } - Cow::Owned(out) + out } // --------------------------------------------------------------------------- @@ -149,315 +168,411 @@ fn resample<'a>( // --------------------------------------------------------------------------- static EMPTY_ALIASES: LazyLock> = LazyLock::new(HashMap::new); +static PARSED_EXPRESSION_CACHE: LazyLock>>> = + LazyLock::new(|| RwLock::new(HashMap::new())); -/// Evaluate a parsed expression against channel data (no aliases). -pub fn evaluate( - expr: &Expr, - channels: &HashMap, - output_freq: u16, - output_len: usize, -) -> Result, MathError> { - eval_impl(expr, channels, output_freq, output_len, &EMPTY_ALIASES) +fn parse_expression_cached(expression: &str) -> Result, crate::math_expr::ParseError> { + if let Ok(cache) = PARSED_EXPRESSION_CACHE.read() + && let Some(parsed) = cache.get(expression) + { + return Ok(Arc::clone(parsed)); + } + + let parsed = Arc::new(parse_expression(expression)?); + if let Ok(mut cache) = PARSED_EXPRESSION_CACHE.write() { + cache.insert(expression.to_string(), Arc::clone(&parsed)); + } + Ok(parsed) } -fn eval_impl( - expr: &Expr, - channels: &HashMap, - output_freq: u16, - output_len: usize, - aliases: &HashMap, -) -> Result, MathError> { - match expr { - Expr::Number(n) => Ok(vec![*n; output_len]), - - Expr::Channel(name) => { - let resolved = - resolve_channel_name(name, channels, aliases).ok_or_else(|| MathError { - message: format!("unknown channel '{}'", name), - })?; - let ch = &channels[resolved]; - Ok(resample(&ch.samples, ch.freq, output_freq, output_len).into_owned()) +struct ChannelResolver<'a> { + channels: &'a HashMap, + normalized_name_index: HashMap, + alias_name_index: HashMap, +} + +impl<'a> ChannelResolver<'a> { + fn new(channels: &'a HashMap, aliases: &HashMap) -> Self { + let mut normalized_name_index = HashMap::new(); + for key in channels.keys() { + normalized_name_index + .entry(normalize_channel_name(key)) + .or_insert_with(|| key.clone()); } - Expr::UnaryNeg(inner) => { - let vals = eval_impl(inner, channels, output_freq, output_len, aliases)?; - Ok(vals.into_iter().map(|v| -v).collect()) + let mut alias_name_index = HashMap::new(); + for (alias, target) in aliases { + if let Some(actual) = + resolve_actual_channel_name(target, channels, &normalized_name_index) + { + alias_name_index.insert(alias.clone(), actual.to_string()); + alias_name_index + .entry(normalize_channel_name(alias)) + .or_insert_with(|| actual.to_string()); + } } - Expr::BinaryOp(lhs, op, rhs) => { - let left = eval_impl(lhs, channels, output_freq, output_len, aliases)?; - let right = eval_impl(rhs, channels, output_freq, output_len, aliases)?; - let result = left - .iter() - .zip(right.iter()) - .map(|(&l, &r)| match op { - BinOp::Add => l + r, - BinOp::Sub => l - r, - BinOp::Mul => l * r, - BinOp::Div => { - if r == 0.0 { - f64::NAN - } else { - l / r - } - } - BinOp::Mod => { - if r == 0.0 { - f64::NAN - } else { - l % r - } - } - BinOp::Gt => { - if l > r { - 1.0 - } else { - 0.0 - } - } - BinOp::Lt => { - if l < r { - 1.0 - } else { - 0.0 - } - } - BinOp::Gte => { - if l >= r { - 1.0 - } else { - 0.0 - } - } - BinOp::Lte => { - if l <= r { - 1.0 - } else { - 0.0 - } - } - BinOp::Eq => { - if l == r { - 1.0 - } else { - 0.0 - } - } - BinOp::Neq => { - if l != r { - 1.0 - } else { - 0.0 - } - } - BinOp::And => { - if !l.is_nan() && l != 0.0 && !r.is_nan() && r != 0.0 { - 1.0 - } else { - 0.0 - } - } - BinOp::Or => { - if (!l.is_nan() && l != 0.0) || (!r.is_nan() && r != 0.0) { - 1.0 - } else { - 0.0 - } - } - }) - .collect(); - Ok(result) + Self { + channels, + normalized_name_index, + alias_name_index, + } + } + + fn resolve(&self, reference: &str) -> Option<&str> { + if let Some(key) = + resolve_actual_channel_name(reference, self.channels, &self.normalized_name_index) + { + return Some(key); } - Expr::FuncCall(name, args) => { - eval_function(name, args, channels, output_freq, output_len, aliases) + let with_spaces = reference.replace('_', " "); + let with_dots = reference.replace('_', "."); + for variant in [reference, with_spaces.as_str(), with_dots.as_str()] { + if let Some(actual) = self.alias_name_index.get(variant) { + return Some(actual.as_str()); + } } + + let normalized = normalize_channel_name(reference); + self.alias_name_index + .get(&normalized) + .or_else(|| self.normalized_name_index.get(&normalized)) + .map(String::as_str) + } + + fn get_channel(&self, reference: &str) -> Option<(&str, &'a ChannelData)> { + let key = self.resolve(reference)?; + self.channels + .get_key_value(key) + .map(|(key, value)| (key.as_str(), value)) } } -fn eval_function( - name: &str, - args: &[Expr], - channels: &HashMap, - freq: u16, - len: usize, - aliases: &HashMap, -) -> Result, MathError> { - match name { - // smooth(channel, window_size) - "smooth" => { - if args.len() != 2 { - return Err(MathError { - message: "smooth() requires 2 arguments: smooth(channel, window_size)".into(), - }); - } - let data = eval_impl(&args[0], channels, freq, len, aliases)?; - let window = match &args[1] { - Expr::Number(n) => *n as usize, - _ => { - let w = eval_impl(&args[1], channels, freq, len, aliases)?; - w[0] as usize - } - }; - Ok(moving_average(&data, window.max(1))) +struct EvaluationContext<'a> { + resolver: ChannelResolver<'a>, + output_freq: u16, + output_len: usize, + resample_cache: HashMap<(String, u16, u16, usize), Arc>>, + node_outputs: HashMap>>, +} + +impl<'a> EvaluationContext<'a> { + fn new( + channels: &'a HashMap, + aliases: &'a HashMap, + output_freq: u16, + output_len: usize, + ) -> Self { + Self { + resolver: ChannelResolver::new(channels, aliases), + output_freq, + output_len, + resample_cache: HashMap::new(), + node_outputs: HashMap::new(), } + } - // derivative(channel) — finite difference * freq - "derivative" => { - if args.len() != 1 { - return Err(MathError { - message: "derivative() requires 1 argument".into(), - }); - } - let data = eval_impl(&args[0], channels, freq, len, aliases)?; - Ok(finite_derivative(&data, freq)) + fn evaluate(&mut self, expr: &Expr) -> Result>, MathError> { + let node_id = expr as *const Expr as usize; + if let Some(cached) = self.node_outputs.get(&node_id) { + return Ok(Arc::clone(cached)); } - // integrate(channel) — cumulative sum / freq - "integrate" => { - if args.len() != 1 { - return Err(MathError { - message: "integrate() requires 1 argument".into(), - }); + let output = match expr { + Expr::Number(n) => Arc::new(vec![*n; self.output_len]), + + Expr::Channel(name) => { + let (resolved, channel) = + self.resolver.get_channel(name).ok_or_else(|| MathError { + message: format!("unknown channel '{}'", name), + })?; + let cache_key = ( + resolved.to_string(), + channel.freq, + self.output_freq, + self.output_len, + ); + if let Some(cached) = self.resample_cache.get(&cache_key) { + Arc::clone(cached) + } else { + let resampled = if channel.freq == self.output_freq + && channel.samples.len() == self.output_len + { + channel.samples.clone() + } else { + resample( + &channel.samples, + channel.freq, + self.output_freq, + self.output_len, + ) + }; + let resampled = Arc::new(resampled); + self.resample_cache + .insert(cache_key, Arc::clone(&resampled)); + resampled + } } - let data = eval_impl(&args[0], channels, freq, len, aliases)?; - Ok(cumulative_integral(&data, freq)) - } - // Single-argument math functions - "abs" => unary_fn(args, channels, freq, len, aliases, f64::abs), - "sqrt" => unary_fn(args, channels, freq, len, aliases, f64::sqrt), - "sin" => unary_fn(args, channels, freq, len, aliases, f64::sin), - "cos" => unary_fn(args, channels, freq, len, aliases, f64::cos), - "tan" => unary_fn(args, channels, freq, len, aliases, f64::tan), - "asin" => unary_fn(args, channels, freq, len, aliases, f64::asin), - "acos" => unary_fn(args, channels, freq, len, aliases, f64::acos), - "atan" => unary_fn(args, channels, freq, len, aliases, f64::atan), - "log" | "ln" => unary_fn(args, channels, freq, len, aliases, f64::ln), - "exp" => unary_fn(args, channels, freq, len, aliases, f64::exp), - "floor" => unary_fn(args, channels, freq, len, aliases, f64::floor), - "ceil" => unary_fn(args, channels, freq, len, aliases, f64::ceil), - "round" => unary_fn(args, channels, freq, len, aliases, f64::round), - - // Two-argument functions - "atan2" => binary_fn(args, channels, freq, len, aliases, f64::atan2), - "pow" => binary_fn(args, channels, freq, len, aliases, f64::powf), - "min" => binary_fn(args, channels, freq, len, aliases, f64::min), - "max" => binary_fn(args, channels, freq, len, aliases, f64::max), - - // clamp(value, min, max) - "clamp" => { - if args.len() != 3 { - return Err(MathError { - message: "clamp() requires 3 arguments: clamp(value, min, max)".into(), - }); + Expr::UnaryNeg(inner) => { + let vals = self.evaluate(inner)?; + Arc::new(vals.iter().map(|v| -*v).collect()) } - let val = eval_impl(&args[0], channels, freq, len, aliases)?; - let lo = eval_impl(&args[1], channels, freq, len, aliases)?; - let hi = eval_impl(&args[2], channels, freq, len, aliases)?; - Ok(val - .iter() - .zip(lo.iter()) - .zip(hi.iter()) - .map(|((&v, &l), &h)| v.clamp(l, h)) - .collect()) - } - // gate(data, condition) — returns data where condition is non-zero, NAN otherwise - "gate" => { - if args.len() != 2 { - return Err(MathError { - message: "gate() requires 2 arguments: gate(data, condition)".into(), - }); + Expr::BinaryOp(lhs, op, rhs) => { + let left = self.evaluate(lhs)?; + let right = self.evaluate(rhs)?; + Arc::new( + left.iter() + .zip(right.iter()) + .map(|(&l, &r)| match op { + BinOp::Add => l + r, + BinOp::Sub => l - r, + BinOp::Mul => l * r, + BinOp::Div => { + if r == 0.0 { + f64::NAN + } else { + l / r + } + } + BinOp::Mod => { + if r == 0.0 { + f64::NAN + } else { + l % r + } + } + BinOp::Gt => { + if l > r { + 1.0 + } else { + 0.0 + } + } + BinOp::Lt => { + if l < r { + 1.0 + } else { + 0.0 + } + } + BinOp::Gte => { + if l >= r { + 1.0 + } else { + 0.0 + } + } + BinOp::Lte => { + if l <= r { + 1.0 + } else { + 0.0 + } + } + BinOp::Eq => { + if l == r { + 1.0 + } else { + 0.0 + } + } + BinOp::Neq => { + if l != r { + 1.0 + } else { + 0.0 + } + } + BinOp::And => { + if !l.is_nan() && l != 0.0 && !r.is_nan() && r != 0.0 { + 1.0 + } else { + 0.0 + } + } + BinOp::Or => { + if (!l.is_nan() && l != 0.0) || (!r.is_nan() && r != 0.0) { + 1.0 + } else { + 0.0 + } + } + }) + .collect(), + ) } - let data = eval_impl(&args[0], channels, freq, len, aliases)?; - let cond = eval_impl(&args[1], channels, freq, len, aliases)?; - Ok(data - .iter() - .zip(cond.iter()) - .map(|(&d, &c)| if c != 0.0 { d } else { f64::NAN }) - .collect()) - } - // if_then(condition, true_value, false_value) - "if_then" => { - if args.len() != 3 { - return Err(MathError { - message: - "if_then() requires 3 arguments: if_then(condition, true_val, false_val)" + Expr::FuncCall(name, args) => self.evaluate_function(name, args)?, + }; + + self.node_outputs.insert(node_id, Arc::clone(&output)); + Ok(output) + } + + fn evaluate_function(&mut self, name: &str, args: &[Expr]) -> Result>, MathError> { + match name { + "smooth" => { + if args.len() != 2 { + return Err(MathError { + message: "smooth() requires 2 arguments: smooth(channel, window_size)" .into(), - }); + }); + } + let data = self.evaluate(&args[0])?; + let window = match &args[1] { + Expr::Number(n) => *n as usize, + _ => self.evaluate(&args[1])?[0] as usize, + }; + Ok(Arc::new(moving_average(&data, window.max(1)))) + } + "derivative" => { + if args.len() != 1 { + return Err(MathError { + message: "derivative() requires 1 argument".into(), + }); + } + let data = self.evaluate(&args[0])?; + Ok(Arc::new(finite_derivative(&data, self.output_freq))) + } + "integrate" => { + if args.len() != 1 { + return Err(MathError { + message: "integrate() requires 1 argument".into(), + }); + } + let data = self.evaluate(&args[0])?; + Ok(Arc::new(cumulative_integral(&data, self.output_freq))) } - let cond = eval_impl(&args[0], channels, freq, len, aliases)?; - let true_val = eval_impl(&args[1], channels, freq, len, aliases)?; - let false_val = eval_impl(&args[2], channels, freq, len, aliases)?; - Ok(cond - .iter() - .zip(true_val.iter()) - .zip(false_val.iter()) - .map(|((&c, &t), &f)| if c != 0.0 { t } else { f }) - .collect()) + "abs" => self.unary_fn(args, f64::abs), + "sqrt" => self.unary_fn(args, f64::sqrt), + "sin" => self.unary_fn(args, f64::sin), + "cos" => self.unary_fn(args, f64::cos), + "tan" => self.unary_fn(args, f64::tan), + "asin" => self.unary_fn(args, f64::asin), + "acos" => self.unary_fn(args, f64::acos), + "atan" => self.unary_fn(args, f64::atan), + "log" | "ln" => self.unary_fn(args, f64::ln), + "exp" => self.unary_fn(args, f64::exp), + "floor" => self.unary_fn(args, f64::floor), + "ceil" => self.unary_fn(args, f64::ceil), + "round" => self.unary_fn(args, f64::round), + "atan2" => self.binary_fn(args, f64::atan2), + "pow" => self.binary_fn(args, f64::powf), + "min" => self.binary_fn(args, f64::min), + "max" => self.binary_fn(args, f64::max), + "clamp" => { + if args.len() != 3 { + return Err(MathError { + message: "clamp() requires 3 arguments: clamp(value, min, max)".into(), + }); + } + let val = self.evaluate(&args[0])?; + let lo = self.evaluate(&args[1])?; + let hi = self.evaluate(&args[2])?; + Ok(Arc::new( + val.iter() + .zip(lo.iter()) + .zip(hi.iter()) + .map(|((&v, &l), &h)| v.clamp(l, h)) + .collect(), + )) + } + "gate" => { + if args.len() != 2 { + return Err(MathError { + message: "gate() requires 2 arguments: gate(data, condition)".into(), + }); + } + let data = self.evaluate(&args[0])?; + let cond = self.evaluate(&args[1])?; + Ok(Arc::new( + data.iter() + .zip(cond.iter()) + .map(|(&d, &c)| if c != 0.0 { d } else { f64::NAN }) + .collect(), + )) + } + "if_then" => { + if args.len() != 3 { + return Err(MathError { + message: + "if_then() requires 3 arguments: if_then(condition, true_val, false_val)" + .into(), + }); + } + let cond = self.evaluate(&args[0])?; + let true_val = self.evaluate(&args[1])?; + let false_val = self.evaluate(&args[2])?; + Ok(Arc::new( + cond.iter() + .zip(true_val.iter()) + .zip(false_val.iter()) + .map(|((&c, &t), &f)| if c != 0.0 { t } else { f }) + .collect(), + )) + } + "kmh_to_mph" => self.unary_fn(args, |v| v * 0.621371), + "mph_to_kmh" => self.unary_fn(args, |v| v * 1.60934), + "c_to_f" => self.unary_fn(args, |v| v * 9.0 / 5.0 + 32.0), + "f_to_c" => self.unary_fn(args, |v| (v - 32.0) * 5.0 / 9.0), + "kpa_to_psi" => self.unary_fn(args, |v| v * 0.145038), + "psi_to_kpa" => self.unary_fn(args, |v| v * 6.89476), + "bar_to_psi" => self.unary_fn(args, |v| v * 14.5038), + "psi_to_bar" => self.unary_fn(args, |v| v / 14.5038), + "deg_to_rad" => self.unary_fn(args, f64::to_radians), + "rad_to_deg" => self.unary_fn(args, f64::to_degrees), + "kg_to_lb" => self.unary_fn(args, |v| v * 2.20462), + "lb_to_kg" => self.unary_fn(args, |v| v * 0.453592), + "m_to_ft" => self.unary_fn(args, |v| v * 3.28084), + "ft_to_m" => self.unary_fn(args, |v| v * 0.3048), + "nm_to_lbft" => self.unary_fn(args, |v| v * 0.737562), + "lbft_to_nm" => self.unary_fn(args, |v| v * 1.35582), + _ => Err(MathError { + message: format!("unknown function '{}'", name), + }), } - - // Unit conversion functions - "kmh_to_mph" => unary_fn(args, channels, freq, len, aliases, |v| v * 0.621371), - "mph_to_kmh" => unary_fn(args, channels, freq, len, aliases, |v| v * 1.60934), - "c_to_f" => unary_fn(args, channels, freq, len, aliases, |v| v * 9.0 / 5.0 + 32.0), - "f_to_c" => unary_fn(args, channels, freq, len, aliases, |v| { - (v - 32.0) * 5.0 / 9.0 - }), - "kpa_to_psi" => unary_fn(args, channels, freq, len, aliases, |v| v * 0.145038), - "psi_to_kpa" => unary_fn(args, channels, freq, len, aliases, |v| v * 6.89476), - "bar_to_psi" => unary_fn(args, channels, freq, len, aliases, |v| v * 14.5038), - "psi_to_bar" => unary_fn(args, channels, freq, len, aliases, |v| v / 14.5038), - "deg_to_rad" => unary_fn(args, channels, freq, len, aliases, |v| v.to_radians()), - "rad_to_deg" => unary_fn(args, channels, freq, len, aliases, |v| v.to_degrees()), - "kg_to_lb" => unary_fn(args, channels, freq, len, aliases, |v| v * 2.20462), - "lb_to_kg" => unary_fn(args, channels, freq, len, aliases, |v| v * 0.453592), - "m_to_ft" => unary_fn(args, channels, freq, len, aliases, |v| v * 3.28084), - "ft_to_m" => unary_fn(args, channels, freq, len, aliases, |v| v * 0.3048), - "nm_to_lbft" => unary_fn(args, channels, freq, len, aliases, |v| v * 0.737562), - "lbft_to_nm" => unary_fn(args, channels, freq, len, aliases, |v| v * 1.35582), - - _ => Err(MathError { - message: format!("unknown function '{}'", name), - }), } -} -fn unary_fn( - args: &[Expr], - channels: &HashMap, - freq: u16, - len: usize, - aliases: &HashMap, - f: fn(f64) -> f64, -) -> Result, MathError> { - if args.len() != 1 { - return Err(MathError { - message: "function requires 1 argument".into(), - }); + fn unary_fn(&mut self, args: &[Expr], f: fn(f64) -> f64) -> Result>, MathError> { + if args.len() != 1 { + return Err(MathError { + message: "function requires 1 argument".into(), + }); + } + let data = self.evaluate(&args[0])?; + Ok(Arc::new(data.iter().copied().map(f).collect())) + } + + fn binary_fn( + &mut self, + args: &[Expr], + f: fn(f64, f64) -> f64, + ) -> Result>, MathError> { + if args.len() != 2 { + return Err(MathError { + message: "function requires 2 arguments".into(), + }); + } + let a = self.evaluate(&args[0])?; + let b = self.evaluate(&args[1])?; + Ok(Arc::new( + a.iter().zip(b.iter()).map(|(&x, &y)| f(x, y)).collect(), + )) } - let data = eval_impl(&args[0], channels, freq, len, aliases)?; - Ok(data.into_iter().map(f).collect()) } -fn binary_fn( - args: &[Expr], +/// Evaluate a parsed expression against channel data (no aliases). +pub fn evaluate( + expr: &Expr, channels: &HashMap, - freq: u16, - len: usize, - aliases: &HashMap, - f: fn(f64, f64) -> f64, + output_freq: u16, + output_len: usize, ) -> Result, MathError> { - if args.len() != 2 { - return Err(MathError { - message: "function requires 2 arguments".into(), - }); - } - let a = eval_impl(&args[0], channels, freq, len, aliases)?; - let b = eval_impl(&args[1], channels, freq, len, aliases)?; - Ok(a.iter().zip(b.iter()).map(|(&x, &y)| f(x, y)).collect()) + let mut context = EvaluationContext::new(channels, &EMPTY_ALIASES, output_freq, output_len); + Ok((*context.evaluate(expr)?).clone()) } // --------------------------------------------------------------------------- @@ -519,19 +634,16 @@ fn cumulative_integral(data: &[f64], freq: u16) -> Vec { /// Determine the output frequency for an expression: max freq of all referenced channels. pub fn determine_output_freq(expr: &Expr, channels: &HashMap) -> u16 { - output_freq_impl(expr, channels, &EMPTY_ALIASES) + let resolver = ChannelResolver::new(channels, &EMPTY_ALIASES); + output_freq_impl(expr, &resolver) } -fn output_freq_impl( - expr: &Expr, - channels: &HashMap, - aliases: &HashMap, -) -> u16 { +fn output_freq_impl(expr: &Expr, resolver: &ChannelResolver<'_>) -> u16 { let refs = referenced_channels(expr); let mut max_freq = 1u16; for name in &refs { - if let Some(resolved) = resolve_channel_name(name, channels, aliases) { - let f = channels[resolved].freq; + if let Some((_, channel)) = resolver.get_channel(name) { + let f = channel.freq; if f > max_freq { max_freq = f; } @@ -546,22 +658,17 @@ pub fn determine_output_len( channels: &HashMap, output_freq: u16, ) -> usize { - output_len_impl(expr, channels, output_freq, &EMPTY_ALIASES) + let resolver = ChannelResolver::new(channels, &EMPTY_ALIASES); + output_len_impl(expr, &resolver, output_freq) } -fn output_len_impl( - expr: &Expr, - channels: &HashMap, - output_freq: u16, - aliases: &HashMap, -) -> usize { +fn output_len_impl(expr: &Expr, resolver: &ChannelResolver<'_>, output_freq: u16) -> usize { let refs = referenced_channels(expr); let mut max_duration: f64 = 0.0; for name in &refs { - if let Some(resolved) = resolve_channel_name(name, channels, aliases) { - let ch = &channels[resolved]; - if ch.freq > 0 { - let dur = ch.samples.len() as f64 / ch.freq as f64; + if let Some((_, channel)) = resolver.get_channel(name) { + if channel.freq > 0 { + let dur = channel.samples.len() as f64 / channel.freq as f64; if dur > max_duration { max_duration = dur; } @@ -585,14 +692,16 @@ pub fn evaluate_expression_with_aliases( channels: &HashMap, aliases: &HashMap, ) -> Result<(Vec, u16), String> { - let expr = parse_expression(expression).map_err(|e| e.to_string())?; - let freq = output_freq_impl(&expr, channels, aliases); - let len = output_len_impl(&expr, channels, freq, aliases); + let expr = parse_expression_cached(expression).map_err(|e| e.to_string())?; + let resolver = ChannelResolver::new(channels, aliases); + let freq = output_freq_impl(&expr, &resolver); + let len = output_len_impl(&expr, &resolver, freq); if len == 0 { return Err("expression references no channels with data".into()); } - let samples = eval_impl(&expr, channels, freq, len, aliases).map_err(|e| e.to_string())?; - Ok((samples, freq)) + let mut context = EvaluationContext::new(channels, aliases, freq, len); + let samples = context.evaluate(&expr).map_err(|e| e.to_string())?; + Ok(((*samples).clone(), freq)) } // --------------------------------------------------------------------------- @@ -860,4 +969,21 @@ mod tests { evaluate_expression_with_aliases("Revs / Velocity", &channels, &aliases).unwrap(); assert_eq!(result, vec![100.0, 100.0, 100.0, 100.0, 100.0]); } + + #[test] + fn eval_alias_and_channel_resolution_uses_normalized_names() { + let channels = HashMap::from([( + "Vehicle Speed".into(), + ChannelData { + samples: vec![1.0, 2.0, 3.0, 4.0], + freq: 2, + }, + )]); + let aliases = HashMap::from([("Speed.Value".into(), "Vehicle_Speed".into())]); + + let (result, _) = + evaluate_expression_with_aliases("Speed_Value + Vehicle_Speed", &channels, &aliases) + .unwrap(); + assert_eq!(result, vec![2.0, 4.0, 6.0, 8.0]); + } } diff --git a/crates/i3rs-core/src/track.rs b/crates/i3rs-core/src/track.rs index de1402a..37aadd2 100644 --- a/crates/i3rs-core/src/track.rs +++ b/crates/i3rs-core/src/track.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use crate::Lap; -use crate::ld_parser::LdFile; +use crate::ld_parser::{LdFile, normalize_channel_name}; /// Normalized GPS track data ready for rendering. pub struct TrackData { @@ -15,6 +15,192 @@ pub struct TrackData { pub time: Vec, /// GPS sample frequency (Hz). pub freq: u16, + spatial_index: Option, +} + +impl TrackData { + pub fn from_normalized_parts(x: Vec, y: Vec, time: Vec, freq: u16) -> Self { + let spatial_index = TrackSpatialIndex::build(&x, &y); + Self { + x, + y, + time, + freq, + spatial_index, + } + } +} + +struct TrackSpatialIndex { + min_x: f64, + min_y: f64, + cell_width: f64, + cell_height: f64, + cols: usize, + rows: usize, + cells: Vec>, +} + +impl TrackSpatialIndex { + fn build(x: &[f64], y: &[f64]) -> Option { + if x.is_empty() || y.is_empty() { + return None; + } + + let mut min_x = f64::INFINITY; + let mut max_x = f64::NEG_INFINITY; + let mut min_y = f64::INFINITY; + let mut max_y = f64::NEG_INFINITY; + + for (&xv, &yv) in x.iter().zip(y.iter()) { + if xv.is_finite() && yv.is_finite() { + min_x = min_x.min(xv); + max_x = max_x.max(xv); + min_y = min_y.min(yv); + max_y = max_y.max(yv); + } + } + + if !min_x.is_finite() || !min_y.is_finite() { + return None; + } + + let grid_dim = ((x.len() as f64 / 64.0).sqrt().ceil() as usize).clamp(4, 128); + let span_x = (max_x - min_x).max(1e-9); + let span_y = (max_y - min_y).max(1e-9); + let cell_width = span_x / grid_dim as f64; + let cell_height = span_y / grid_dim as f64; + let cell_count = grid_dim * grid_dim; + let mut cells = vec![Vec::new(); cell_count]; + + let index = Self { + min_x, + min_y, + cell_width, + cell_height, + cols: grid_dim, + rows: grid_dim, + cells: Vec::new(), + }; + + for (sample_idx, (&xv, &yv)) in x.iter().zip(y.iter()).enumerate() { + let (col, row) = index.cell_coords(xv, yv); + cells[index.cell_index(col, row)].push(sample_idx); + } + + Some(Self { cells, ..index }) + } + + fn find_nearest(&self, track: &TrackData, x: f64, y: f64) -> usize { + let (base_col, base_row) = self.cell_coords(x, y); + let max_ring = self.cols.max(self.rows); + let mut best_idx = 0usize; + let mut best_dist = f64::INFINITY; + + for ring in 0..max_ring { + self.for_each_ring_cell(base_col, base_row, ring, |col, row| { + for &sample_idx in &self.cells[self.cell_index(col, row)] { + let dx = track.x[sample_idx] - x; + let dy = track.y[sample_idx] - y; + let dist = dx * dx + dy * dy; + if dist < best_dist { + best_dist = dist; + best_idx = sample_idx; + } + } + }); + + if best_dist.is_finite() { + let next_ring = ring + 1; + if next_ring >= max_ring { + break; + } + + let mut next_ring_min_dist = f64::INFINITY; + self.for_each_ring_cell(base_col, base_row, next_ring, |col, row| { + let dist = self.cell_rect_distance2(col, row, x, y); + next_ring_min_dist = next_ring_min_dist.min(dist); + }); + + if !next_ring_min_dist.is_finite() || best_dist <= next_ring_min_dist { + break; + } + } + } + + best_idx + } + + fn for_each_ring_cell( + &self, + base_col: usize, + base_row: usize, + ring: usize, + mut visit: impl FnMut(usize, usize), + ) { + let min_col = base_col.saturating_sub(ring); + let max_col = (base_col + ring).min(self.cols.saturating_sub(1)); + let min_row = base_row.saturating_sub(ring); + let max_row = (base_row + ring).min(self.rows.saturating_sub(1)); + + if ring == 0 { + visit(base_col, base_row); + return; + } + + for col in min_col..=max_col { + visit(col, min_row); + if max_row != min_row { + visit(col, max_row); + } + } + + if max_row > min_row + 1 { + for row in (min_row + 1)..max_row { + visit(min_col, row); + if max_col != min_col { + visit(max_col, row); + } + } + } + } + + fn cell_coords(&self, x: f64, y: f64) -> (usize, usize) { + let col = ((x - self.min_x) / self.cell_width).floor() as isize; + let row = ((y - self.min_y) / self.cell_height).floor() as isize; + ( + col.clamp(0, self.cols.saturating_sub(1) as isize) as usize, + row.clamp(0, self.rows.saturating_sub(1) as isize) as usize, + ) + } + + fn cell_index(&self, col: usize, row: usize) -> usize { + row * self.cols + col + } + + fn cell_rect_distance2(&self, col: usize, row: usize, x: f64, y: f64) -> f64 { + let x0 = self.min_x + col as f64 * self.cell_width; + let x1 = x0 + self.cell_width; + let y0 = self.min_y + row as f64 * self.cell_height; + let y1 = y0 + self.cell_height; + + let dx = if x < x0 { + x0 - x + } else if x > x1 { + x - x1 + } else { + 0.0 + }; + let dy = if y < y0 { + y0 - y + } else if y > y1 { + y - y1 + } else { + 0.0 + }; + + dx * dx + dy * dy + } } /// A track sector defined by GPS sample index boundaries. @@ -87,14 +273,14 @@ pub fn extract_gps_track(ld: &LdFile) -> Option { } } - Some(TrackData { x, y, time, freq }) + Some(TrackData::from_normalized_parts(x, y, time, freq)) } /// Find a GPS channel by looking for "gps" + one of the given suffixes in the channel name. fn find_gps_channel<'a>(ld: &'a LdFile, suffixes: &[&str]) -> Option<&'a crate::ChannelMeta> { let channels = &ld.channels; channels.iter().find(|ch| { - let name = ch.name.to_lowercase().replace(['.', '_'], " "); + let name = normalize_channel_name(&ch.name); name.contains("gps") && suffixes.iter().any(|s| name.contains(s)) }) } @@ -102,6 +288,10 @@ fn find_gps_channel<'a>(ld: &'a LdFile, suffixes: &[&str]) -> Option<&'a crate:: /// Find the nearest GPS sample to the given normalized (x, y) coordinate. /// Returns the sample index. pub fn find_nearest_sample(track: &TrackData, x: f64, y: f64) -> usize { + if let Some(index) = &track.spatial_index { + return index.find_nearest(track, x, y); + } + let mut best_idx = 0; let mut best_dist = f64::MAX; for i in 0..track.x.len() { @@ -324,12 +514,12 @@ mod tests { #[test] fn test_find_nearest_sample() { - let track = TrackData { - x: vec![0.0, 1.0, 2.0, 3.0], - y: vec![0.0, 0.0, 1.0, 1.0], - time: vec![0.0, 0.05, 0.1, 0.15], - freq: 20, - }; + let track = TrackData::from_normalized_parts( + vec![0.0, 1.0, 2.0, 3.0], + vec![0.0, 0.0, 1.0, 1.0], + vec![0.0, 0.05, 0.1, 0.15], + 20, + ); assert_eq!(find_nearest_sample(&track, 0.1, 0.1), 0); assert_eq!(find_nearest_sample(&track, 2.1, 0.9), 2); assert_eq!(find_nearest_sample(&track, 3.0, 1.0), 3); @@ -337,12 +527,8 @@ mod tests { #[test] fn test_resample_to_track() { - let track = TrackData { - x: vec![0.0, 0.0], - y: vec![0.0, 0.0], - time: vec![0.0, 0.5], - freq: 2, - }; + let track = + TrackData::from_normalized_parts(vec![0.0, 0.0], vec![0.0, 0.0], vec![0.0, 0.5], 2); // 10 Hz source data let data: Vec = (0..10).map(|i| i as f64 * 10.0).collect(); let resampled = resample_to_track(&track, &data, 10); diff --git a/crates/i3rs-core/tests/integration_tests.rs b/crates/i3rs-core/tests/integration_tests.rs index 375add1..3d05536 100644 --- a/crates/i3rs-core/tests/integration_tests.rs +++ b/crates/i3rs-core/tests/integration_tests.rs @@ -55,6 +55,58 @@ fn channel_count() { assert_eq!(ld.channels.len(), 199); } +#[test] +fn normalized_channel_lookup_finds_expected_channels() { + let ld = LdFile::open(TEST_LD).unwrap(); + assert_eq!( + ld.find_channel_by_name("GPS_Latitude") + .map(|channel| channel.index), + ld.channels + .iter() + .find(|channel| channel.name == "GPS Latitude") + .map(|channel| channel.index) + ); + assert_eq!( + ld.find_channel_by_name("Vehicle.Speed") + .map(|channel| channel.index), + ld.channels + .iter() + .find(|channel| channel.name == "Vehicle Speed") + .map(|channel| channel.index) + ); +} + +#[test] +fn vir_lap_parser_snapshot() { + let ld = LdFile::open(TEST_LD).unwrap(); + let laps = detect_laps(&ld); + let track = extract_gps_track(&ld).expect("GPS track should parse"); + + assert_eq!(ld.channels.len(), 199); + assert_eq!(laps.len(), 3); + assert_eq!(track.x.len(), 2660); + + let engine_speed = ld + .channels + .iter() + .find(|channel| channel.name == "Engine Speed") + .expect("Engine Speed channel missing"); + let lap_number = ld + .channels + .iter() + .find(|channel| channel.name == "Lap Number") + .expect("Lap Number channel missing"); + let gps_latitude = ld + .channels + .iter() + .find(|channel| channel.name == "GPS Latitude") + .expect("GPS Latitude channel missing"); + + assert_eq!(engine_speed.n_data, 2660); + assert_eq!(lap_number.n_data, 266); + assert_eq!(gps_latitude.n_data, 2660); +} + #[test] fn duration_is_plausible() { let ld = LdFile::open(TEST_LD).unwrap(); diff --git a/docs/performance-regression.md b/docs/performance-regression.md new file mode 100644 index 0000000..e1b71af --- /dev/null +++ b/docs/performance-regression.md @@ -0,0 +1,67 @@ +# Performance Regression Guardrails + +This repository tracks performance in two layers: + +- `crates/i3rs-core/benches/perf_benches.rs` provides Criterion numbers for the core hot paths. +- `crates/i3rs-app` can emit runtime percentile summaries behind the `perf_metrics` feature for end-to-end UI scenarios. + +## CI Reporting + +Pull requests run a report-only perf bench job in `.github/workflows/pr-verify.yml`. +The job does not enforce numeric thresholds yet; it uploads: + +- `perf-bench-output.txt` +- `target/criterion/` + +That keeps perf visible in CI while we gather more stable history across machines. + +## Local Commands + +Core benches: + +```bash +cargo bench -p i3rs-core --bench perf_benches -- --output-format bencher +``` + +App runtime summaries: + +```bash +cargo run -p i3rs-app --features perf_metrics +``` + +## Acceptance Targets + +These come from the performance pass plan and are the thresholds we expect future work to preserve: + +| Area | Target | +| --- | --- | +| Main-thread stall after file selection | under 16 ms for steady-state interactions | +| First visible plot after channel add | UI remains responsive while background decode runs | +| Graph pan/zoom p95 | at least 2x better than the pre-pass baseline on an 8-channel tiled view | +| Track hover latency | visibly smooth and below pre-pass baseline | +| Physical channel decode lifetime | decode at most once per loaded session unless the session changes | + +## Current Baseline + +The table below is the repo-tracked baseline captured after the shared-cache, background-work, and render-cache pass landed. Future changes should compare against these numbers and update the table intentionally when a new stable baseline is accepted. + +| Benchmark | Current baseline | +| --- | --- | +| `LdFile::read_channel_data/VIR_LAP/Engine Speed` | 3,132 ns/iter (+/- 41) | +| `downsample_minmax/synthetic_1m_to_2k` | 437,853 ns/iter (+/- 1,359) | +| `evaluate_expression_with_aliases/synthetic` | 2,491,055 ns/iter (+/- 53,978) | +| `compute_fft_with_planner/synthetic_32k` | 238,436 ns/iter (+/- 2,122) | +| `find_nearest_sample/synthetic_200k` | 5,209 ns/iter (+/- 40) | + +## Before/After Tracking + +Use this template when capturing a new round of perf results for a change: + +| Scenario | Before | After | Notes | +| --- | --- | --- | --- | +| Open `VIR_LAP.ld` | | | | +| Open synthetic large session | | | | +| Add several graph channels | | | | +| Pan/zoom graph | | | | +| Track map hover | | | | +| Edit multi-input math channel | | | | From 2323ca222b517906cbb10543ba85db5b501d0f78 Mon Sep 17 00:00:00 2001 From: Zachary Friss Date: Wed, 22 Apr 2026 13:08:08 -0400 Subject: [PATCH 2/6] fix clippy --- crates/i3rs-core/src/math_engine.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/i3rs-core/src/math_engine.rs b/crates/i3rs-core/src/math_engine.rs index 982629b..0df8040 100644 --- a/crates/i3rs-core/src/math_engine.rs +++ b/crates/i3rs-core/src/math_engine.rs @@ -666,12 +666,12 @@ fn output_len_impl(expr: &Expr, resolver: &ChannelResolver<'_>, output_freq: u16 let refs = referenced_channels(expr); let mut max_duration: f64 = 0.0; for name in &refs { - if let Some((_, channel)) = resolver.get_channel(name) { - if channel.freq > 0 { - let dur = channel.samples.len() as f64 / channel.freq as f64; - if dur > max_duration { - max_duration = dur; - } + if let Some((_, channel)) = resolver.get_channel(name) + && channel.freq > 0 + { + let dur = channel.samples.len() as f64 / channel.freq as f64; + if dur > max_duration { + max_duration = dur; } } } From 031bad1562fa1e38134878cd3dd30fda8b1f2f1a Mon Sep 17 00:00:00 2001 From: Zachary Friss Date: Wed, 22 Apr 2026 18:00:24 -0400 Subject: [PATCH 3/6] Address actionable PR review feedback --- crates/i3rs-app/src/app.rs | 13 ++++-- crates/i3rs-app/src/background_jobs.rs | 8 +++- crates/i3rs-app/src/panels/math_editor.rs | 52 ++++++++++++++++++++++- crates/i3rs-app/src/state.rs | 18 ++++++++ 4 files changed, 85 insertions(+), 6 deletions(-) diff --git a/crates/i3rs-app/src/app.rs b/crates/i3rs-app/src/app.rs index acc8178..2c74bbe 100644 --- a/crates/i3rs-app/src/app.rs +++ b/crates/i3rs-app/src/app.rs @@ -496,7 +496,9 @@ impl App { } } JobResult::DecodePhysicalChannel { - session_id, result, .. + session_id, + channel_idx, + result, } => { if self.shared.session_id != session_id { continue; @@ -511,11 +513,14 @@ impl App { decoded.freq, ); if !self.shared.math_channels.is_empty() { - math_editor::evaluate_all_math_channels(&mut self.shared); + math_editor::reevaluate_math_channels_waiting_on_inputs( + &mut self.shared, + ); } self.maybe_apply_pending_workspace_restore(); } Err(err) => { + self.shared.cancel_physical_channel_decode(channel_idx); self.load_error = Some(format!("Failed to decode channel: {err}")); } } @@ -547,7 +552,9 @@ impl App { ) { self.shared.invalidate_derived_caches(); if !self.shared.math_channels.is_empty() { - math_editor::evaluate_all_math_channels(&mut self.shared); + math_editor::reevaluate_math_channels_waiting_on_inputs( + &mut self.shared, + ); } } self.maybe_apply_pending_workspace_restore(); diff --git a/crates/i3rs-app/src/background_jobs.rs b/crates/i3rs-app/src/background_jobs.rs index bec4ba8..e498986 100644 --- a/crates/i3rs-app/src/background_jobs.rs +++ b/crates/i3rs-app/src/background_jobs.rs @@ -82,6 +82,7 @@ pub enum JobResult { }, DecodePhysicalChannel { session_id: u64, + channel_idx: usize, result: Result, }, BuildTrackData { @@ -393,7 +394,11 @@ async fn run_wasm_job(request: JobRequest) -> JobResult { yield_to_browser().await; let result = perform_decode_physical_channel(ld, channel_idx); yield_to_browser().await; - JobResult::DecodePhysicalChannel { session_id, result } + JobResult::DecodePhysicalChannel { + session_id, + channel_idx, + result, + } } JobRequest::BuildTrackData { request_id: _, @@ -472,6 +477,7 @@ impl BackgroundJobs { channel_idx, } => JobResult::DecodePhysicalChannel { session_id, + channel_idx, result: perform_decode_physical_channel(ld, channel_idx), }, JobRequest::BuildTrackData { diff --git a/crates/i3rs-app/src/panels/math_editor.rs b/crates/i3rs-app/src/panels/math_editor.rs index f02657c..64f4e18 100644 --- a/crates/i3rs-app/src/panels/math_editor.rs +++ b/crates/i3rs-app/src/panels/math_editor.rs @@ -484,6 +484,21 @@ pub fn evaluate_all_math_channels(shared: &mut SharedState) { shared.invalidate_derived_caches(); } +pub fn reevaluate_math_channels_waiting_on_inputs(shared: &mut SharedState) { + let waiting_ids: Vec = shared + .math_channels + .iter() + .filter(|mc| mc.evaluation_state == MathEvaluationState::WaitingForInputs) + .map(|mc| mc.id) + .collect(); + + for math_id in waiting_ids { + if let Some(idx) = shared.math_channel_index_by_id(math_id) { + queue_math_channel_evaluation(shared, idx); + } + } +} + /// Compute a topological evaluation order for math channels based on their dependencies. /// Falls back to original index order for channels involved in cycles. pub fn topological_eval_order(shared: &SharedState) -> Vec { @@ -718,8 +733,11 @@ struct EvaluationInputs { #[cfg(test)] mod tests { - use super::{build_math_evaluation_job, queue_math_channel_evaluation}; - use crate::state::SharedState; + use super::{ + build_math_evaluation_job, queue_math_channel_evaluation, + reevaluate_math_channels_waiting_on_inputs, + }; + use crate::state::{MathEvaluationState, SharedState}; #[test] fn math_jobs_follow_channel_ids_after_list_reordering() { @@ -743,4 +761,34 @@ mod tests { assert_eq!(job.math_id, second_id); assert_eq!(shared.math_channels[0].name, "B"); } + + #[test] + fn reevaluate_only_requeues_math_channels_waiting_on_inputs() { + let mut shared = SharedState::new(); + let waiting = + shared.create_math_channel_def("Waiting".into(), "A".into(), String::new(), 2); + let waiting_id = waiting.id; + let mut ready = + shared.create_math_channel_def("Ready".into(), "1".into(), String::new(), 2); + ready.evaluation_state = MathEvaluationState::Ready; + shared.math_channels.push(waiting); + shared.math_channels.push(ready); + shared.math_channels[0].evaluation_state = MathEvaluationState::WaitingForInputs; + shared.math_channels[0].error = Some("Waiting for source channels...".into()); + + reevaluate_math_channels_waiting_on_inputs(&mut shared); + + assert_eq!( + shared.take_requested_math_channel_evaluations(), + vec![waiting_id] + ); + assert_eq!( + shared.math_channels[0].evaluation_state, + MathEvaluationState::Queued + ); + assert_eq!( + shared.math_channels[1].evaluation_state, + MathEvaluationState::Ready + ); + } } diff --git a/crates/i3rs-app/src/state.rs b/crates/i3rs-app/src/state.rs index 886ff29..76f1767 100644 --- a/crates/i3rs-app/src/state.rs +++ b/crates/i3rs-app/src/state.rs @@ -768,3 +768,21 @@ impl SharedState { .remove(&channel_idx); } } + +#[cfg(test)] +mod tests { + use super::SharedState; + + #[test] + fn canceled_channel_decode_can_be_requested_again() { + let shared = SharedState::new(); + + shared.request_physical_channel_decode(7); + assert_eq!(shared.take_requested_physical_channel_decodes(), vec![7]); + + shared.cancel_physical_channel_decode(7); + shared.request_physical_channel_decode(7); + + assert_eq!(shared.take_requested_physical_channel_decodes(), vec![7]); + } +} From 1e2cc59bbe03b66711af1e05669c7bf71c416e85 Mon Sep 17 00:00:00 2001 From: Zachary Friss Date: Wed, 22 Apr 2026 18:45:02 -0400 Subject: [PATCH 4/6] Remove blocking background job queue --- crates/i3rs-app/src/background_jobs.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/i3rs-app/src/background_jobs.rs b/crates/i3rs-app/src/background_jobs.rs index e498986..8b4bbb1 100644 --- a/crates/i3rs-app/src/background_jobs.rs +++ b/crates/i3rs-app/src/background_jobs.rs @@ -460,8 +460,8 @@ pub struct BackgroundJobs { impl BackgroundJobs { pub fn new() -> Self { let (request_tx, request_rx) = - crossbeam_channel::bounded::<(JobRequest, egui::Context)>(64); - let (result_tx, result_rx) = crossbeam_channel::bounded::(64); + crossbeam_channel::unbounded::<(JobRequest, egui::Context)>(); + let (result_tx, result_rx) = crossbeam_channel::unbounded::(); std::thread::spawn(move || { while let Ok((request, ctx)) = request_rx.recv() { From c54d91d0f9fa84b9e3a7cae6e796ac3ddf0027c5 Mon Sep 17 00:00:00 2001 From: Zachary Friss Date: Wed, 22 Apr 2026 18:49:29 -0400 Subject: [PATCH 5/6] Fix workspace clippy warnings --- crates/i3rs-app/src/app.rs | 2 +- crates/i3rs-app/src/background_jobs.rs | 6 ++-- crates/i3rs-app/src/panels/graph.rs | 8 ++++-- crates/i3rs-app/src/panels/track_map.rs | 37 ++++++++++++------------- 4 files changed, 27 insertions(+), 26 deletions(-) diff --git a/crates/i3rs-app/src/app.rs b/crates/i3rs-app/src/app.rs index 2c74bbe..28a704e 100644 --- a/crates/i3rs-app/src/app.rs +++ b/crates/i3rs-app/src/app.rs @@ -486,7 +486,7 @@ impl App { continue; } - match result { + match *result { Ok(loaded) => { self.install_loaded_session(loaded, pending.workspace_snapshot); } diff --git a/crates/i3rs-app/src/background_jobs.rs b/crates/i3rs-app/src/background_jobs.rs index 8b4bbb1..6b6478a 100644 --- a/crates/i3rs-app/src/background_jobs.rs +++ b/crates/i3rs-app/src/background_jobs.rs @@ -78,7 +78,7 @@ pub struct EvaluatedMathChannel { pub enum JobResult { LoadSession { request_id: u64, - result: Result, + result: Box>, }, DecodePhysicalChannel { session_id: u64, @@ -381,7 +381,7 @@ async fn run_wasm_job(request: JobRequest) -> JobResult { match request { JobRequest::LoadSession { request_id, source } => { yield_to_browser().await; - let result = perform_load_session(source); + let result = Box::new(perform_load_session(source)); yield_to_browser().await; JobResult::LoadSession { request_id, result } } @@ -468,7 +468,7 @@ impl BackgroundJobs { let result = match request { JobRequest::LoadSession { request_id, source } => JobResult::LoadSession { request_id, - result: perform_load_session(source), + result: Box::new(perform_load_session(source)), }, JobRequest::DecodePhysicalChannel { request_id: _, diff --git a/crates/i3rs-app/src/panels/graph.rs b/crates/i3rs-app/src/panels/graph.rs index 65cc1ff..e4854af 100644 --- a/crates/i3rs-app/src/panels/graph.rs +++ b/crates/i3rs-app/src/panels/graph.rs @@ -897,7 +897,7 @@ impl GraphPanel { Self::draw_channels(plot_ui, &plotted, &freq_map, x_axis, shared, render_cache); if show_markers { - Self::draw_lap_markers(plot_ui, &laps, x_axis); + Self::draw_lap_markers(plot_ui, laps, x_axis); } if let Some(t) = cursor_time { @@ -1037,7 +1037,7 @@ impl GraphPanel { ); if show_markers { - Self::draw_lap_markers(plot_ui, &laps, x_axis); + Self::draw_lap_markers(plot_ui, laps, x_axis); } if let Some(t) = cursor_time { @@ -2907,7 +2907,9 @@ fn find_distance_channel_in_ld(ld: &LdFile) -> Option { None } -fn find_speed_channel(shared: &SharedState) -> (Option<(Arc<[f64]>, u16, String)>, bool) { +type SpeedChannelLookup = Option<(Arc<[f64]>, u16, String)>; + +fn find_speed_channel(shared: &SharedState) -> (SpeedChannelLookup, bool) { let speed_names = [ "gps speed", "vehicle speed", diff --git a/crates/i3rs-app/src/panels/track_map.rs b/crates/i3rs-app/src/panels/track_map.rs index a8690be..add9a03 100644 --- a/crates/i3rs-app/src/panels/track_map.rs +++ b/crates/i3rs-app/src/panels/track_map.rs @@ -736,15 +736,14 @@ impl CachedSectorMarkers { let markers = sectors .iter() .enumerate() - .filter_map(|(idx, sector)| { - (sector.start_index < track.x.len()).then(|| CachedSectorMarker { - name: format!("{} start", sector.name), - color: CHANNEL_COLORS[idx % CHANNEL_COLORS.len()], - points: [PlotPoint::new( - track.x[sector.start_index], - track.y[sector.start_index], - )], - }) + .filter(|(_, sector)| sector.start_index < track.x.len()) + .map(|(idx, sector)| CachedSectorMarker { + name: format!("{} start", sector.name), + color: CHANNEL_COLORS[idx % CHANNEL_COLORS.len()], + points: [PlotPoint::new( + track.x[sector.start_index], + track.y[sector.start_index], + )], }) .collect(); @@ -755,6 +754,16 @@ impl CachedSectorMarkers { } } +fn delta_color(delta: f64) -> egui::Color32 { + if delta < -0.01 { + egui::Color32::from_rgb(100, 255, 100) // green = faster + } else if delta > 0.01 { + egui::Color32::from_rgb(255, 100, 100) // red = slower + } else { + egui::Color32::from_gray(200) + } +} + #[cfg(test)] mod tests { use super::*; @@ -828,13 +837,3 @@ mod tests { assert!(panel.cursor_marker_cache.is_some()); } } - -fn delta_color(delta: f64) -> egui::Color32 { - if delta < -0.01 { - egui::Color32::from_rgb(100, 255, 100) // green = faster - } else if delta > 0.01 { - egui::Color32::from_rgb(255, 100, 100) // red = slower - } else { - egui::Color32::from_gray(200) - } -} From bf93a4dacba9ab205d81b61bdf70f87ced9a391b Mon Sep 17 00:00:00 2001 From: Zachary Friss Date: Wed, 22 Apr 2026 19:11:50 -0400 Subject: [PATCH 6/6] Bump release versions --- Cargo.lock | 6 +++--- Cargo.toml | 4 ++-- crates/i3rs-app/Packager.toml | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6fce0f7..8f6fa47 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1618,7 +1618,7 @@ checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" [[package]] name = "i3rs-app" -version = "0.1.1" +version = "0.2.0" dependencies = [ "crossbeam-channel", "eframe", @@ -1640,14 +1640,14 @@ dependencies = [ [[package]] name = "i3rs-cli" -version = "0.1.1" +version = "0.2.0" dependencies = [ "i3rs-core", ] [[package]] name = "i3rs-core" -version = "0.1.1" +version = "0.2.0" dependencies = [ "criterion", "half", diff --git a/Cargo.toml b/Cargo.toml index 5ab9d29..6cfb5f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/i3rs-core", "crates/i3rs-app", "crates/i3rs-cli"] resolver = "2" [workspace.package] -version = "0.1.1" +version = "0.2.0" edition = "2024" authors = ["Friss"] license = "MIT" @@ -11,7 +11,7 @@ homepage = "https://github.com/Friss/i3rs" repository = "https://github.com/Friss/i3rs" [workspace.dependencies] -i3rs-core = { path = "crates/i3rs-core", version = "0.1.1" } +i3rs-core = { path = "crates/i3rs-core", version = "0.2.0" } half = "2.7.1" memmap2 = "0.9.10" eframe = "0.34.1" diff --git a/crates/i3rs-app/Packager.toml b/crates/i3rs-app/Packager.toml index 4aadbb0..6720326 100644 --- a/crates/i3rs-app/Packager.toml +++ b/crates/i3rs-app/Packager.toml @@ -1,6 +1,6 @@ product-name = "i3rs" name = "i3rs-app" -version = "0.2.0" +version = "0.3.0" identifier = "io.github.friss.i3rs" publisher = "i3rs contributors" authors = ["i3rs contributors"]