diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..ac2b23f8 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.x86_64-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5df0c7b1..97c3dd1c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,7 +63,7 @@ jobs: # we specify bash to get pipefail; it guards against the `curl` command # failing. otherwise `sh` won't catch that `curl` returned non-0 shell: bash - run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.22.1/cargo-dist-installer.sh | sh" + run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.23.0/cargo-dist-installer.sh | sh" - name: Cache cargo-dist uses: actions/upload-artifact@v4 with: @@ -117,6 +117,10 @@ jobs: - uses: actions/checkout@v4 with: submodules: recursive + - uses: swatinem/rust-cache@v2 + with: + key: ${{ join(matrix.targets, '-') }} + cache-provider: ${{ matrix.cache_provider }} - name: Install cargo-dist run: ${{ matrix.install_dist }} # Get the dist-manifest diff --git a/CHANGELOG.md b/CHANGELOG.md index 6926736c..7d16f9c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +## [0.24.0] + +### Added + +- Initial support for `HDF` files, starting with bifrost (TX) loop current. The feature is currently guarded behind a feature flag, enabling it is tracked at: https://github.com/luftkode/logviewer-rs/issues/84. +- + +### Changed + +- Various UI tweaks + +### Internal + +- Upgraded `cargo-dist` `0.22.1` -> `0.23.0` + ## [0.23.0] ### Added diff --git a/Cargo.lock b/Cargo.lock index aa1b98dc..322f271a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -189,6 +189,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "ashpd" version = "0.9.2" @@ -592,6 +598,15 @@ dependencies = [ "error-code", ] +[[package]] +name = "cmake" +version = "0.1.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e43aa7fd152b1f968787f7dbcdeb306d1867ff373c69955211876c053f91a" +dependencies = [ + "cc", +] + [[package]] name = "colorchoice" version = "1.0.2" @@ -1414,6 +1429,74 @@ version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb" +[[package]] +name = "hdf5-metno" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3167207f341f305141d3fbae48690442cb4b57771c827ccfae7b66b7cd8891bc" +dependencies = [ + "bitflags 2.6.0", + "cfg-if", + "hdf5-metno-derive", + "hdf5-metno-sys", + "hdf5-metno-types", + "lazy_static", + "libc", + "ndarray", + "paste", +] + +[[package]] +name = "hdf5-metno-derive" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c75d3c9831cc601c2f2829dcdc8178b2503a9127ce1255af33b7d6d3067d44e8" +dependencies = [ + "proc-macro-crate", + "proc-macro-error", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "hdf5-metno-src" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b746b01050a79cb2914d4d62ae61b8a7e93cc7699a050081b0f88329e859dcd" +dependencies = [ + "cmake", +] + +[[package]] +name = "hdf5-metno-sys" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7be8ee30e5034ee48dd30bc85a2fdde6e1ec0e5e19598409e8e9955eab14af" +dependencies = [ + "hdf5-metno-src", + "libc", + "libloading", + "parking_lot", + "pkg-config", + "regex", + "serde", + "serde_derive", + "winreg", +] + +[[package]] +name = "hdf5-metno-types" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "236c29ece21fd13543f45b1578c4e65d5d61da38d90192812f1f6ad86ac255ac" +dependencies = [ + "ascii", + "cfg-if", + "hdf5-metno-sys", + "libc", +] + [[package]] name = "heck" version = "0.5.0" @@ -1572,6 +1655,12 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.159" @@ -1644,7 +1733,7 @@ dependencies = [ [[package]] name = "logviewer-rs" -version = "0.23.0" +version = "0.24.0" dependencies = [ "chrono", "eframe", @@ -1659,6 +1748,7 @@ dependencies = [ "plot_util", "rfd", "serde", + "skytem_hdf", "skytem_logs", "strum", "strum_macros", @@ -1678,6 +1768,16 @@ dependencies = [ "crc", ] +[[package]] +name = "matrixmultiply" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9380b911e3e96d10c1f415da0876389aaf1b56759054eeb0de7df940c456ba1a" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "memchr" version = "2.7.4" @@ -1712,6 +1812,21 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ndarray" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + [[package]] name = "ndk" version = "0.9.0" @@ -1761,12 +1876,30 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2063,6 +2196,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pbkdf2" version = "0.12.2" @@ -2176,6 +2315,21 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2" +[[package]] +name = "portable-atomic" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc9c68a3f6da06753e9335d63e27f6b9754dd1920d941135b7ea8224f141adb2" + +[[package]] +name = "portable-atomic-util" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcdd8420072e66d54a407b3316991fe946ce3ab1083a7f575b2463866624704d" +dependencies = [ + "portable-atomic", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -2210,6 +2364,29 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -2295,6 +2472,12 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + [[package]] name = "rayon" version = "1.10.0" @@ -2476,6 +2659,15 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_spanned" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +dependencies = [ + "serde", +] + [[package]] name = "sha1" version = "0.10.6" @@ -2508,6 +2700,29 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +[[package]] +name = "skytem_hdf" +version = "0.1.0" +dependencies = [ + "byteorder", + "chrono", + "derive_more", + "egui_plot", + "getset", + "hdf5-metno", + "log", + "log_if", + "ndarray", + "num-traits", + "plot_util", + "serde", + "serde-big-array", + "strum", + "strum_macros", + "testresult", + "toml", +] + [[package]] name = "skytem_logs" version = "0.1.0" @@ -2711,11 +2926,26 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "toml" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + [[package]] name = "toml_datetime" version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +dependencies = [ + "serde", +] [[package]] name = "toml_edit" @@ -2724,6 +2954,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" dependencies = [ "indexmap", + "serde", + "serde_spanned", "toml_datetime", "winnow", ] @@ -3389,6 +3621,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "serde", + "windows-sys 0.48.0", +] + [[package]] name = "x11-dl" version = "2.21.0" diff --git a/Cargo.toml b/Cargo.toml index 2e9add44..36d8028b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "logviewer-rs" description = "Log viewer app for viewing plots of data from projects such as motor and generator control" authors = ["SkyTEM Surveys", "Marc Beck König"] -version = "0.23.0" +version = "0.24.0" edition = "2021" repository = "https://github.com/luftkode/logviewer-rs" homepage = "https://github.com/luftkode/logviewer-rs" @@ -30,6 +30,8 @@ byteorder = "1.5.0" chrono = { version = "0.4.38", features = ["serde"] } getset = "0.1.3" derive_more = { version = "1", features = ["full"] } +num-traits = "0.2.19" +toml = "0.8.19" # Dev dependencies testresult = "0.4.1" @@ -39,6 +41,7 @@ pretty_assertions = "1.4.1" skytem_logs = { version = "*", path = "crates/skytem_logs" } log_if = { version = "*", path = "crates/log_if" } plot_util = { version = "*", path = "crates/plot_util" } +skytem_hdf = { version = "*", path = "crates/skytem_hdf" } egui_plot.workspace = true log.workspace = true serde.workspace = true @@ -59,11 +62,16 @@ egui-notify = "0.16.0" [dev-dependencies] testresult.workspace = true +[features] +default = [] +hdf = [] + # native: [target.'cfg(not(target_arch = "wasm32"))'.dependencies] env_logger = "0.11" zip = "2.2.0" + # web: [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen-futures = "0.4" @@ -85,26 +93,6 @@ opt-level = 2 [profile.dist] inherits = "release" -# Config for 'cargo dist' -[workspace.metadata.dist] -# The preferred cargo-dist version to use in CI (Cargo.toml SemVer syntax) -cargo-dist-version = "0.22.1" -# CI backends to support -ci = "github" -# The installers to generate for each app -installers = ["shell", "powershell", "msi"] -# Target platforms to build apps for (Rust target-triple syntax) -targets = [ - "aarch64-apple-darwin", - "x86_64-apple-darwin", - "x86_64-unknown-linux-gnu", - "x86_64-pc-windows-msvc", -] -# Path that installers should place binaries in -install-path = "CARGO_HOME" -# Whether to install an updater program -install-updater = true - [lints] workspace = true diff --git a/Justfile b/Justfile index 78e09622..35b5a89b 100644 --- a/Justfile +++ b/Justfile @@ -91,4 +91,5 @@ install-extra-devtools: cargo install cargo-limit --locked cargo install bacon --locked - +apt-install-hdf5-header: + sudo apt install libhdf5-dev diff --git a/crates/plot_util/Cargo.toml b/crates/plot_util/Cargo.toml index a7fcc2e6..a0776b9f 100644 --- a/crates/plot_util/Cargo.toml +++ b/crates/plot_util/Cargo.toml @@ -12,7 +12,7 @@ egui_plot.workspace = true egui.workspace = true serde.workspace = true chrono.workspace = true -num-traits = "0.2.19" +num-traits.workspace = true [dev-dependencies] testresult.workspace = true diff --git a/crates/plot_util/src/mipmap.rs b/crates/plot_util/src/mipmap.rs index d144131b..b2523adc 100644 --- a/crates/plot_util/src/mipmap.rs +++ b/crates/plot_util/src/mipmap.rs @@ -275,7 +275,10 @@ impl MipMap2D { /// /// This function is highly optimized for performance. #[inline(always)] -#[allow(clippy::needless_pass_by_value)] +#[allow( + clippy::needless_pass_by_value, + reason = "It's a numeric primitive, we don't mind passing by value" +)] pub fn fast_unix_ns_to_usize( unix_ts_ns: T, ) -> usize { diff --git a/crates/skytem_hdf/Cargo.toml b/crates/skytem_hdf/Cargo.toml new file mode 100644 index 00000000..9f01b1af --- /dev/null +++ b/crates/skytem_hdf/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "skytem_hdf" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +plot_util = { version = "*", path = "../plot_util" } +log_if = { version = "*", path = "../log_if" } +egui_plot.workspace = true +serde.workspace = true +serde-big-array.workspace = true +strum.workspace = true +strum_macros.workspace = true +byteorder.workspace = true +chrono.workspace = true +getset.workspace = true +derive_more.workspace = true +log.workspace = true +num-traits.workspace = true +toml.workspace = true +ndarray = "0.16.1" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +hdf5 = { package = "hdf5-metno", version = "0.9", features = ["static"] } + +[dev-dependencies] +testresult.workspace = true diff --git a/crates/skytem_hdf/src/bifrost.rs b/crates/skytem_hdf/src/bifrost.rs new file mode 100644 index 00000000..b3eaed5e --- /dev/null +++ b/crates/skytem_hdf/src/bifrost.rs @@ -0,0 +1,247 @@ +use std::{io, path::Path}; + +use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, NaiveTime, Utc}; + +use hdf5::Dataset; +use log_if::prelude::*; +use num_traits::ToPrimitive; +use serde::{Deserialize, Serialize}; +use stream_descriptor::StreamDescriptor; + +use crate::util::{read_any_attribute_to_string, read_string_attribute}; + +mod stream_descriptor; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BifrostLoopCurrent { + // It does not actually contain a timestamp so we just add 1. january current year to make it slightly more convenient + starting_timestamp_utc: DateTime, + dataset_description: String, + raw_plots: Vec, + metadata: Vec<(String, String)>, +} + +impl BifrostLoopCurrent { + pub const DATASET_NAME: &str = "hm_current"; + pub const DATASET_DIMENSIONS: usize = 3; +} + +impl BifrostLoopCurrent { + /// Opens the [`BifrostCurrent`] dataset and checks the validity of the [`Dataset`] structure. + /// + /// # Returns + /// + /// The [`BifrostCurrent`] dataset as a [`Dataset`]. + /// + /// # Errors + /// + /// If opening the file or any validity check fails. + pub fn open_bifrost_current_dataset>(path: P) -> io::Result { + let hdf5_file = hdf5::File::open(&path)?; + + let Ok(current_data_set) = hdf5_file.dataset(Self::DATASET_NAME) else { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "No {dataset_name} dataset in {fname}", + dataset_name = Self::DATASET_NAME, + fname = path.as_ref().display() + ), + )); + }; + + if current_data_set.ndim() != Self::DATASET_DIMENSIONS { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "Expected {ndim} dimensions in dataset {dataset_name}", + dataset_name = Self::DATASET_NAME, + ndim = Self::DATASET_DIMENSIONS + ), + )); + } + + let dataset_attributes = current_data_set.attr_names()?; + + if !dataset_attributes.contains(&"description".to_owned()) { + let comma_separated_attr_list = dataset_attributes + .iter() + .map(|a| a.to_string()) + .collect::>() + .join(", "); + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "Expected 'description' among dataset attributes, but attributes do not contain 'description'. Attributes in dataset: {comma_separated_attr_list}", + ))); + } + + Ok(current_data_set) + } + + pub fn from_path>(path: P) -> io::Result { + let current_dataset = Self::open_bifrost_current_dataset(path)?; + + let dataset_description = read_string_attribute(¤t_dataset.attr("description")?)?; + let stream_descriptor_toml_str = + read_string_attribute(¤t_dataset.attr("stream_descriptor")?)?; + let Ok(stream_descriptor): Result = + toml::from_str(&stream_descriptor_toml_str) + else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "Failed decoding 'stream_descriptor' string as TOML from stream_descriptor: {stream_descriptor_toml_str}" + ))); + }; + + for a in current_dataset.attr_names()? { + let attr = current_dataset.attr(&a)?; + let attr_val_as_string = read_any_attribute_to_string(&attr)?; + eprintln!("Attr: {attr_val_as_string}"); + } + + let data_3: ndarray::Array3 = current_dataset.read()?; + + let (gps_timestamps, samples_per_ts, polarities) = data_3.dim(); + log::info!("Got bifrost current dataset with: GPS timestamps: {gps_timestamps}, samples per timestamp: {samples_per_ts}, polarities: {polarities}"); + let mut metadata = vec![ + ("Dataset Description".into(), dataset_description.clone()), + ("GPS Timestamps".into(), gps_timestamps.to_string()), + ("Samples per timestamp".into(), samples_per_ts.to_string()), + ("Polarities".into(), polarities.to_string()), + ]; + metadata.extend_from_slice(&stream_descriptor.to_metadata()); + // There's no timestamp (it has to be correlated with GPS data) so we just set the time to + // 1. january current year, hopefully it will be obvious to the user that it is an auto generated timestamp and not the real one + let now = chrono::offset::Local::now(); + let first_january_this_year = + NaiveDate::from_ymd_opt(now.year(), 1, 1).expect("Invalid date"); + let first_january_this_year = NaiveDateTime::new( + first_january_this_year, + NaiveTime::from_hms_opt(0, 0, 0).expect("Invalid time"), + ) + .and_utc(); + let starting_timestamp_ns = first_january_this_year + .timestamp_nanos_opt() + .expect("timestamp as nanoseconds out of range") + .to_f64() + .expect("Failed converting timestamp to f64"); + + let mut polarity0_currents: Vec<[f64; 2]> = Vec::new(); + let mut polarity1_currents: Vec<[f64; 2]> = Vec::new(); + + let nanosec_multiplier = 1_000_000_000.0; + // Assumes one timestamp per second + let sample_step_size_approx: f64 = 1.0 * nanosec_multiplier + / (samples_per_ts + .to_f64() + .expect("Failed converting usize to f64")); + let mut timestamp_idx = 0; + let mut sample_idx = 0; + + for d in data_3.rows() { + let offset_from_start = (timestamp_idx as f64) * nanosec_multiplier + + (sample_idx as f64) * sample_step_size_approx; + + let offset_ts = starting_timestamp_ns + offset_from_start; + + sample_idx += 1; + if sample_idx == samples_per_ts { + sample_idx = 0; + timestamp_idx += 1; + } + if d.len() != 2 { + log::error!( + "Expected bifrost hm_current row length of 2, got: {}", + d.len() + ); + continue; + } + + polarity0_currents.push([offset_ts, d[0].into()]); + polarity1_currents.push([offset_ts, d[1].into()]); + } + + let plot_polarity0 = RawPlot::new( + "+ Polarity [A]".to_owned(), + polarity0_currents.clone(), + ExpectedPlotRange::OneToOneHundred, + ); + let plot_polarity1 = RawPlot::new( + "- Polarity [A]".to_owned(), + polarity1_currents.clone(), + ExpectedPlotRange::OneToOneHundred, + ); + + Ok(Self { + starting_timestamp_utc: first_january_this_year, + dataset_description, + raw_plots: vec![plot_polarity0, plot_polarity1], + metadata, + }) + } +} + +impl Plotable for BifrostLoopCurrent { + fn raw_plots(&self) -> &[RawPlot] { + &self.raw_plots + } + + fn first_timestamp(&self) -> DateTime { + self.starting_timestamp_utc + } + + fn descriptive_name(&self) -> &str { + &self.dataset_description + } + + fn labels(&self) -> Option<&[PlotLabels]> { + None + } + + fn metadata(&self) -> Option> { + Some(self.metadata.clone()) + } +} + +#[cfg(test)] +mod tests { + use testresult::TestResult; + + use super::*; + + const TEST_DATA: &str = "../../test_data/hdf5/bifrost_current/20240930_100137_bifrost.h5"; + + #[test] + fn test_read_bifrost_current() -> TestResult { + let bifrost_currents = BifrostLoopCurrent::from_path(TEST_DATA)?; + assert_eq!(&bifrost_currents.dataset_description, "TX Loop Current"); + + let expected_metadata = [ + ("Dataset Description".into(), "TX Loop Current".into()), + ("GPS Timestamps".into(), "50".into()), + ("Samples per timestamp".into(), "303".into()), + ("Polarities".into(), "2".into()), + ]; + + for (metadata_kv, expected_metadata_kv) in bifrost_currents + .metadata() + .expect("Expected metadata but contained none") + .iter() + .zip(expected_metadata.iter()) + { + assert_eq!(metadata_kv, expected_metadata_kv); + } + + let plot_polarity1 = bifrost_currents.raw_plots()[1].points(); + + let expected_first_value = 4.434181213378906; + assert_eq!(plot_polarity1.first().unwrap()[1], expected_first_value); + + let expected_last_value = 17.78993797302246; + assert_eq!(plot_polarity1.last().unwrap()[1], expected_last_value); + + Ok(()) + } +} diff --git a/crates/skytem_hdf/src/bifrost/stream_descriptor.rs b/crates/skytem_hdf/src/bifrost/stream_descriptor.rs new file mode 100644 index 00000000..432023d5 --- /dev/null +++ b/crates/skytem_hdf/src/bifrost/stream_descriptor.rs @@ -0,0 +1,459 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub(crate) struct StreamDescriptor { + stream_id: String, + chunk_size: Vec, + description: String, + unit: String, + data_type: String, + timestamp_stream: String, + axes: HashMap, + converter: Converter, + aux_metadata: AuxMetadata, +} + +impl StreamDescriptor { + /// Flattens the [`StreamDescriptor`] to a list of key-value pairs + pub(crate) fn to_metadata(&self) -> Vec<(String, String)> { + let mut metadata = Vec::new(); + + metadata.push(("stream_id".to_owned(), self.stream_id.clone())); + metadata.push(("chunk_size".to_owned(), format!("{:?}", self.chunk_size))); + metadata.push(("description".to_owned(), self.description.clone())); + metadata.push(("unit".to_owned(), self.unit.clone())); + metadata.push(("data_type".to_owned(), self.data_type.clone())); + metadata.push(("timestamp_stream".to_owned(), self.timestamp_stream.clone())); + + // Flatten axes + for (key, axis) in &self.axes { + for (sub_key, value) in axis.to_metadata() { + metadata.push((format!("axes.{key}.{sub_key}"), value)); + } + } + + // Converter + for (key, value) in self.converter.to_metadata() { + metadata.push((format!("converter.{key}"), value)); + } + + // AuxMetadata + for (key, value) in self.aux_metadata.to_metadata() { + metadata.push((format!("aux_metadata.{key}"), value)); + } + + metadata + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct Axis { + classname: String, + description: String, + values: Vec, + unit: String, +} + +impl Axis { + fn to_metadata(&self) -> Vec<(String, String)> { + vec![ + ("classname".to_owned(), self.classname.clone()), + ("description".to_owned(), self.description.clone()), + ("values".to_owned(), format!("{:?}", self.values)), + ("unit".to_owned(), self.unit.clone()), + ] + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct Converter { + classname: String, +} + +impl Converter { + fn to_metadata(&self) -> Vec<(String, String)> { + vec![("classname".to_owned(), self.classname.clone())] + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct AuxMetadata { + cal_offset: f64, + cal_scale: f64, +} + +impl AuxMetadata { + fn to_metadata(&self) -> Vec<(String, String)> { + vec![ + ("cal_offset".to_owned(), self.cal_offset.to_string()), + ("cal_scale".to_owned(), self.cal_scale.to_string()), + ] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use testresult::TestResult; + + const TEST_TOML_STR: &str = r#"stream_id = "hm_current" + chunk_size = [ + 10, + 303, + 2, + ] + description = "TX Loop Current" + unit = "A" + data_type = "numpy.float32" + timestamp_stream = "" + + [axes.0] + classname = "Primary" + description = "" + values = [] + unit = "" + + [axes.1] + classname = "Selector" + description = "hm_current" + values = [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 62, + 63, + 64, + 65, + 66, + 67, + 68, + 69, + 70, + 71, + 72, + 73, + 74, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 82, + 83, + 84, + 85, + 86, + 87, + 88, + 89, + 90, + 91, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 100, + 101, + 102, + 103, + 104, + 105, + 106, + 107, + 108, + 109, + 110, + 111, + 112, + 113, + 114, + 115, + 116, + 117, + 118, + 119, + 120, + 121, + 122, + 123, + 124, + 125, + 126, + 127, + 128, + 129, + 130, + 131, + 132, + 133, + 134, + 135, + 136, + 137, + 138, + 139, + 140, + 141, + 142, + 143, + 144, + 145, + 146, + 147, + 148, + 149, + 150, + 151, + 152, + 153, + 154, + 155, + 156, + 157, + 158, + 159, + 160, + 161, + 162, + 163, + 164, + 165, + 166, + 167, + 168, + 169, + 170, + 171, + 172, + 173, + 174, + 175, + 176, + 177, + 178, + 179, + 180, + 181, + 182, + 183, + 184, + 185, + 186, + 187, + 188, + 189, + 190, + 191, + 192, + 193, + 194, + 195, + 196, + 197, + 198, + 199, + 200, + 201, + 202, + 203, + 204, + 205, + 206, + 207, + 208, + 209, + 210, + 211, + 212, + 213, + 214, + 215, + 216, + 217, + 218, + 219, + 220, + 221, + 222, + 223, + 224, + 225, + 226, + 227, + 228, + 229, + 230, + 231, + 232, + 233, + 234, + 235, + 236, + 237, + 238, + 239, + 240, + 241, + 242, + 243, + 244, + 245, + 246, + 247, + 248, + 249, + 250, + 251, + 252, + 253, + 254, + 255, + 256, + 257, + 258, + 259, + 260, + 261, + 262, + 263, + 264, + 265, + 266, + 267, + 268, + 269, + 270, + 271, + 272, + 273, + 274, + 275, + 276, + 277, + 278, + 279, + 280, + 281, + 282, + 283, + 284, + 285, + 286, + 287, + 288, + 289, + 290, + 291, + 292, + 293, + 294, + 295, + 296, + 297, + 298, + 299, + 300, + 301, + 302, + ] + unit = "" + + [axes.2] + classname = "Selector" + description = "hm_current" + values = [ + 0, + 1, + ] + unit = "" + + [converter] + classname = "Unity" + + [aux_metadata] + cal_offset = 0 + cal_scale = 0.005 + "#; + + #[test] + fn test_deserialize() -> TestResult { + let stream_descriptor: StreamDescriptor = toml::from_str(TEST_TOML_STR)?; + + assert_eq!(stream_descriptor.description, "TX Loop Current"); + assert_eq!(stream_descriptor.stream_id, "hm_current"); + assert_eq!(stream_descriptor.unit, "A"); + assert_eq!(stream_descriptor.axes["2"].values, vec![0, 1]); + + assert_eq!(stream_descriptor.aux_metadata.cal_offset, 0.0); + assert_eq!(stream_descriptor.aux_metadata.cal_scale, 0.005); + + Ok(()) + } +} diff --git a/crates/skytem_hdf/src/lib.rs b/crates/skytem_hdf/src/lib.rs new file mode 100644 index 00000000..f7a5a96d --- /dev/null +++ b/crates/skytem_hdf/src/lib.rs @@ -0,0 +1,21 @@ +#[cfg(not(target_arch = "wasm32"))] +pub(crate) mod util; + +#[cfg(not(target_arch = "wasm32"))] +pub mod bifrost; + +// File extensions we recognize as hdf files. +const POSSIBLE_HDF_EXTENSIONS_CASE_INSENSITIVE: [&str; 3] = ["h5", "hdf", "hdf5"]; + +pub fn path_has_hdf_extension(path: &std::path::Path) -> bool { + let Some(extension) = path.extension() else { + return false; + }; + + for possible_extension in POSSIBLE_HDF_EXTENSIONS_CASE_INSENSITIVE { + if extension.eq_ignore_ascii_case(possible_extension) { + return true; + } + } + false +} diff --git a/crates/skytem_hdf/src/util.rs b/crates/skytem_hdf/src/util.rs new file mode 100644 index 00000000..3008a86e --- /dev/null +++ b/crates/skytem_hdf/src/util.rs @@ -0,0 +1,115 @@ +use hdf5::{ + types::{IntSize, TypeDescriptor, VarLenAscii, VarLenUnicode}, + Attribute, +}; + +/// Reads an HDF5 attribute's value as a HDF5 string type and returns it as a native [`String`]. +/// +/// If the value is not a string type, an error is returned. +pub fn read_string_attribute(attr: &Attribute) -> hdf5::Result { + // Get the data type descriptor for the attribute + match attr.dtype()?.to_descriptor()? { + // Handle variable-length ASCII string + TypeDescriptor::VarLenAscii => { + let value: VarLenAscii = attr.read_scalar()?; + Ok(value.as_str().to_owned()) + } + // Handle variable-length UTF-8 string + TypeDescriptor::VarLenUnicode => { + let value: VarLenUnicode = attr.read_scalar()?; + Ok(value.as_str().to_owned()) + } + // Handle fixed-length ASCII string + TypeDescriptor::FixedAscii(_) => { + let buf = attr.read_raw()?; + let string = String::from_utf8_lossy(&buf) + .trim_end_matches('\0') + .to_owned(); + Ok(string) + } + // Handle fixed-length UTF-8 string + TypeDescriptor::FixedUnicode(_) => { + let buf = attr.read_raw()?; + let string = String::from_utf8_lossy(&buf) + .trim_end_matches('\0') + .to_owned(); + Ok(string) + } + // Unsupported data type + _ => Err(hdf5::Error::from("Unsupported attribute type")), + } +} + +/// Reads an HDF5 attribute's value and converts it to a native [`String`]. +pub fn read_any_attribute_to_string(attr: &Attribute) -> hdf5::Result { + // Get the data type descriptor for the attribute + let type_descriptor = attr.dtype()?.to_descriptor()?; + match &type_descriptor { + // Handle variable-length ASCII string + TypeDescriptor::VarLenAscii => { + let value: VarLenAscii = attr.read_scalar()?; + Ok(value.as_str().to_owned()) + } + // Handle variable-length UTF-8 string + TypeDescriptor::VarLenUnicode => { + let value: VarLenUnicode = attr.read_scalar()?; + Ok(value.as_str().to_owned()) + } + // Handle fixed-length ASCII string + TypeDescriptor::FixedAscii(_) => { + let buf = attr.read_raw()?; + let string = String::from_utf8_lossy(&buf) + .trim_end_matches('\0') + .to_owned(); + Ok(string) + } + // Handle fixed-length UTF-8 string + TypeDescriptor::FixedUnicode(_) => { + let buf = attr.read_raw()?; + let string = String::from_utf8_lossy(&buf) + .trim_end_matches('\0') + .to_owned(); + Ok(string) + } + TypeDescriptor::Integer(int_size) => { + let value: String = match int_size { + IntSize::U1 => attr.read_scalar::()?.to_string(), + IntSize::U2 => attr.read_scalar::()?.to_string(), + IntSize::U4 => attr.read_scalar::()?.to_string(), + IntSize::U8 => attr.read_scalar::()?.to_string(), + }; + Ok(value) + } + TypeDescriptor::Unsigned(int_size) => { + let value: String = match int_size { + hdf5::types::IntSize::U1 => attr.read_scalar::()?.to_string(), + hdf5::types::IntSize::U2 => attr.read_scalar::()?.to_string(), + hdf5::types::IntSize::U4 => attr.read_scalar::()?.to_string(), + hdf5::types::IntSize::U8 => attr.read_scalar::()?.to_string(), + }; + Ok(value) + } + TypeDescriptor::Float(float_size) => { + let value: String = match float_size { + hdf5::types::FloatSize::U4 => attr.read_scalar::()?.to_string(), + hdf5::types::FloatSize::U8 => attr.read_scalar::()?.to_string(), + }; + Ok(value) + } + TypeDescriptor::Boolean => { + let value: bool = attr.read_scalar()?; + Ok(value.to_string()) + } + TypeDescriptor::Enum(enum_type) => { + let value: u64 = attr.read_scalar()?; + let enum_name = enum_type.members.get(value as usize).map_or_else( + || format!("Unknown Enum: {type_descriptor}"), + |member| member.name.clone(), + ); + Ok(enum_name) + } + _ => Err(hdf5::Error::from(format!( + "Unsupported attribute type: {type_descriptor}" + ))), + } +} diff --git a/crates/skytem_logs/src/mbed_motor_control/mbed_header.rs b/crates/skytem_logs/src/mbed_motor_control/mbed_header.rs index 410435e1..343d2c53 100644 --- a/crates/skytem_logs/src/mbed_motor_control/mbed_header.rs +++ b/crates/skytem_logs/src/mbed_motor_control/mbed_header.rs @@ -185,7 +185,10 @@ pub trait BuildMbedLogHeaderV2: Sized + MbedMotorControlLogHeader { } // Not much to do about this lint other than wrap some arguments in another struct but it is not worth the effort, this is a simple constructor - #[allow(clippy::too_many_arguments)] + #[allow( + clippy::too_many_arguments, + reason = "It's a constructor with a lot of data that doesn't benefit from being grouped/wrapped into a struct" + )] fn new( unique_description: UniqueDescriptionData, version: u16, diff --git a/crates/skytem_logs/src/mbed_motor_control/status/entry.rs b/crates/skytem_logs/src/mbed_motor_control/status/entry.rs index 29671e5c..03fa323e 100644 --- a/crates/skytem_logs/src/mbed_motor_control/status/entry.rs +++ b/crates/skytem_logs/src/mbed_motor_control/status/entry.rs @@ -5,7 +5,11 @@ use byteorder::{LittleEndian, ReadBytesExt}; use serde::{Deserialize, Serialize}; use strum_macros::{Display, FromRepr}; -#[allow(non_camel_case_types, clippy::upper_case_acronyms)] +#[allow( + non_camel_case_types, + clippy::upper_case_acronyms, + reason = "This is how it is represented in the motor control code" +)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, FromRepr, Display)] pub enum MotorState { POWER_HOLD = 0, diff --git a/dist-workspace.toml b/dist-workspace.toml new file mode 100644 index 00000000..dfc91c73 --- /dev/null +++ b/dist-workspace.toml @@ -0,0 +1,19 @@ +[workspace] +members = ["cargo:."] + +# Config for 'cargo dist' +[dist] +# Which actions to run on pull requests +pr-run-mode = "upload" +# The preferred cargo-dist version to use in CI (Cargo.toml SemVer syntax) +cargo-dist-version = "0.23.0" +# CI backends to support +ci = "github" +# The installers to generate for each app +installers = ["shell", "powershell", "msi"] +# Target platforms to build apps for (Rust target-triple syntax) +targets = ["aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"] +# Path that installers should place binaries in +install-path = "CARGO_HOME" +# Whether to install an updater program +install-updater = true diff --git a/src/app.rs b/src/app.rs index 988021da..87aa2c9c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,10 +4,10 @@ use crate::{plot::LogPlotUi, util::format_data_size}; use egui::{Color32, DroppedFile, Hyperlink, RichText, TextStyle}; use egui_notify::Toasts; use log_if::prelude::Plotable; -use supported_logs::{SupportedLog, SupportedLogs}; +use supported_formats::{SupportedFormat, SupportedLogs}; mod preview_dropped; -pub mod supported_logs; +pub mod supported_formats; mod util; /// if a log is loaded from content that exceeds this many unparsed bytes: @@ -16,7 +16,10 @@ mod util; pub const WARN_ON_UNPARSED_BYTES_THRESHOLD: usize = 128; /// We derive Deserialize/Serialize so we can persist app state on shutdown. -#[allow(missing_debug_implementations)] // Some of the nested types are from egui or egui_plot and we cannot implement Debug for them +#[allow( + missing_debug_implementations, + reason = "Some of the nested types are from egui or egui_plot and we cannot implement Debug for them" +)] #[derive(serde::Deserialize, serde::Serialize)] #[serde(default)] // if we add new fields, give them default values when deserializing old state pub struct App { @@ -243,7 +246,7 @@ fn collapsible_instructions(ui: &mut egui::Ui) { } /// Displays a toasts notification if logs are added with the names of all added logs -fn notify_if_logs_added(toasts: &mut Toasts, logs: &[SupportedLog]) { +fn notify_if_logs_added(toasts: &mut Toasts, logs: &[SupportedFormat]) { if !logs.is_empty() { let mut log_names_str = String::new(); for l in logs { @@ -259,22 +262,23 @@ fn notify_if_logs_added(toasts: &mut Toasts, logs: &[SupportedLog]) { )) .duration(Some(Duration::from_secs(2))); for l in logs { - let parse_info = l.parse_info(); - log::debug!( - "Unparsed bytes for {remainder}:{log_name}", - remainder = parse_info.remainder_bytes(), - log_name = l.descriptive_name() - ); - if parse_info.remainder_bytes() > WARN_ON_UNPARSED_BYTES_THRESHOLD { - toasts - .warning(format!( + if let Some(parse_info) = l.parse_info() { + log::debug!( + "Unparsed bytes for {remainder}:{log_name}", + remainder = parse_info.remainder_bytes(), + log_name = l.descriptive_name() + ); + if parse_info.remainder_bytes() > WARN_ON_UNPARSED_BYTES_THRESHOLD { + toasts + .warning(format!( "Could only parse {parsed}/{total} for {log_name}\n{remainder} remain unparsed", parsed = format_data_size(parse_info.parsed_bytes()), total = format_data_size(parse_info.total_bytes()), log_name = l.descriptive_name(), remainder = format_data_size(parse_info.remainder_bytes()) )) - .duration(Some(Duration::from_secs(30))); + .duration(Some(Duration::from_secs(30))); + } } } } diff --git a/src/app/supported_logs.rs b/src/app/supported_formats.rs similarity index 58% rename from src/app/supported_logs.rs rename to src/app/supported_formats.rs index 317b3ed5..8af48974 100644 --- a/src/app/supported_logs.rs +++ b/src/app/supported_formats.rs @@ -1,5 +1,9 @@ use egui::DroppedFile; use log_if::prelude::*; +use logs::{ + parse_info::{ParseInfo, ParsedBytes, TotalBytes}, + SupportedLog, +}; use serde::{Deserialize, Serialize}; use skytem_logs::{ generator::{GeneratorLog, GeneratorLogEntry}, @@ -10,79 +14,76 @@ use std::{ io::{self, BufReader}, path::Path, }; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] -pub struct ParsedBytes(usize); -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] -pub struct TotalBytes(usize); +#[cfg(feature = "hdf")] +#[cfg(not(target_arch = "wasm32"))] +mod hdf; +pub(crate) mod logs; +mod util; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] -pub struct ParseInfo { - parsed_bytes: ParsedBytes, - total_bytes: TotalBytes, +/// Represents a supported format, which can be any of the supported format types. +/// +/// This simply serves to encapsulate all the supported format in a single type +#[derive(Debug, Clone, Deserialize, Serialize)] +#[allow( + clippy::large_enum_variant, + reason = "This enum is only created once when parsing an added file for the first time so optimizing memory for an instance of this enum is a waste of effort" +)] +pub enum SupportedFormat { + Log(SupportedLog), + #[cfg(feature = "hdf")] + #[cfg(not(target_arch = "wasm32"))] + #[allow(clippy::upper_case_acronyms, reason = "The format is called HDF...")] + HDF(hdf::SupportedHdfFormat), } -impl ParseInfo { - pub fn new(parsed_bytes: ParsedBytes, total_bytes: TotalBytes) -> Self { - let parsed = parsed_bytes.0; - let total = total_bytes.0; - - debug_assert!( - parsed <= total, - "Unsound condition, parsed more than the total bytes! Parsed: {parsed}, total: {total}" - ); - Self { - parsed_bytes, - total_bytes, - } - } - pub fn parsed_bytes(&self) -> usize { - self.parsed_bytes.0 - } - - pub fn total_bytes(&self) -> usize { - self.total_bytes.0 +impl From<(PidLog, ParseInfo)> for SupportedFormat { + fn from(value: (PidLog, ParseInfo)) -> Self { + Self::Log(SupportedLog::from(value)) } +} - pub fn remainder_bytes(&self) -> usize { - self.total_bytes.0 - self.parsed_bytes.0 +impl From<(StatusLog, ParseInfo)> for SupportedFormat { + fn from(value: (StatusLog, ParseInfo)) -> Self { + Self::Log(SupportedLog::from(value)) } } -/// Represents a supported log, which can be any of the supported log types. -/// -/// This simply serves to encapsulate all the supported logs in a single type -#[derive(Debug, Clone, Deserialize, Serialize)] -pub enum SupportedLog { - MbedPid(PidLog, ParseInfo), - MbedStatus(StatusLog, ParseInfo), - Generator(GeneratorLog, ParseInfo), +impl From<(GeneratorLog, ParseInfo)> for SupportedFormat { + fn from(value: (GeneratorLog, ParseInfo)) -> Self { + Self::Log(SupportedLog::from(value)) + } } -impl SupportedLog { +impl SupportedFormat { /// Attempts to parse a log from raw content. + /// + /// This is how content is made available in a browser. fn parse_from_content(mut content: &[u8]) -> io::Result { let total_bytes = content.len(); log::debug!("Parsing content of length: {total_bytes}"); - let log = if PidLog::is_buf_valid(content) { + let log: Self = if PidLog::is_buf_valid(content) { let (log, read_bytes) = PidLog::from_reader(&mut content)?; log::debug!("Read: {read_bytes} bytes"); - Self::MbedPid( + ( log, ParseInfo::new(ParsedBytes(read_bytes), TotalBytes(total_bytes)), ) + .into() } else if StatusLog::is_buf_valid(content) { let (log, read_bytes) = StatusLog::from_reader(&mut content)?; - Self::MbedStatus( + ( log, ParseInfo::new(ParsedBytes(read_bytes), TotalBytes(read_bytes)), ) + .into() } else if GeneratorLogEntry::is_bytes_valid_generator_log_entry(content) { let (log, read_bytes) = GeneratorLog::from_reader(&mut content)?; - Self::Generator( + ( log, ParseInfo::new(ParsedBytes(read_bytes), TotalBytes(total_bytes)), ) + .into() } else { return Err(io::Error::new( io::ErrorKind::InvalidData, @@ -94,31 +95,38 @@ impl SupportedLog { } /// Attempts to parse a log from a file path. + /// + /// This is how it is made available on native. fn parse_from_path(path: &Path) -> io::Result { let file = fs::File::open(path)?; let total_bytes = file.metadata()?.len() as usize; log::debug!("Parsing content of length: {total_bytes}"); - let mut reader = BufReader::new(file); - let log = if PidLog::file_is_valid(path) { + let mut reader = BufReader::new(file); + let log: Self = if util::path_has_hdf_extension(path) { + Self::parse_hdf_from_path(path)? + } else if PidLog::file_is_valid(path) { let (log, parsed_bytes) = PidLog::from_reader(&mut reader)?; log::debug!("Read: {parsed_bytes} bytes"); - Self::MbedPid( + ( log, ParseInfo::new(ParsedBytes(parsed_bytes), TotalBytes(total_bytes)), ) + .into() } else if StatusLog::file_is_valid(path) { let (log, parsed_bytes) = StatusLog::from_reader(&mut reader)?; - Self::MbedStatus( + ( log, ParseInfo::new(ParsedBytes(parsed_bytes), TotalBytes(total_bytes)), ) + .into() } else if GeneratorLog::file_is_generator_log(path).unwrap_or(false) { let (log, parsed_bytes) = GeneratorLog::from_reader(&mut reader)?; - Self::Generator( + ( log, ParseInfo::new(ParsedBytes(parsed_bytes), TotalBytes(total_bytes)), ) + .into() } else { return Err(io::Error::new( io::ErrorKind::InvalidData, @@ -129,72 +137,118 @@ impl SupportedLog { Ok(log) } - pub fn parse_info(&self) -> ParseInfo { + #[cfg(feature = "hdf")] + #[cfg(not(target_arch = "wasm32"))] + fn parse_hdf_from_path(path: &Path) -> io::Result { + use skytem_hdf::bifrost::BifrostLoopCurrent; + // Attempt to parse it has an hdf file + if let Ok(bifrost_loop_current) = BifrostLoopCurrent::from_path(path) { + Ok(Self::HDF(bifrost_loop_current.into())) + } else { + Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Unrecognized HDF file", + )) + } + } + + #[cfg(not(feature = "hdf"))] + #[cfg(not(target_arch = "wasm32"))] + fn parse_hdf_from_path(path: &Path) -> io::Result { + Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "Recognized '{}' as an HDF file. But the HDF feature is turned off.", + path.display() + ), + )) + } + + #[cfg(target_arch = "wasm32")] + fn parse_hdf_from_path(path: &Path) -> io::Result { + Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("Recognized '{}' as an HDF file. HDF files are only supported on the native version", path.display()), + )) + } + + /// Returns [`None`] if there's no meaningful parsing information such as with HDF5 files. + #[allow( + clippy::unnecessary_wraps, + reason = "HDF files are not supported on web (yet?) and the lint is triggered when compiling for web since then only logs are supported which always have parse info" + )] + pub fn parse_info(&self) -> Option { match self { - Self::MbedPid(_, parse_info) - | Self::MbedStatus(_, parse_info) - | Self::Generator(_, parse_info) => *parse_info, + Self::Log(l) => Some(l.parse_info()), + #[cfg(feature = "hdf")] + #[cfg(not(target_arch = "wasm32"))] + Self::HDF(_) => None, } } } -impl Plotable for SupportedLog { +impl Plotable for SupportedFormat { fn raw_plots(&self) -> &[RawPlot] { match self { - Self::MbedPid(l, _) => l.raw_plots(), - Self::MbedStatus(l, _) => l.raw_plots(), - Self::Generator(l, _) => l.raw_plots(), + Self::Log(l) => l.raw_plots(), + + #[cfg(feature = "hdf")] + #[cfg(not(target_arch = "wasm32"))] + Self::HDF(hdf) => hdf.raw_plots(), } } fn first_timestamp(&self) -> chrono::DateTime { match self { - Self::MbedPid(l, _) => l.first_timestamp(), - Self::MbedStatus(l, _) => l.first_timestamp(), - Self::Generator(l, _) => l.first_timestamp(), + Self::Log(l) => l.first_timestamp(), + #[cfg(feature = "hdf")] + #[cfg(not(target_arch = "wasm32"))] + Self::HDF(hdf) => hdf.first_timestamp(), } } fn descriptive_name(&self) -> &str { match self { - Self::MbedPid(l, _) => l.descriptive_name(), - Self::MbedStatus(l, _) => l.descriptive_name(), - Self::Generator(l, _) => l.descriptive_name(), + Self::Log(l) => l.descriptive_name(), + #[cfg(feature = "hdf")] + #[cfg(not(target_arch = "wasm32"))] + Self::HDF(hdf) => hdf.descriptive_name(), } } fn labels(&self) -> Option<&[PlotLabels]> { match self { - Self::MbedPid(l, _) => l.labels(), - Self::MbedStatus(l, _) => l.labels(), - Self::Generator(l, _) => l.labels(), + Self::Log(l) => l.labels(), + #[cfg(feature = "hdf")] + #[cfg(not(target_arch = "wasm32"))] + Self::HDF(hdf) => hdf.labels(), } } fn metadata(&self) -> Option> { match self { - Self::MbedPid(l, _) => l.metadata(), - Self::MbedStatus(l, _) => l.metadata(), - Self::Generator(l, _) => l.metadata(), + Self::Log(l) => l.metadata(), + #[cfg(feature = "hdf")] + #[cfg(not(target_arch = "wasm32"))] + Self::HDF(hdf) => hdf.metadata(), } } - // Implement any methods required by the Plotable trait for SupportedLog } /// Contains all supported logs in a single vector. #[derive(Default, Deserialize, Serialize)] pub struct SupportedLogs { - logs: Vec, + logs: Vec, } impl SupportedLogs { /// Return a vector of immutable references to all logs - pub fn logs(&self) -> &[SupportedLog] { + pub fn logs(&self) -> &[SupportedFormat] { &self.logs } /// Take all the logs currently stored in [`SupportedLogs`] and return them as a list - pub fn take_logs(&mut self) -> Vec { + pub fn take_logs(&mut self) -> Vec { self.logs.drain(..).collect() } @@ -213,7 +267,8 @@ impl SupportedLogs { fn parse_file(&mut self, file: &DroppedFile) -> io::Result<()> { if let Some(content) = file.bytes.as_ref() { - self.logs.push(SupportedLog::parse_from_content(content)?); + self.logs + .push(SupportedFormat::parse_from_content(content)?); } else if let Some(path) = &file.path { if path.is_dir() { self.parse_directory(path)?; @@ -221,7 +276,7 @@ impl SupportedLogs { #[cfg(not(target_arch = "wasm32"))] self.parse_zip_file(path)?; } else { - self.logs.push(SupportedLog::parse_from_path(path)?); + self.logs.push(SupportedFormat::parse_from_path(path)?); } } Ok(()) @@ -239,7 +294,7 @@ impl SupportedLogs { #[cfg(not(target_arch = "wasm32"))] self.parse_zip_file(&path)?; } else { - match SupportedLog::parse_from_path(&path) { + match SupportedFormat::parse_from_path(&path) { Ok(l) => self.logs.push(l), Err(e) => log::warn!("{e}"), } @@ -258,7 +313,7 @@ impl SupportedLogs { if file.is_file() { let mut contents = Vec::new(); io::Read::read_to_end(&mut file, &mut contents)?; - if let Ok(log) = SupportedLog::parse_from_content(&contents) { + if let Ok(log) = SupportedFormat::parse_from_content(&contents) { self.logs.push(log); } } diff --git a/src/app/supported_formats/hdf.rs b/src/app/supported_formats/hdf.rs new file mode 100644 index 00000000..6d103b7a --- /dev/null +++ b/src/app/supported_formats/hdf.rs @@ -0,0 +1,50 @@ +use chrono::{DateTime, Utc}; +use log_if::prelude::*; +use serde::{Deserialize, Serialize}; +use skytem_hdf::bifrost::BifrostLoopCurrent; + +/// Represents a supported HDF format, which can be any of the supported HDF format types. +/// +/// This simply serves to encapsulate all the supported HDF formats in a single type +#[derive(Debug, Clone, Deserialize, Serialize)] +pub enum SupportedHdfFormat { + BifrostLoopCurrent(BifrostLoopCurrent), +} + +impl From for SupportedHdfFormat { + fn from(value: BifrostLoopCurrent) -> Self { + Self::BifrostLoopCurrent(value) + } +} + +impl Plotable for SupportedHdfFormat { + fn raw_plots(&self) -> &[RawPlot] { + match self { + Self::BifrostLoopCurrent(hdf) => hdf.raw_plots(), + } + } + + fn first_timestamp(&self) -> DateTime { + match self { + Self::BifrostLoopCurrent(hdf) => hdf.first_timestamp(), + } + } + + fn descriptive_name(&self) -> &str { + match self { + Self::BifrostLoopCurrent(hdf) => hdf.descriptive_name(), + } + } + + fn labels(&self) -> Option<&[PlotLabels]> { + match self { + Self::BifrostLoopCurrent(hdf) => hdf.labels(), + } + } + + fn metadata(&self) -> Option> { + match self { + Self::BifrostLoopCurrent(hdf) => hdf.metadata(), + } + } +} diff --git a/src/app/supported_formats/logs.rs b/src/app/supported_formats/logs.rs new file mode 100644 index 00000000..c9456956 --- /dev/null +++ b/src/app/supported_formats/logs.rs @@ -0,0 +1,89 @@ +use log_if::prelude::*; +use parse_info::ParseInfo; +use serde::{Deserialize, Serialize}; +use skytem_logs::{ + generator::GeneratorLog, + mbed_motor_control::{pid::pidlog::PidLog, status::statuslog::StatusLog}, +}; + +pub(crate) mod parse_info; + +/// Represents a supported log format, which can be any of the supported log format types. +/// +/// This simply serves to encapsulate all the supported log format in a single type +#[derive(Debug, Clone, Deserialize, Serialize)] +pub enum SupportedLog { + MbedPid(PidLog, ParseInfo), + MbedStatus(StatusLog, ParseInfo), + Generator(GeneratorLog, ParseInfo), +} + +impl SupportedLog { + pub(crate) fn parse_info(&self) -> ParseInfo { + match self { + Self::MbedPid(_, parse_info) + | Self::MbedStatus(_, parse_info) + | Self::Generator(_, parse_info) => *parse_info, + } + } +} + +impl From<(PidLog, ParseInfo)> for SupportedLog { + fn from(value: (PidLog, ParseInfo)) -> Self { + Self::MbedPid(value.0, value.1) + } +} + +impl From<(StatusLog, ParseInfo)> for SupportedLog { + fn from(value: (StatusLog, ParseInfo)) -> Self { + Self::MbedStatus(value.0, value.1) + } +} + +impl From<(GeneratorLog, ParseInfo)> for SupportedLog { + fn from(value: (GeneratorLog, ParseInfo)) -> Self { + Self::Generator(value.0, value.1) + } +} + +impl Plotable for SupportedLog { + fn raw_plots(&self) -> &[RawPlot] { + match self { + Self::MbedPid(l, _) => l.raw_plots(), + Self::MbedStatus(l, _) => l.raw_plots(), + Self::Generator(l, _) => l.raw_plots(), + } + } + + fn first_timestamp(&self) -> chrono::DateTime { + match self { + Self::MbedPid(l, _) => l.first_timestamp(), + Self::MbedStatus(l, _) => l.first_timestamp(), + Self::Generator(l, _) => l.first_timestamp(), + } + } + + fn descriptive_name(&self) -> &str { + match self { + Self::MbedPid(l, _) => l.descriptive_name(), + Self::MbedStatus(l, _) => l.descriptive_name(), + Self::Generator(l, _) => l.descriptive_name(), + } + } + + fn labels(&self) -> Option<&[PlotLabels]> { + match self { + Self::MbedPid(l, _) => l.labels(), + Self::MbedStatus(l, _) => l.labels(), + Self::Generator(l, _) => l.labels(), + } + } + + fn metadata(&self) -> Option> { + match self { + Self::MbedPid(l, _) => l.metadata(), + Self::MbedStatus(l, _) => l.metadata(), + Self::Generator(l, _) => l.metadata(), + } + } +} diff --git a/src/app/supported_formats/logs/parse_info.rs b/src/app/supported_formats/logs/parse_info.rs new file mode 100644 index 00000000..b113269b --- /dev/null +++ b/src/app/supported_formats/logs/parse_info.rs @@ -0,0 +1,41 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +pub struct ParsedBytes(pub usize); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +pub struct TotalBytes(pub usize); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +pub struct ParseInfo { + parsed_bytes: ParsedBytes, + total_bytes: TotalBytes, +} + +impl ParseInfo { + pub fn new(parsed_bytes: ParsedBytes, total_bytes: TotalBytes) -> Self { + let parsed = parsed_bytes.0; + let total = total_bytes.0; + + debug_assert!( + parsed <= total, + "Unsound condition, parsed more than the total bytes! Parsed: {parsed}, total: {total}" + ); + Self { + parsed_bytes, + total_bytes, + } + } + + pub fn parsed_bytes(&self) -> usize { + self.parsed_bytes.0 + } + + pub fn total_bytes(&self) -> usize { + self.total_bytes.0 + } + + pub fn remainder_bytes(&self) -> usize { + self.total_bytes.0 - self.parsed_bytes.0 + } +} diff --git a/src/app/supported_formats/util.rs b/src/app/supported_formats/util.rs new file mode 100644 index 00000000..e16a8c02 --- /dev/null +++ b/src/app/supported_formats/util.rs @@ -0,0 +1,15 @@ +// File extensions we recognize as hdf files. +const POSSIBLE_HDF_EXTENSIONS_CASE_INSENSITIVE: [&str; 3] = ["h5", "hdf", "hdf5"]; + +pub fn path_has_hdf_extension(path: &std::path::Path) -> bool { + let Some(extension) = path.extension() else { + return false; + }; + + for possible_extension in POSSIBLE_HDF_EXTENSIONS_CASE_INSENSITIVE { + if extension.eq_ignore_ascii_case(possible_extension) { + return true; + } + } + false +} diff --git a/src/app/util.rs b/src/app/util.rs index 2a0cb0a9..691def28 100644 --- a/src/app/util.rs +++ b/src/app/util.rs @@ -20,61 +20,71 @@ pub fn file_info(file: &DroppedFile) -> String { } pub fn draw_empty_state(gui: &mut egui::Ui) { - _ = gui.vertical_centered(|arg_ui| { + gui.vertical_centered(|arg_ui| { arg_ui.add_space(100.0); - _ = arg_ui.heading(RichText::new("Drag and drop logfiles, directories with logfiles, or zip archives with logfiles onto this window").size(40.0)); + arg_ui.heading(RichText::new("Drag and drop files, directories/folder, or zip archives onto this window").size(40.0)); arg_ui.add_space(40.0); let table_width = arg_ui.available_width() * 0.8; - _ = egui::Frame::none() + egui::Frame::none() .fill(arg_ui.style().visuals.extreme_bg_color) .stroke(Stroke::new(1.0, arg_ui.style().visuals.widgets.active.bg_fill)) .inner_margin(10.0) .outer_margin(0.0) .show(arg_ui, |inner_arg_ui| { inner_arg_ui.set_width(table_width); - _ = egui::Grid::new("supported_formats_grid") + egui::Grid::new("supported_formats_grid") .num_columns(2) .spacing([40.0, 10.0]) .striped(true) .show(inner_arg_ui, |ui| { - _ = ui.colored_label( + ui.colored_label( ui.style().visuals.strong_text_color(), "Supported Formats", ); - _ = ui.colored_label( + ui.colored_label( ui.style().visuals.strong_text_color(), "Description", ); ui.end_row(); - _ = ui.label(RichText::new("Mbed Motor Control").strong()); - _ = ui.label("Logs from Mbed-based motor controller"); - _ = ui.add(Hyperlink::from_label_and_url( + ui.label(RichText::new("Mbed Motor Control").strong()); + ui.label("Logs from Mbed-based motor controller"); + ui.add(Hyperlink::from_label_and_url( "https://github.com/luftkode/mbed-motor-control", "https://github.com/luftkode/mbed-motor-control", )); ui.end_row(); - _ = ui.label("• PID Logs"); - _ = ui.label("Contains PID controller data"); + ui.label("• PID Logs"); + ui.label("Contains PID controller data"); ui.end_row(); - _ = ui.label("• Status Logs"); - _ = ui.label( + ui.label("• Status Logs"); + ui.label( "General status information such as engine temperature, and controller state machine information", ); ui.end_row(); - _ = ui.label(RichText::new("Generator").strong()); - _ = ui.label("Logs from the generator"); + ui.label(RichText::new("Generator").strong()); + ui.label("Logs from the generator"); - _ = ui.add(Hyperlink::from_label_and_url( + ui.add(Hyperlink::from_label_and_url( "https://github.com/luftkode/delphi_generator_linux", "https://github.com/luftkode/delphi_generator_linux", )); ui.end_row(); + + + ui.label(RichText::new("⚠ Coming soon: Bifrost TX Loop Current ⚠")); + ui.label("Loop Current measurements"); + + ui.add(Hyperlink::from_label_and_url( + "https://github.com/luftkode/bifrost-app", + "https://github.com/luftkode/bifrost-app", + )); + ui.end_row(); }); }); }); diff --git a/src/plot.rs b/src/plot.rs index c3b9c800..4d6d1954 100644 --- a/src/plot.rs +++ b/src/plot.rs @@ -7,7 +7,7 @@ use axis_config::AxisConfig; use egui::{Id, Response}; use egui_plot::Legend; -use crate::app::supported_logs::SupportedLog; +use crate::app::supported_formats::SupportedFormat; mod axis_config; mod plot_graphics; mod plot_settings; @@ -21,7 +21,10 @@ pub enum PlotType { Thousands, } -#[allow(missing_debug_implementations)] // Legend is from egui_plot and doesn't implement debug +#[allow( + missing_debug_implementations, + reason = "Legend is from egui_plot and doesn't implement debug" +)] #[derive(PartialEq, Deserialize, Serialize)] pub struct LogPlotUi { legend_cfg: Legend, @@ -57,7 +60,7 @@ impl LogPlotUi { pub fn ui( &mut self, ui: &mut egui::Ui, - logs: &[SupportedLog], + logs: &[SupportedFormat], _toasts: &mut Toasts, ) -> Response { let Self { diff --git a/src/plot/axis_config.rs b/src/plot/axis_config.rs index 50a85f70..49c92407 100644 --- a/src/plot/axis_config.rs +++ b/src/plot/axis_config.rs @@ -13,6 +13,7 @@ pub struct AxisConfig { show_axes: bool, show_grid: bool, y_axis_lock: YAxisLock, + pub ui_visible: bool, } impl Default for AxisConfig { @@ -23,6 +24,7 @@ impl Default for AxisConfig { show_axes: true, show_grid: true, y_axis_lock: YAxisLock::default(), + ui_visible: false, } } } diff --git a/src/plot/plot_settings.rs b/src/plot/plot_settings.rs index f2724c02..fe396406 100644 --- a/src/plot/plot_settings.rs +++ b/src/plot/plot_settings.rs @@ -1,5 +1,5 @@ use date_settings::LoadedLogSettings; -use egui::{Key, Response, RichText}; +use egui::{Color32, Key, Response, RichText}; use egui_phosphor::regular; use mipmap_settings::MipMapSettings; use plot_filter::{PlotNameFilter, PlotNameShow}; @@ -25,7 +25,7 @@ impl Default for PlotSettingsUi { Self { show_loaded_logs: Default::default(), show_filter_settings: Default::default(), - filter_settings_text: format!("{} Filter {}", regular::FUNNEL, regular::CHART_LINE), + filter_settings_text: format!("{} Filter", regular::FUNNEL), } } } @@ -59,51 +59,61 @@ pub struct PlotSettings { impl PlotSettings { pub fn show(&mut self, ui: &mut egui::Ui) { + if self.log_start_date_settings.is_empty() { + ui.label(RichText::new("No Logs Loaded").color(Color32::RED)); + } else { + self.show_loaded_logs(ui); + self.ui_plot_filter_settings(ui); + self.mipmap_settings.show(ui); + } self.visibility.toggle_visibility_ui(ui); - if !self.log_start_date_settings.is_empty() { - self.ps_ui.ui_toggle_show_filter(ui); - if self.ps_ui.show_filter_settings { - egui::Window::new(self.ps_ui.filter_settings_text()) - .open(&mut self.ps_ui.show_filter_settings) - .show(ui.ctx(), |ui| { - self.plot_name_filter.show(ui); - }); - if ui.ctx().input(|i| i.key_pressed(Key::Escape)) { - self.ps_ui.show_filter_settings = false; - } + } + + fn ui_plot_filter_settings(&mut self, ui: &mut egui::Ui) { + self.ps_ui.ui_toggle_show_filter(ui); + if self.ps_ui.show_filter_settings { + egui::Window::new(self.ps_ui.filter_settings_text()) + .open(&mut self.ps_ui.show_filter_settings) + .show(ui.ctx(), |ui| { + self.plot_name_filter.show(ui); + }); + if ui.ctx().input(|i| i.key_pressed(Key::Escape)) { + self.ps_ui.show_filter_settings = false; } + } + } - let show_loaded_logs_text = RichText::new(format!( - "{} Loaded logs", - if self.ps_ui.show_loaded_logs { - regular::EYE - } else { - regular::EYE_SLASH - } - )); - ui.toggle_value( - &mut self.ps_ui.show_loaded_logs, - show_loaded_logs_text.text(), - ); - if self.ps_ui.show_loaded_logs { - // Only react on Escape input if no settings are currently open - if ui.ctx().input(|i| i.key_pressed(Key::Escape)) - && !self.log_start_date_settings.iter().any(|s| s.clicked) - { - self.ps_ui.show_loaded_logs = false; - } - egui::Window::new(show_loaded_logs_text) - .open(&mut self.ps_ui.show_loaded_logs) - .show(ui.ctx(), |ui| { - egui::Grid::new("log_settings_grid").show(ui, |ui| { - for settings in &mut self.log_start_date_settings { - loaded_logs::log_date_settings_ui(ui, settings); - ui.end_row(); - } - }); - }); + fn show_loaded_logs(&mut self, ui: &mut egui::Ui) { + let loaded_log_count = self.log_start_date_settings.len(); + let visibility_icon = if self.ps_ui.show_loaded_logs { + regular::EYE + } else { + regular::EYE_SLASH + }; + let show_loaded_logs_text = RichText::new(format!( + "{visibility_icon} Loaded logs ({loaded_log_count})", + )); + ui.toggle_value( + &mut self.ps_ui.show_loaded_logs, + show_loaded_logs_text.text(), + ); + if self.ps_ui.show_loaded_logs { + // Only react on Escape input if no settings are currently open + if ui.ctx().input(|i| i.key_pressed(Key::Escape)) + && !self.log_start_date_settings.iter().any(|s| s.clicked) + { + self.ps_ui.show_loaded_logs = false; } - self.mipmap_settings.show(ui); + egui::Window::new(show_loaded_logs_text) + .open(&mut self.ps_ui.show_loaded_logs) + .show(ui.ctx(), |ui| { + egui::Grid::new("log_settings_grid").show(ui, |ui| { + for settings in &mut self.log_start_date_settings { + loaded_logs::log_date_settings_ui(ui, settings); + ui.end_row(); + } + }); + }); } } diff --git a/src/plot/plot_settings/date_settings.rs b/src/plot/plot_settings/date_settings.rs index e9f48d08..47b2e090 100644 --- a/src/plot/plot_settings/date_settings.rs +++ b/src/plot/plot_settings/date_settings.rs @@ -1,8 +1,46 @@ use chrono::{DateTime, NaiveDateTime, Utc}; +use egui::RichText; use plot_util::{PlotData, Plots}; use serde::{Deserialize, Serialize}; -use crate::app::supported_logs::ParseInfo; +use crate::app::supported_formats::logs::parse_info::ParseInfo; + +#[derive(PartialEq, Eq, Deserialize, Serialize)] +pub struct LoadedLogMetadata { + description: String, + value: String, + selected: bool, +} +impl LoadedLogMetadata { + pub fn new(description: String, value: String) -> Self { + Self { + description, + value, + selected: false, + } + } + + pub fn show(&mut self, ui: &mut egui::Ui) { + ui.label(RichText::new(&self.description).strong()); + if self.value.len() > 100 { + let shortened_preview_value = format!("{} ...", &self.value[..40]); + if ui.button(&shortened_preview_value).clicked() { + self.selected = !self.selected; + }; + if self.selected { + egui::Window::new(shortened_preview_value) + .open(&mut self.selected) + .show(ui.ctx(), |ui| { + ui.horizontal_wrapped(|ui| ui.label(&self.value)); + }); + } + } else { + ui.label(&self.value); + } + + ui.end_row(); + } +} #[derive(PartialEq, Eq, Deserialize, Serialize)] pub struct LoadedLogSettings { @@ -16,8 +54,8 @@ pub struct LoadedLogSettings { pub new_date_candidate: Option, pub date_changed: bool, show: bool, - log_metadata: Option>, - parse_info: ParseInfo, + log_metadata: Option>, + parse_info: Option, } impl LoadedLogSettings { @@ -26,8 +64,13 @@ impl LoadedLogSettings { descriptive_name: String, start_date: DateTime, log_metadata: Option>, - parse_info: ParseInfo, + parse_info: Option, ) -> Self { + let log_metadata = log_metadata.map(|l| { + l.into_iter() + .map(|l| LoadedLogMetadata::new(l.0, l.1)) + .collect() + }); Self { log_id, log_descriptive_name: descriptive_name, @@ -73,11 +116,11 @@ impl LoadedLogSettings { &mut self.show } - pub fn log_metadata(&self) -> Option<&[(String, String)]> { - self.log_metadata.as_deref() + pub fn log_metadata(&mut self) -> Option<&mut [LoadedLogMetadata]> { + self.log_metadata.as_deref_mut() } - pub fn parse_info(&self) -> ParseInfo { + pub fn parse_info(&self) -> Option { self.parse_info } } diff --git a/src/plot/plot_settings/loaded_logs.rs b/src/plot/plot_settings/loaded_logs.rs index 4e86ae15..fa0ee5ab 100644 --- a/src/plot/plot_settings/loaded_logs.rs +++ b/src/plot/plot_settings/loaded_logs.rs @@ -2,7 +2,10 @@ use chrono::NaiveDateTime; use egui::{Color32, Key, RichText, TextEdit}; use egui_phosphor::regular; -use crate::{app::WARN_ON_UNPARSED_BYTES_THRESHOLD, util::format_data_size}; +use crate::{ + app::{supported_formats::logs::parse_info::ParseInfo, WARN_ON_UNPARSED_BYTES_THRESHOLD}, + util::format_data_size, +}; use super::date_settings::LoadedLogSettings; @@ -49,31 +52,14 @@ fn log_settings_window(ui: &egui::Ui, settings: &mut LoadedLogSettings, log_name .anchor(egui::Align2::LEFT_TOP, egui::Vec2::ZERO) .show(ui.ctx(), |ui| { ui.horizontal_wrapped(|ui| { - let parse_info = settings.parse_info(); - let parse_info_str = format!( - "Parsed {parsed}/{total}", - parsed = format_data_size(settings.parse_info().parsed_bytes()), - total = format_data_size(settings.parse_info().total_bytes()), - ); - let unparsed_text = format!( - "({} unparsed)", - format_data_size(settings.parse_info().remainder_bytes()) - ); - if parse_info.remainder_bytes() > WARN_ON_UNPARSED_BYTES_THRESHOLD { - ui.label(RichText::new("⚠").color(Color32::YELLOW)); - ui.label(parse_info_str); - ui.label(RichText::new(unparsed_text).color(Color32::YELLOW)); - } else { - ui.label(parse_info_str); - ui.label(RichText::new(unparsed_text)); + if let Some(parse_info) = settings.parse_info() { + show_parse_info(ui, parse_info); } }); if let Some(log_metadata) = settings.log_metadata() { egui::Grid::new("metadata").show(ui, |ui| { - for (k, v) in log_metadata { - ui.label(RichText::new(k).strong()); - ui.label(v); - ui.end_row(); + for log_metadata in log_metadata { + log_metadata.show(ui); } }); } @@ -119,3 +105,23 @@ fn log_settings_window(ui: &egui::Ui, settings: &mut LoadedLogSettings, log_name settings.clicked = false; } } + +fn show_parse_info(ui: &mut egui::Ui, parse_info: ParseInfo) { + let parse_info_str = format!( + "Parsed {parsed}/{total}", + parsed = format_data_size(parse_info.parsed_bytes()), + total = format_data_size(parse_info.total_bytes()), + ); + let unparsed_text = format!( + "({} unparsed)", + format_data_size(parse_info.remainder_bytes()) + ); + if parse_info.remainder_bytes() > WARN_ON_UNPARSED_BYTES_THRESHOLD { + ui.label(RichText::new("⚠").color(Color32::YELLOW)); + ui.label(parse_info_str); + ui.label(RichText::new(unparsed_text).color(Color32::YELLOW)); + } else { + ui.label(parse_info_str); + ui.label(RichText::new(unparsed_text)); + } +} diff --git a/src/plot/plot_ui.rs b/src/plot/plot_ui.rs index 85900413..0be2ee97 100644 --- a/src/plot/plot_ui.rs +++ b/src/plot/plot_ui.rs @@ -1,3 +1,6 @@ +use egui::{Key, RichText}; +use egui_phosphor::regular; + use super::{axis_config::AxisConfig, plot_settings::PlotSettings}; // filter settings should be refactored out to be a standalone thing, maybe together with loaded_logs_ui @@ -8,17 +11,31 @@ pub fn show_settings_grid( plot_settings: &mut PlotSettings, ) { egui::Grid::new("settings").show(ui, |ui| { - ui.label("Line width"); - ui.add( - egui::DragValue::new(line_width) - .speed(0.02) - .range(0.5..=20.0), - ); - ui.horizontal_top(|ui| { - axis_cfg.toggle_axis_cfg_ui(ui); - ui.label("|"); plot_settings.show(ui); + ui.label("|"); + let axis_cfg_str = RichText::new(format!("{} Axis config", regular::GEAR)); + if ui.button(axis_cfg_str.clone()).clicked() { + axis_cfg.ui_visible = !axis_cfg.ui_visible; + } + if axis_cfg.ui_visible { + let mut open: bool = axis_cfg.ui_visible; + egui::Window::new(axis_cfg_str) + .open(&mut open) + .show(ui.ctx(), |ui| { + axis_cfg.toggle_axis_cfg_ui(ui); + }); + axis_cfg.ui_visible = open; + } + if ui.ctx().input(|i| i.key_pressed(Key::Escape)) { + axis_cfg.ui_visible = false; + } + ui.label("Line width"); + ui.add( + egui::DragValue::new(line_width) + .speed(0.02) + .range(0.5..=20.0), + ); }); }); } diff --git a/src/plot/util.rs b/src/plot/util.rs index ff2b8de4..863a3649 100644 --- a/src/plot/util.rs +++ b/src/plot/util.rs @@ -1,13 +1,13 @@ use log_if::prelude::*; use plot_util::{Plots, StoredPlotLabels}; -use crate::app::supported_logs::SupportedLog; +use crate::app::supported_formats::SupportedFormat; use super::plot_settings::{date_settings::LoadedLogSettings, PlotSettings}; pub fn add_plot_data_to_plot_collections( plots: &mut Plots, - log: &SupportedLog, + log: &SupportedFormat, plot_settings: &mut PlotSettings, ) { // This is how all logs get their log_id, and how each plot for each log gets their log_id diff --git a/test_data/hdf5/bifrost_current/20240930_100137_bifrost.h5 b/test_data/hdf5/bifrost_current/20240930_100137_bifrost.h5 new file mode 100644 index 00000000..ed4a176f Binary files /dev/null and b/test_data/hdf5/bifrost_current/20240930_100137_bifrost.h5 differ