diff --git a/Cargo.lock b/Cargo.lock index 362e295..a1ee23d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -289,6 +289,16 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "797146bb2677299a1eb6b7b50a890f4c361b29ef967addf5b2fa45dae1bb6d7d" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -435,16 +445,21 @@ dependencies = [ "rustfft", "serde", "serde_json", + "tsify-next", + "wasm-bindgen", ] [[package]] name = "propwash-web" version = "0.11.0" dependencies = [ + "az", "console_error_panic_hook", "propwash-core", "serde", + "serde-wasm-bindgen", "serde_json", + "tsify-next", "wasm-bindgen", ] @@ -580,6 +595,17 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -600,6 +626,17 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_json" version = "1.0.149" @@ -659,6 +696,30 @@ dependencies = [ "strength_reduce", ] +[[package]] +name = "tsify-next" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d0f2208feeb5f7a6edb15a2389c14cd42480ef6417318316bb866da5806a61d" +dependencies = [ + "serde", + "serde-wasm-bindgen", + "tsify-next-macros", + "wasm-bindgen", +] + +[[package]] +name = "tsify-next-macros" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f81253930d0d388a3ab8fa4ae56da9973ab171ef833d1be2e9080fc3ce502bd6" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + [[package]] name = "unarray" version = "0.1.4" diff --git a/propwash-cli/src/main.rs b/propwash-cli/src/main.rs index 177bdea..c01dac1 100644 --- a/propwash-cli/src/main.rs +++ b/propwash-cli/src/main.rs @@ -1,4 +1,5 @@ use std::process; +use std::str::FromStr; use clap::{Parser, Subcommand, ValueEnum}; use propwash_core::analysis::episodes; @@ -134,31 +135,20 @@ fn cmd_info(path: &str, json: bool) { println!(" Frames: {}", session.frame_count()); println!(" Motors: {}", session.motor_count()); - println!( - " RPM telemetry: {}", - if session - .field(&propwash_core::types::SensorField::ERpm( - propwash_core::types::MotorIndex(0), - )) - .is_empty() - { - "no" - } else { - "yes" - } - ); + let has_erpm = session + .motors + .esc + .as_ref() + .is_some_and(|esc| esc.erpm.iter().any(|c| !c.is_empty())); + println!(" RPM telemetry: {}", if has_erpm { "yes" } else { "no" }); + // Unfiltered gyro isn't a typed Session slot today — BF is the + // only format that exposes it (as a non-canonical field in + // `extras`). Detect via the extras key the BF parser uses. + let has_unfilt = session.extras.contains_key("gyroUnfilt[0]") + || session.extras.contains_key("gyroUnfilt[roll]"); println!( " Gyro unfilt: {}", - if session - .field(&propwash_core::types::SensorField::GyroUnfilt( - propwash_core::types::Axis::Roll, - )) - .is_empty() - { - "no" - } else { - "yes" - } + if has_unfilt { "yes" } else { "no" } ); println!( " Truncated: {}", @@ -714,23 +704,24 @@ fn cmd_dump( let field_names = session.field_names(); - // Pair field names with their parsed SensorField, dropping unparseable ones - let resolved: Vec<(&str, SensorField)> = field_names - .iter() + // Prefix-filter the canonical names, then parse each survivor + // into a typed SensorField. Names that don't parse (extras + // keys, attitude[*], etc. that have no SensorField variant) + // route through Unknown, which yields an empty column. + let selected: Vec<(String, SensorField)> = field_names + .into_iter() .filter(|name| { - if field_prefixes.is_empty() { - return true; - } - field_prefixes.iter().any(|prefix| name.starts_with(prefix)) + field_prefixes.is_empty() + || field_prefixes.iter().any(|prefix| name.starts_with(prefix)) + }) + .map(|name| { + let parsed = SensorField::from_str(&name) + .unwrap_or_else(|_| SensorField::Unknown(name.clone())); + (name, parsed) }) - .filter_map(|name| SensorField::parse(name).ok().map(|f| (name.as_str(), f))) .collect(); - let selected_fields: Vec<&str> = resolved.iter().map(|(name, _)| *name).collect(); - let columns: Vec> = resolved - .iter() - .map(|(_, field)| session.field(field)) - .collect(); + let columns: Vec> = selected.iter().map(|(_, f)| session.field(f)).collect(); let n_frames = session.frame_count(); let time_data = session.field(&SensorField::Time); @@ -753,11 +744,11 @@ fn cmd_dump( .into_iter() .map(|i| { let mut field_values = serde_json::Map::new(); - for (col_idx, &name) in selected_fields.iter().enumerate() { + for (col_idx, (name, _)) in selected.iter().enumerate() { let val = columns[col_idx].get(i).copied().unwrap_or(0.0); let json_val = serde_json::Number::from_f64(val) .map_or(serde_json::Value::Null, serde_json::Value::Number); - field_values.insert(name.to_string(), json_val); + field_values.insert(name.clone(), json_val); } DumpFrame { index: i, @@ -773,7 +764,7 @@ fn cmd_dump( firmware: session.firmware_version().to_string(), total_frames: n_frames, dumped_frames: frames.len(), - fields: selected_fields.iter().map(|s| (*s).to_string()).collect(), + fields: selected.iter().map(|(name, _)| name.clone()).collect(), frames, }); } diff --git a/propwash-core/Cargo.toml b/propwash-core/Cargo.toml index 89af47e..a8df892 100644 --- a/propwash-core/Cargo.toml +++ b/propwash-core/Cargo.toml @@ -12,6 +12,14 @@ bytemuck = { version = "1", features = ["derive"] } memchr = "2" rustfft = "6" serde = { version = "1", features = ["derive"] } +tsify-next = { version = "0.5", optional = true, default-features = false, features = ["js"] } +wasm-bindgen = { version = "0.2", optional = true } + +[features] +# Enable Tsify derives + wasm-bindgen ABI on public types (SensorField, +# Axis, RcChannel, MotorIndex). Off by default — only the WASM crate +# turns it on. +wasm = ["dep:tsify-next", "dep:wasm-bindgen"] [dev-dependencies] proptest = "1" diff --git a/propwash-core/examples/bench.rs b/propwash-core/examples/bench.rs index 404d2b7..c0692ac 100644 --- a/propwash-core/examples/bench.rs +++ b/propwash-core/examples/bench.rs @@ -1,6 +1,6 @@ use std::time::Instant; -use propwash_core::types::{Axis, SensorField}; +use propwash_core::units::DegPerSec; fn main() { let args: Vec = std::env::args().collect(); @@ -39,21 +39,16 @@ fn main() { frames as f64 / ms ); - // Benchmark field extraction + // Benchmark typed field access (the recommended path). let s = &log.sessions[0]; - let fields = [ - SensorField::Time, - SensorField::Gyro(Axis::Roll), - SensorField::Gyro(Axis::Pitch), - SensorField::Gyro(Axis::Yaw), - ]; let start = Instant::now(); for _ in 0..10 { - for f in &fields { - std::hint::black_box(s.field(f)); - } + std::hint::black_box(s.gyro.time_us.as_slice()); + std::hint::black_box(bytemuck::cast_slice::(&s.gyro.values.roll)); + std::hint::black_box(bytemuck::cast_slice::(&s.gyro.values.pitch)); + std::hint::black_box(bytemuck::cast_slice::(&s.gyro.values.yaw)); } let field_elapsed = start.elapsed(); let field_ms = field_elapsed.as_secs_f64() * 1000.0 / 10.0; - println!("Field extract (4 fields x10): {field_ms:.2}ms per iteration"); + println!("Typed field access (time + 3 gyro x10): {field_ms:.4}ms per iteration"); } diff --git a/propwash-core/src/analysis/fft.rs b/propwash-core/src/analysis/fft.rs index fdab951..1ffb99a 100644 --- a/propwash-core/src/analysis/fft.rs +++ b/propwash-core/src/analysis/fft.rs @@ -4,40 +4,8 @@ use rustfft::FftPlanner; use serde::Serialize; use super::events::{EventKind, FlightEvent}; -use crate::types::{Axis, MotorIndex, RcChannel, SensorField, Session}; -use crate::units::{DegPerSec, MetersPerSec2, Normalized01}; - -/// Local helper for `compute_spectrogram` — pulls a numeric trace from -/// the typed Session by `SensorField`. Only the variants actually requested -/// by the spectrogram caller are handled; others return empty. -fn field_as_f64(s: &Session, f: &SensorField) -> Vec { - match f { - SensorField::Time => s.gyro.time_us.iter().map(|&t| t.az::()).collect(), - SensorField::Gyro(axis) => { - bytemuck::cast_slice::(s.gyro.values.get(*axis).as_slice()).to_vec() - } - SensorField::Setpoint(axis) => { - bytemuck::cast_slice::(s.setpoint.values.get(*axis).as_slice()).to_vec() - } - SensorField::Accel(axis) => { - bytemuck::cast_slice::(s.accel.values.get(*axis).as_slice()) - .to_vec() - } - SensorField::Motor(MotorIndex(i)) => s - .motors - .commands - .get(*i) - .map(|c| c.iter().map(|n| f64::from(n.0)).collect()) - .unwrap_or_default(), - SensorField::Rc(RcChannel::Throttle) => { - bytemuck::cast_slice::(s.rc_command.throttle.as_slice()) - .iter() - .map(|&v| f64::from(v)) - .collect() - } - _ => Vec::new(), - } -} +use crate::types::{Axis, Session}; +use crate::units::DegPerSec; #[derive(Debug, Clone, Serialize)] pub struct FrequencySpectrum { @@ -288,9 +256,11 @@ pub fn analyze_vibration_unified( } // Get motor pole count for eRPM → frequency conversion. - // TODO(refactor/session-typed): wire BF parser to put motor_poles into - // SessionMeta or extras, then read it here. Default 14 is fine for now. - let motor_poles: u32 = 14; + // BF logs surface motor pole count via the `motor_poles` header. + // AP/PX4/MAVLink don't expose it; default to 14 (3-pole-pair 5" + // brushless, the common build) so eRPM→Hz still produces a + // reasonable number on those formats. + let motor_poles: u32 = unified.meta.motor_poles.unwrap_or(14); let mut spectra = Vec::new(); @@ -712,37 +682,38 @@ pub struct Spectrogram { /// Computes a time-frequency spectrogram for the given axes. /// -/// Each entry in `axes` is `(axis_name, sensor_field)`. Returns `None` if the -/// session has no valid sample rate or no axis produced output. +/// Each entry in `axes` is `(axis_label, samples)` — the caller pulls +/// the samples out of typed Session fields (e.g. via +/// `bytemuck::cast_slice` on `session.gyro.values.roll`) and labels +/// them however it wants for the result. `sample_rate_hz` and +/// `time_us_axis` come from the same Session field the samples are +/// taken from, typically the gyro axis. +/// +/// Returns `None` if `sample_rate_hz` is non-positive or no axis +/// produced enough samples for one window. /// -/// **Caveat (multi-rate sources):** the per-window timestamps are -/// derived from `session.gyro.time_us`. For non-gyro fields (motor, -/// throttle, accel) on AP/PX4/MAVLink — where each stream has its own -/// rate — the FFT magnitudes themselves are correct (each window is -/// contiguous samples of the source field), but the x-axis labels -/// linearly project onto the gyro time axis, so they're approximate. -/// Spec accuracy is tracked separately; `bug_005` follow-up. +/// **Caveat (multi-rate sources):** for axes whose source stream +/// samples at a different rate than `time_us_axis`, the FFT magnitudes +/// are correct (each window is contiguous samples of the source) but +/// the per-window x-axis labels linearly project onto `time_us_axis`, +/// so they're approximate. Pass the same axis whose `samples` you're +/// FFT-ing to keep labels exact. pub fn compute_spectrogram( - session: &Session, - axes: &[(&str, &SensorField)], + sample_rate_hz: f64, + time_us_axis: &[u64], + axes: &[(&str, &[f64])], ) -> Option { - let sample_rate = session.sample_rate_hz(); - if sample_rate <= 0.0 { + if sample_rate_hz <= 0.0 { return None; } - let freq_res = sample_rate / SPEC_WINDOW.az::(); + let freq_res = sample_rate_hz / SPEC_WINDOW.az::(); let max_bin = (SPEC_MAX_FREQ / freq_res) .saturating_as::() .min(SPEC_WINDOW / 2); let frequencies_hz: Vec = (0..max_bin).map(|i| i.az::() * freq_res).collect(); - let time_raw: Vec = session - .gyro - .time_us - .iter() - .map(|&t| t.az::()) - .collect(); + let time_raw: Vec = time_us_axis.iter().map(|&t| t.az::()).collect(); let t0 = time_raw.first().copied().unwrap_or(0.0); let hann = hann_window(SPEC_WINDOW); @@ -752,8 +723,7 @@ pub fn compute_spectrogram( let step = SPEC_WINDOW - SPEC_OVERLAP; let mut result_axes = Vec::new(); - for &(axis_name, field) in axes { - let raw = field_as_f64(session, field); + for &(axis_name, raw) in axes { if raw.len() < SPEC_WINDOW { continue; } @@ -808,7 +778,7 @@ pub fn compute_spectrogram( Some(Spectrogram { axes: result_axes, - sample_rate_hz: sample_rate, + sample_rate_hz, }) } diff --git a/propwash-core/src/analysis/mod.rs b/propwash-core/src/analysis/mod.rs index 3af20b5..c81328b 100644 --- a/propwash-core/src/analysis/mod.rs +++ b/propwash-core/src/analysis/mod.rs @@ -10,14 +10,16 @@ pub mod unified_events; pub(crate) mod util; use diagnostics::Diagnostic; -use events::FlightEvent; +use events::{EventKind as FlightEventKind, FlightEvent}; use fft::VibrationAnalysis; use pid::PidAnalysis; use step_response::StepResponseAnalysis; use summary::FlightSummary; +use crate::session as sess; use crate::types::Session; +use az::Az; use serde::Serialize; #[derive(Debug, Clone, Serialize)] @@ -32,12 +34,17 @@ pub struct FlightAnalysis { /// Analyzes a parsed session, detecting events and producing a summary. /// -/// TODO(refactor/session-typed): all format-specific event extraction -/// (PX4 `log_messages`, AP `EV`/`ERR`, `MAVLink` `STATUSTEXT`) now lives -/// inside each parser's `build` step and lands in `session.events`. -/// Once parsers are filled in, fold those into `FlightEvents` here. +/// Returned `events` combines two sources: +/// 1. Behavior-detected events from [`unified_events::detect_all`] +/// (gyro spikes, throttle chops, motor saturation, overshoot, desync). +/// 2. Format-native events that landed on `session.events` during parse +/// (firmware messages, mode changes, armed transitions). These are +/// surfaced as [`FlightEventKind::FirmwareMessage`] with a synthetic +/// severity so a single timeline view can render both. pub fn analyze(session: &Session) -> FlightAnalysis { - let detected = unified_events::detect_all(session); + let mut detected = unified_events::detect_all(session); + detected.extend(fold_session_events(session)); + detected.sort_by(|a, b| a.time_seconds.total_cmp(&b.time_seconds)); let vibration = fft::analyze_vibration_unified(session, &detected); let summary = summary::summarize(session, &detected); @@ -64,3 +71,134 @@ pub fn analyze(session: &Session) -> FlightAnalysis { diagnostics: diags, } } + +/// Translate `Session.events` (format-native events emitted by parsers +/// during the build step) into [`FlightEvent`]s for the timeline. +/// +/// All variants currently fold into [`FlightEventKind::FirmwareMessage`] +/// with a synthetic level string — this preserves the timeline without +/// expanding the analysis-layer `EventKind` enum. Frontends rendering +/// the timeline see one shape regardless of source. +fn fold_session_events(session: &Session) -> Vec { + let first_t = session + .gyro + .time_us + .first() + .copied() + .unwrap_or(0) + .az::(); + session + .events + .iter() + .map(|ev| { + let (level, message) = describe_session_event(ev); + let t = ev.time_us.az::(); + FlightEvent { + frame_index: 0, + time_us: t.az::(), + time_seconds: (t - first_t) / 1_000_000.0, + kind: FlightEventKind::FirmwareMessage { level, message }, + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::session::{Event, EventKind, FlightMode, LogSeverity}; + + #[test] + fn session_events_fold_into_flight_analysis() { + let mut s = Session::default(); + // Need at least 2 gyro samples for first_t to be meaningful. + s.gyro.time_us = vec![1_000_000, 2_000_000]; + s.gyro.values.roll = vec![Default::default(); 2]; + s.gyro.values.pitch = vec![Default::default(); 2]; + s.gyro.values.yaw = vec![Default::default(); 2]; + s.events = vec![ + Event { + time_us: 1_500_000, + kind: EventKind::Armed, + message: None, + }, + Event { + time_us: 1_750_000, + kind: EventKind::ModeChange { + to: FlightMode::Stabilize, + }, + message: None, + }, + Event { + time_us: 1_900_000, + kind: EventKind::LogMessage { + severity: LogSeverity::Warning, + }, + message: Some("low battery".into()), + }, + ]; + + let folded = fold_session_events(&s); + assert_eq!(folded.len(), 3, "all 3 session events should fold"); + + // Each should be a FirmwareMessage with a sensible level. + let messages: Vec<&str> = folded + .iter() + .filter_map(|e| match &e.kind { + FlightEventKind::FirmwareMessage { level, message } => Some(level.as_str()) + .zip(Some(message.as_str())) + .map(|_| level.as_str()), + _ => None, + }) + .collect(); + assert_eq!(messages, vec!["info", "info", "warning"]); + + // Time projection: events should land at t-first_t in seconds. + // first_t = 1_000_000 µs, so Armed at 1.5s = 0.5s. + assert!((folded[0].time_seconds - 0.5).abs() < 1e-9); + } +} + +fn describe_session_event(ev: &sess::Event) -> (String, String) { + use sess::{EventKind, FlightMode, LogSeverity}; + let mode_label = |m: &FlightMode| match m { + FlightMode::Other(s) => s.clone(), + other => format!("{other:?}"), + }; + let level = match &ev.kind { + EventKind::LogMessage { severity } => match severity { + LogSeverity::Emergency => "emergency", + LogSeverity::Alert => "alert", + LogSeverity::Critical => "critical", + LogSeverity::Error => "error", + LogSeverity::Warning => "warning", + LogSeverity::Notice => "notice", + LogSeverity::Info => "info", + LogSeverity::Debug => "debug", + } + .to_string(), + EventKind::Crash | EventKind::Failsafe { .. } => "critical".into(), + EventKind::GpsRescue { .. } => "warning".into(), + // Armed/Disarmed/ModeChange/Custom are state changes, not severity- + // bearing — surface as info-level so they show up but don't trip + // any "looks bad" UI heuristic. + _ => "info".into(), + }; + let message = match &ev.kind { + EventKind::Armed => "Armed".into(), + EventKind::Disarmed => ev + .message + .clone() + .map_or_else(|| "Disarmed".into(), |m| format!("Disarmed: {m}")), + EventKind::ModeChange { to } => format!("Mode change → {}", mode_label(to)), + EventKind::Crash => "Crash detected".into(), + EventKind::Failsafe { reason } => format!("Failsafe: {reason}"), + EventKind::GpsRescue { phase } => format!("GPS rescue ({phase})"), + EventKind::LogMessage { .. } => ev.message.clone().unwrap_or_default(), + EventKind::Custom(name) => ev + .message + .clone() + .map_or_else(|| name.clone(), |m| format!("{name}: {m}")), + }; + (level, message) +} diff --git a/propwash-core/src/analysis/util.rs b/propwash-core/src/analysis/util.rs index 5ea33e3..210b5c5 100644 --- a/propwash-core/src/analysis/util.rs +++ b/propwash-core/src/analysis/util.rs @@ -4,13 +4,12 @@ use crate::session::TimeSeries; use crate::units::{Amps, DegPerSec, MetersPerSec, MetersPerSec2, Volts}; // ── Resampling ────────────────────────────────────────────────────────────── -// TODO(refactor/session-typed): drop these `allow(dead_code)`s once the -// analysis layer migrates and starts calling resample() / resample_zoh(). /// Strategy for filling samples at target timestamps that don't align with -/// the source time axis. -#[allow(dead_code)] +/// the source time axis. Public API — `StepFill`/`Nearest` aren't used by +/// the analysis layer today but stay available for future callers. #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(dead_code)] pub enum Strategy { /// Linear interpolation between bracketing samples. Best for continuous /// signals (gyro, setpoint, accel). Requires `T: Lerp`. @@ -26,7 +25,6 @@ pub enum Strategy { /// Linear interpolation contract. Implemented for numeric scalars and the /// unit-typed newtypes built on them. -#[allow(dead_code)] pub trait Lerp: Copy { /// Returns `a + (b - a) * t` where `t ∈ [0.0, 1.0]`. fn lerp(a: Self, b: Self, t: f64) -> Self; @@ -82,7 +80,25 @@ impl<'a, T> From<&'a TimeSeries> for TimeSeriesView<'a, T> { } } -/// Resample a time series onto a target timestamp axis. +/// Resample an owned [`TimeSeries`] onto a target timestamp axis. Thin +/// wrapper around [`resample_view`] for callers who happen to have a +/// `TimeSeries` (vs slice pairs) — see [`resample_view`] for the +/// boundary semantics. Public for symmetry; not used by the analysis +/// layer today (which works with `TriaxialSeries` axes via views). +#[allow(dead_code)] +pub fn resample(src: &TimeSeries, target: &[u64], strategy: Strategy) -> Vec { + resample_view(src.into(), target, strategy) +} + +/// Resample for any `Copy` type using zero-order hold (step fill). Use +/// this for bool, enum, and other non-interpolatable streams. +#[allow(dead_code)] +pub fn resample_zoh(src: &TimeSeries, target: &[u64]) -> Vec { + resample_zoh_view(src.into(), target) +} + +/// Resample onto a target timestamp axis, taking separate `time_us` and +/// `values` slices. /// /// `target` must be sorted ascending. The returned `Vec` has the same /// length as `target`. If `src` is empty, the result is an empty `Vec` @@ -91,14 +107,10 @@ impl<'a, T> From<&'a TimeSeries> for TimeSeriesView<'a, T> { /// For target timestamps before the first source sample, all strategies /// return the first sample value. For target timestamps after the last /// source sample, all strategies return the last sample value. -#[allow(dead_code)] // thin TimeSeries-taking wrapper; analysis call sites use resample_view directly -pub fn resample(src: &TimeSeries, target: &[u64], strategy: Strategy) -> Vec { - resample_view(src.into(), target, strategy) -} - -/// Same as [`resample`] but takes a [`TimeSeriesView`] (separate -/// `time_us` and `values` slices). Useful when the stream isn't stored -/// as a `TimeSeries` (e.g. one axis of a `TriaxialSeries`). +/// +/// Useful when the stream isn't stored as a [`TimeSeries`] (e.g. one +/// axis of a [`crate::session::TriaxialSeries`]) and you don't want to +/// allocate a temporary `TimeSeries` just to resample it. pub fn resample_view( src: TimeSeriesView<'_, T>, target: &[u64], @@ -163,14 +175,7 @@ pub fn resample_view( out } -/// Resample for any `Copy` type using zero-order hold (step fill). Use this -/// for bool, enum, and other non-interpolatable streams. -#[allow(dead_code)] -pub fn resample_zoh(src: &TimeSeries, target: &[u64]) -> Vec { - resample_zoh_view(src.into(), target) -} - -#[allow(dead_code)] +/// View-based zero-order-hold variant of [`resample_zoh`]. pub fn resample_zoh_view(src: TimeSeriesView<'_, T>, target: &[u64]) -> Vec { let n = src.time_us.len(); if n == 0 || src.values.is_empty() { diff --git a/propwash-core/src/format/ap/build.rs b/propwash-core/src/format/ap/build.rs index 15efb4c..9981623 100644 --- a/propwash-core/src/format/ap/build.rs +++ b/propwash-core/src/format/ap/build.rs @@ -305,6 +305,7 @@ pub(crate) fn session(parsed: ApParsed, warnings: Vec, session_index: u }, board: None, motor_count: s.meta.motor_count, + motor_poles: None, // AP doesn't expose motor pole count via params pid_gains: Some(ardupilot_pid_gains(&parsed.params)), filter_config: Some(ardupilot_filter_config(&parsed.params)), session_index, diff --git a/propwash-core/src/format/bf/build.rs b/propwash-core/src/format/bf/build.rs index d98b280..12121ee 100644 --- a/propwash-core/src/format/bf/build.rs +++ b/propwash-core/src/format/bf/build.rs @@ -323,6 +323,14 @@ pub(crate) fn session_from_frames( }, board: None, motor_count, + motor_poles: { + let v = BfHeaderValue::int(headers, "motor_poles", 0); + if v > 0 { + Some(v.cast_unsigned()) + } else { + None + } + }, pid_gains: Some(parse_pid_gains(headers)), filter_config: Some(parse_filter_config(headers)), session_index, @@ -484,9 +492,7 @@ fn parse_flight_mode_flags(flags: u32) -> (FlightMode, bool) { #[cfg(test)] mod tests { use super::*; - use crate::format::bf::types::{ - BfFieldDef, BfFieldSign, BfFrameKind, BfHeaderValue, Encoding, Predictor, - }; + use crate::format::bf::types::{BfFieldDef, BfFieldSign, BfFrameKind, Encoding, Predictor}; use crate::types::Axis; fn field(name: SensorField, encoding: Encoding) -> BfFieldDef { diff --git a/propwash-core/src/format/mavlink/build.rs b/propwash-core/src/format/mavlink/build.rs index bbed821..1f88d50 100644 --- a/propwash-core/src/format/mavlink/build.rs +++ b/propwash-core/src/format/mavlink/build.rs @@ -315,6 +315,7 @@ pub(crate) fn session( craft_name: Some(parsed.vehicle_type.as_str().to_string()), board: None, motor_count: s.meta.motor_count, + motor_poles: None, // MAVLink doesn't expose motor pole count pid_gains, filter_config, session_index, diff --git a/propwash-core/src/format/px4/build.rs b/propwash-core/src/format/px4/build.rs index eb67eb1..c360603 100644 --- a/propwash-core/src/format/px4/build.rs +++ b/propwash-core/src/format/px4/build.rs @@ -333,6 +333,7 @@ pub(crate) fn session(parsed: Px4Parsed, warnings: Vec, session_index: }, board: None, motor_count: s.meta.motor_count, + motor_poles: None, // PX4 doesn't expose motor pole count via params pid_gains: None, filter_config: None, session_index, diff --git a/propwash-core/src/session.rs b/propwash-core/src/session.rs index c76b9b8..59971eb 100644 --- a/propwash-core/src/session.rs +++ b/propwash-core/src/session.rs @@ -396,6 +396,12 @@ pub struct SessionMeta { pub craft_name: Option, pub board: Option, pub motor_count: usize, + /// Motor pole count, used by FFT analysis to convert eRPM to + /// mechanical Hz (`mechanical_hz = erpm / 60 / (poles / 2)`). + /// Sourced from BF `motor_poles` header; AP/PX4/MAVLink leave it + /// `None`. Default 14 (3-pole-pair brushless, the common 5" build) + /// is a reasonable downstream fallback. + pub motor_poles: Option, pub pid_gains: Option, pub filter_config: Option, /// 1-based index within the parent log (logs may contain multiple sessions). @@ -416,6 +422,7 @@ impl Default for SessionMeta { craft_name: None, board: None, motor_count: 0, + motor_poles: None, pid_gains: None, filter_config: None, session_index: 0, @@ -500,27 +507,122 @@ impl Session { (0.0, 1.0) } - /// Returns the names of all populated streams. - /// TODO(refactor/session-typed): replace with typed iteration once - /// consumers no longer ask for fields by name. + /// Enumerate canonical names of populated streams (typed slots + + /// `extras` keys) for runtime field-picker UIs and `propwash info` + /// output. A name in this list, when round-tripped through + /// [`SensorField::from_str`] and [`Self::field`], is guaranteed to + /// resolve to a non-empty `Vec` (with the exception of names that + /// have no `SensorField` variant — `attitude[*]`, `current`, + /// `extras` keys — which fall back to [`SensorField::Unknown`]). + /// + /// Code that knows what it wants at compile time should reach into + /// the typed fields directly (`session.gyro.values.roll`, etc.) + /// instead of round-tripping through strings. pub fn field_names(&self) -> Vec { let mut names = Vec::new(); - if !self.gyro.is_empty() { + // Time is the implicit anchor for any non-empty triaxial stream. + if !self.gyro.is_empty() + || !self.accel.is_empty() + || !self.setpoint.is_empty() + || !self.motors.is_empty() + || !self.rc_command.is_empty() + { names.push("time".into()); - for axis in [Axis::Roll, Axis::Pitch, Axis::Yaw] { + } + for axis in [Axis::Roll, Axis::Pitch, Axis::Yaw] { + if !self.gyro.values.get(axis).is_empty() { names.push(format!("gyro[{axis}]")); } } + for axis in [Axis::Roll, Axis::Pitch, Axis::Yaw] { + if !self.accel.values.get(axis).is_empty() { + names.push(format!("accel[{axis}]")); + } + } + for axis in [Axis::Roll, Axis::Pitch, Axis::Yaw] { + if !self.setpoint.values.get(axis).is_empty() { + names.push(format!("setpoint[{axis}]")); + } + } + for axis in [Axis::Roll, Axis::Pitch, Axis::Yaw] { + if !self.attitude.values.get(axis).is_empty() { + names.push(format!("attitude[{axis}]")); + } + } + for (i, col) in self.motors.commands.iter().enumerate() { + if !col.is_empty() { + names.push(format!("motor[{i}]")); + } + } + if let Some(esc) = &self.motors.esc { + for (i, col) in esc.erpm.iter().enumerate() { + if !col.is_empty() { + names.push(format!("erpm[{i}]")); + } + } + } + if !self.rc_command.sticks.roll.is_empty() { + names.push("rc[roll]".into()); + } + if !self.rc_command.sticks.pitch.is_empty() { + names.push("rc[pitch]".into()); + } + if !self.rc_command.sticks.yaw.is_empty() { + names.push("rc[yaw]".into()); + } + if !self.rc_command.throttle.is_empty() { + names.push("rc[throttle]".into()); + } + if !self.vbat.is_empty() { + names.push("vbat".into()); + } + if !self.current.is_empty() { + names.push("current".into()); + } + if !self.rssi.is_empty() { + names.push("rssi".into()); + } + if let Some(gps) = &self.gps { + if !gps.lat.is_empty() { + names.push("gps_lat".into()); + } + if !gps.lng.is_empty() { + names.push("gps_lng".into()); + } + if !gps.alt.is_empty() { + names.push("altitude".into()); + } + if !gps.speed.is_empty() { + names.push("gps_speed".into()); + } + if !gps.heading.is_empty() { + names.push("heading".into()); + } + } names.extend(self.extras.keys().cloned()); names } - /// Bridge: legacy stringly-typed field accessor. + /// Look up a field by typed [`SensorField`] handle. Returns an + /// empty `Vec` for variants that aren't populated in this session + /// (e.g. asking for `Gyro(Roll)` on a session whose gyro stream is + /// empty, or any [`SensorField::Unknown`] value). + /// + /// **Prefer the typed accessors directly** when the field is + /// known at compile time — they're zero-cost and preserve unit + /// information: + /// + /// ```ignore + /// let roll: &[DegPerSec] = &session.gyro.values.roll; + /// let m0: &[Normalized01] = &session.motors.commands[0]; + /// let vbat: &[Volts] = &session.vbat.values; + /// ``` /// - /// Used by the CLI `dump` command and the WASM raw-data tab where - /// the user supplies a field name (e.g. `gyro[roll]`) at runtime — - /// stringly-typed lookup is the right shape there. Internal analysis - /// code uses typed accessors directly. + /// Use this method when the field handle came from a runtime + /// source — a CLI argument parsed via [`SensorField::from_str`], + /// a `SensorField` deserialized off the WASM boundary, etc. It + /// allocates a `Vec` per call (copy + cast off the typed + /// columns) and erases the unit type. #[allow(clippy::too_many_lines)] // declarative variant-to-typed-field routing pub fn field(&self, field: &SensorField) -> Vec { match field { diff --git a/propwash-core/src/types.rs b/propwash-core/src/types.rs index ebf0150..2751857 100644 --- a/propwash-core/src/types.rs +++ b/propwash-core/src/types.rs @@ -1,13 +1,18 @@ use std::fmt; +use std::str::FromStr; -use serde::Serialize; +use serde::{Deserialize, Serialize}; +#[cfg(feature = "wasm")] +use tsify_next::Tsify; // Re-export so existing `crate::types::Session` imports keep working during // the refactor — Session itself lives in `crate::session`. pub use crate::session::Session; /// Rotational axis: roll, pitch, or yaw. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "wasm", derive(Tsify))] +#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))] pub enum Axis { Roll, Pitch, @@ -47,7 +52,9 @@ impl fmt::Display for Axis { } /// RC channel: roll, pitch, yaw, or throttle. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "wasm", derive(Tsify))] +#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))] pub enum RcChannel { Roll, Pitch, @@ -55,17 +62,6 @@ pub enum RcChannel { Throttle, } -impl RcChannel { - pub fn index(self) -> usize { - match self { - Self::Roll => 0, - Self::Pitch => 1, - Self::Yaw => 2, - Self::Throttle => 3, - } - } -} - impl fmt::Display for RcChannel { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -78,7 +74,12 @@ impl fmt::Display for RcChannel { } /// Typed motor index. Prevents mixing with axis indices or other ordinals. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +/// +/// Serialized as a bare integer (newtype-struct shorthand): `MotorIndex(3)` +/// goes over the wire as `3`, not `{ "MotorIndex": 3 }`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "wasm", derive(Tsify))] +#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))] pub struct MotorIndex(pub usize); impl fmt::Display for MotorIndex { @@ -89,52 +90,22 @@ impl fmt::Display for MotorIndex { /// Format-agnostic sensor field identifier. /// -/// Canonical unit for a sensor field's values. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Unit { - /// Microseconds (timestamps). - Microseconds, - /// Degrees per second (angular rates). - DegreesPerSecond, - /// Meters per second squared (linear acceleration). - MetersPerSecondSquared, - /// PWM microseconds (motor/servo/RC outputs). - Pwm, - /// Revolutions per minute. - Rpm, - /// Volts. - Volts, - /// Meters. - Meters, - /// Meters per second. - MetersPerSecond, - /// Degrees (angle or coordinate). - Degrees, - /// No meaningful unit. - Dimensionless, -} - -impl fmt::Display for Unit { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Microseconds | Self::Pwm => write!(f, "μs"), - Self::DegreesPerSecond => write!(f, "deg/s"), - Self::MetersPerSecondSquared => write!(f, "m/s²"), - Self::Rpm => write!(f, "rpm"), - Self::Volts => write!(f, "V"), - Self::Meters => write!(f, "m"), - Self::MetersPerSecond => write!(f, "m/s"), - Self::Degrees => write!(f, "deg"), - Self::Dimensionless => write!(f, ""), - } - } -} - -/// Format-agnostic sensor field identifier. +/// Public so external consumers can pass typed field handles to +/// [`Session::field`] (faster + type-safe) or speak it across the +/// WASM boundary as a discriminated union. +/// +/// Wire format (default serde external tagging): +/// - Unit variants serialize as plain strings: `"Time"`, `"Vbat"`, +/// `"Heading"`. +/// - Payload variants serialize as single-key objects: +/// `{ "Gyro": "Roll" }`, `{ "Motor": 0 }`, +/// `{ "Unknown": "weird_field" }`. /// -/// Known fields get proper variants with typed indices. -/// Unknown fields preserve the original header string. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] +/// For the canonical-string format used by [`Self::parse`] / +/// [`Display`] (`"gyro[roll]"`, `"motor[0]"`), use [`FromStr`]. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "wasm", derive(Tsify))] +#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))] #[non_exhaustive] pub enum SensorField { Time, @@ -159,44 +130,19 @@ pub enum SensorField { Unknown(String), } -impl SensorField { - /// Parses a canonical field name into a `SensorField`. - /// - /// Canonical names use format-agnostic conventions: - /// `"gyro[roll]"`, `"motor[0]"`, `"rc[throttle]"`, `"pid_p[yaw]"`, etc. - /// - /// Unknown names are returned as `Ok(Self::Unknown(...))`. - /// - /// # Errors - /// - /// Returns `Err` if an indexed field has an invalid axis or index - /// (e.g., `"gyro[invalid]"`). - /// Returns the canonical unit for this field's values. - /// - /// All format implementations must convert to these units in their - /// `field()` methods. For example, `Vbat` must return volts regardless - /// of whether the format stores millivolts, centivalts, or raw ADC counts. - pub fn unit(&self) -> Unit { - match self { - Self::Time => Unit::Microseconds, - Self::Gyro(_) | Self::GyroUnfilt(_) | Self::Setpoint(_) => Unit::DegreesPerSecond, - Self::Accel(_) => Unit::MetersPerSecondSquared, - Self::Motor(_) | Self::Rc(_) => Unit::Pwm, - Self::ERpm(_) => Unit::Rpm, - Self::Vbat => Unit::Volts, - Self::Altitude => Unit::Meters, - Self::GpsSpeed => Unit::MetersPerSecond, - Self::GpsLat | Self::GpsLng | Self::Heading => Unit::Degrees, - Self::Rssi - | Self::PidP(_) - | Self::PidI(_) - | Self::PidD(_) - | Self::Feedforward(_) - | Self::Unknown(_) => Unit::Dimensionless, - } +impl FromStr for SensorField { + type Err = String; + + fn from_str(s: &str) -> Result { + Self::parse(s) } +} - /// Parses a canonical field name into a `SensorField`. +impl SensorField { + /// Parses a canonical field name (e.g. `"gyro[roll]"`, + /// `"motor[0]"`, `"vbat"`) into a `SensorField`. Same logic as + /// [`FromStr`]; kept as an inherent method for ergonomic use + /// without needing the trait in scope. /// /// # Errors /// diff --git a/propwash-core/tests/integration.rs b/propwash-core/tests/integration.rs index 15d6621..905c2db 100644 --- a/propwash-core/tests/integration.rs +++ b/propwash-core/tests/integration.rs @@ -204,7 +204,6 @@ fn ap_heading_uses_airframe_attitude_not_gps_cog() { } #[test] -#[allow(deprecated)] fn field_heading_prefers_attitude_over_gps_cog() { use propwash_core::types::SensorField; let sessions = decode("ardupilot/dronekit-copter-log171.bin"); @@ -325,14 +324,7 @@ fn mavlink_unit_sanity() { #[test] fn spectrogram_smoke_each_format() { use propwash_core::analysis::fft::compute_spectrogram; - use propwash_core::types::{Axis, SensorField}; - - let axes_input = [ - ("roll", SensorField::Gyro(Axis::Roll)), - ("pitch", SensorField::Gyro(Axis::Pitch)), - ("yaw", SensorField::Gyro(Axis::Yaw)), - ]; - let axis_refs: Vec<(&str, &SensorField)> = axes_input.iter().map(|(n, f)| (*n, f)).collect(); + use propwash_core::units::DegPerSec; for fixture in [ "fc-blackbox/btfl_002.bbl", @@ -341,7 +333,12 @@ fn spectrogram_smoke_each_format() { "mavlink/dronekit-flight.tlog", ] { let sessions = decode(fixture); - let spec = compute_spectrogram(&sessions[0], &axis_refs); + let s = &sessions[0]; + let roll: &[f64] = bytemuck::cast_slice::(&s.gyro.values.roll); + let pitch: &[f64] = bytemuck::cast_slice::(&s.gyro.values.pitch); + let yaw: &[f64] = bytemuck::cast_slice::(&s.gyro.values.yaw); + let axes = [("roll", roll), ("pitch", pitch), ("yaw", yaw)]; + let spec = compute_spectrogram(s.sample_rate_hz(), &s.gyro.time_us, &axes); // Some fixtures may not have enough samples for a full // spectrogram window; require ≥1 axis produced output for // those that do, but skip cleanly for those that don't. diff --git a/propwash-core/tests/perf.rs b/propwash-core/tests/perf.rs index af0c4df..b82bd37 100644 --- a/propwash-core/tests/perf.rs +++ b/propwash-core/tests/perf.rs @@ -140,14 +140,7 @@ fn perf_mavlink_dronekit() { #[test] #[ignore] fn perf_field_extraction_all_formats() { - use propwash_core::types::{Axis, SensorField}; - - let fields = [ - SensorField::Time, - SensorField::Gyro(Axis::Roll), - SensorField::Gyro(Axis::Pitch), - SensorField::Gyro(Axis::Yaw), - ]; + use propwash_core::units::DegPerSec; for (name, fixture) in [ ("BF", "fc-blackbox/btfl_001.bbl"), @@ -158,23 +151,26 @@ fn perf_field_extraction_all_formats() { let path = fixture_path(fixture); let data = std::fs::read(&path).unwrap(); let log = propwash_core::decode(&data).unwrap(); - let session = &log.sessions[0]; + let s = &log.sessions[0]; let start = Instant::now(); for _ in 0..100 { - for f in &fields { - std::hint::black_box(session.field(f)); - } + std::hint::black_box(s.gyro.time_us.as_slice()); + std::hint::black_box(bytemuck::cast_slice::(&s.gyro.values.roll)); + std::hint::black_box(bytemuck::cast_slice::(&s.gyro.values.pitch)); + std::hint::black_box(bytemuck::cast_slice::(&s.gyro.values.yaw)); } let elapsed = start.elapsed(); let ms_per = elapsed.as_secs_f64() * 1000.0 / 100.0; - eprintln!(" {name} field extract (4 fields): {ms_per:.2}ms per iteration"); + eprintln!(" {name} typed field access (time + 3 gyro): {ms_per:.4}ms per iteration"); - // Columnar storage should make field extraction fast + // Typed access via cast_slice is zero-cost; this should be + // well under a millisecond. Catches accidental regressions + // (e.g. an allocating implementation slipping in). assert!( - ms_per < 50.0, - "{name} field extraction {ms_per:.1}ms exceeds 50ms ceiling" + ms_per < 1.0, + "{name} typed field access {ms_per:.3}ms exceeds 1ms ceiling" ); } } diff --git a/propwash-web/Cargo.toml b/propwash-web/Cargo.toml index 2ae33eb..f84d4a5 100644 --- a/propwash-web/Cargo.toml +++ b/propwash-web/Cargo.toml @@ -10,10 +10,13 @@ license = "MIT" crate-type = ["cdylib", "rlib"] [dependencies] -propwash-core = { path = "../propwash-core" } +az = "1" +propwash-core = { path = "../propwash-core", features = ["wasm"] } wasm-bindgen = "0.2" serde = { version = "1", features = ["derive"] } serde_json = "1" +serde-wasm-bindgen = "0.6" +tsify-next = { version = "0.5", default-features = false, features = ["js"] } console_error_panic_hook = "0.1" [lints.rust] diff --git a/propwash-web/src/lib.rs b/propwash-web/src/lib.rs index 9b5febf..44780fc 100644 --- a/propwash-web/src/lib.rs +++ b/propwash-web/src/lib.rs @@ -1,12 +1,22 @@ use std::cell::RefCell; use std::collections::HashMap; -use serde::Serialize; +use az::Az; +use serde::{Deserialize, Serialize}; +use tsify_next::Tsify; use wasm_bindgen::prelude::*; use propwash_core::analysis::{self, fft, trend, FlightAnalysis}; use propwash_core::types::{Log, SensorField}; +/// Newtype wrapper so `Vec` can cross the wasm-bindgen +/// boundary as a typed `SensorField[]` array. JS callers see a plain +/// array of `SensorField` values; Rust gets a typed `Vec`. +#[derive(Tsify, Serialize, Deserialize)] +#[tsify(into_wasm_abi, from_wasm_abi)] +#[serde(transparent)] +pub struct SensorFields(pub Vec); + // --------------------------------------------------------------------------- // Workspace state — supports multiple loaded files // --------------------------------------------------------------------------- @@ -198,8 +208,9 @@ pub fn get_timeseries( file_id: u32, session_idx: usize, max_points: usize, - field_list: &str, + field_list: SensorFields, ) -> String { + let SensorFields(requested) = field_list; with_session(file_id, session_idx, |session| { let total_frames = session.frame_count(); let sample_rate = session.sample_rate_hz(); @@ -210,24 +221,30 @@ pub fn get_timeseries( 1 }; - let requested: Vec<&str> = field_list.split(',').collect(); let mut fields: HashMap> = HashMap::new(); - for &name in &requested { - let Ok(field) = SensorField::parse(name) else { + for name in &requested { + let raw = session.field(name); + if raw.is_empty() { continue; - }; - let raw = session.field(&field); + } let decimated: Vec = raw.iter().step_by(step).copied().collect(); fields.insert(name.to_string(), decimated); } - let time_raw = session.field(&SensorField::Time); - let t0 = time_raw.first().copied().unwrap_or(0.0); - let time_s: Vec = time_raw + let t0 = session + .gyro + .time_us + .first() + .copied() + .unwrap_or(0) + .az::(); + let time_s: Vec = session + .gyro + .time_us .iter() .step_by(step) - .map(|&v| (v - t0) / 1_000_000.0) + .map(|&v| (v.az::() - t0) / 1_000_000.0) .collect(); let result = TimeseriesResult { @@ -243,27 +260,24 @@ pub fn get_timeseries( }) } -/// Get spectrogram data for a session. +/// Get spectrogram data for a session. Each axis is labelled by its +/// `Display` form (e.g. `"gyro[roll]"`) in the response. #[wasm_bindgen] -pub fn get_spectrogram(file_id: u32, session_idx: usize, axis_list: &str) -> String { +pub fn get_spectrogram(file_id: u32, session_idx: usize, axis_list: SensorFields) -> String { + let SensorFields(requested) = axis_list; with_session(file_id, session_idx, |session| { - let axes: Vec<(&str, SensorField)> = axis_list - .split(',') - .filter_map(|a| { - let field_name = match a { - "roll" => "gyro[roll]", - "pitch" => "gyro[pitch]", - "yaw" => "gyro[yaw]", - other => other, - }; - SensorField::parse(field_name).ok().map(|f| (a, f)) - }) + let owned: Vec<(String, Vec)> = requested + .iter() + .map(|f| (f.to_string(), session.field(f))) + .collect(); + let axes: Vec<(&str, &[f64])> = owned + .iter() + .map(|(label, samples)| (label.as_str(), samples.as_slice())) .collect(); - let axis_refs: Vec<(&str, &SensorField)> = - axes.iter().map(|(name, field)| (*name, field)).collect(); - - let Some(spectrogram) = fft::compute_spectrogram(session, &axis_refs) else { + let Some(spectrogram) = + fft::compute_spectrogram(session.sample_rate_hz(), &session.gyro.time_us, &axes) + else { return r#"{"error":"no spectrogram data"}"#.to_string(); }; @@ -288,19 +302,15 @@ pub fn get_raw_frames( session_idx: usize, start: usize, count: usize, - field_list: &str, + field_list: SensorFields, ) -> String { + let SensorFields(requested) = field_list; with_session(file_id, session_idx, |session| { let total = session.frame_count(); - let requested: Vec<&str> = field_list.split(',').collect(); let end = (start + count).min(total); - let resolved: Vec = requested - .iter() - .filter_map(|&name| SensorField::parse(name).ok()) - .collect(); - let columns: Vec> = resolved.iter().map(|f| session.field(f)).collect(); + let columns: Vec> = requested.iter().map(|f| session.field(f)).collect(); let frames: Vec> = (start..end) .map(|frame_idx| { @@ -312,7 +322,7 @@ pub fn get_raw_frames( .collect(); let result = RawFramesResult { - field_names: requested.iter().map(|s| (*s).to_string()).collect(), + field_names: requested.iter().map(SensorField::to_string).collect(), frames, start, total, diff --git a/propwash-web/tests/bridge.rs b/propwash-web/tests/bridge.rs index 1e283fb..c86c294 100644 --- a/propwash-web/tests/bridge.rs +++ b/propwash-web/tests/bridge.rs @@ -1,4 +1,8 @@ use std::path::Path; +use std::str::FromStr; + +use propwash_core::types::SensorField; +use propwash_web::SensorFields; fn fixtures_dir() -> &'static Path { Path::new(concat!( @@ -12,6 +16,17 @@ fn read_fixture(rel_path: &str) -> Vec { std::fs::read(&path).unwrap_or_else(|e| panic!("Failed to read {rel_path}: {e}")) } +/// Parse a comma-separated list of canonical field names (e.g. +/// `"gyro[roll],motor[0]"`) into the typed `SensorFields` payload the +/// bridge functions now expect. +fn fields(s: &str) -> SensorFields { + SensorFields( + s.split(',') + .map(|n| SensorField::from_str(n).unwrap()) + .collect(), + ) +} + // --------------------------------------------------------------------------- // Workspace lifecycle // --------------------------------------------------------------------------- @@ -77,7 +92,7 @@ fn remove_file_drops_entry() { propwash_web::remove_file(file_id); // Accessing removed file should return error - let ts = propwash_web::get_timeseries(file_id, 0, 100, "gyro[roll]"); + let ts = propwash_web::get_timeseries(file_id, 0, 100, fields("gyro[roll]")); let ts_result: serde_json::Value = serde_json::from_str(&ts).unwrap(); assert!( ts_result["error"].is_string(), @@ -101,8 +116,8 @@ fn multiple_files_in_workspace() { assert_ne!(id1, id2, "file IDs should be unique"); // Both should be accessible - let ts1 = propwash_web::get_timeseries(id1 as u32, 0, 100, "gyro[roll]"); - let ts2 = propwash_web::get_timeseries(id2 as u32, 0, 100, "gyro[roll]"); + let ts1 = propwash_web::get_timeseries(id1 as u32, 0, 100, fields("gyro[roll]")); + let ts2 = propwash_web::get_timeseries(id2 as u32, 0, 100, fields("gyro[roll]")); assert!(!ts1.contains("error")); assert!(!ts2.contains("error")); } @@ -135,7 +150,8 @@ fn get_timeseries_returns_data() { serde_json::from_str(&propwash_web::add_file(&data, "test.bbl")).unwrap(); let file_id = r["file_id"].as_u64().unwrap() as u32; - let json = propwash_web::get_timeseries(file_id, 0, 1000, "gyro[roll],gyro[pitch],motor[0]"); + let json = + propwash_web::get_timeseries(file_id, 0, 1000, fields("gyro[roll],gyro[pitch],motor[0]")); let ts: serde_json::Value = serde_json::from_str(&json).unwrap(); assert!(ts["time_s"].as_array().unwrap().len() > 0); @@ -153,7 +169,7 @@ fn get_timeseries_decimation() { serde_json::from_str(&propwash_web::add_file(&data, "test.bbl")).unwrap(); let file_id = r["file_id"].as_u64().unwrap() as u32; - let json = propwash_web::get_timeseries(file_id, 0, 100, "gyro[roll]"); + let json = propwash_web::get_timeseries(file_id, 0, 100, fields("gyro[roll]")); let ts: serde_json::Value = serde_json::from_str(&json).unwrap(); let points = ts["time_s"].as_array().unwrap().len(); @@ -178,7 +194,7 @@ fn get_timeseries_unknown_field_skipped() { serde_json::from_str(&propwash_web::add_file(&data, "test.bbl")).unwrap(); let file_id = r["file_id"].as_u64().unwrap() as u32; - let json = propwash_web::get_timeseries(file_id, 0, 100, "nonexistent_field"); + let json = propwash_web::get_timeseries(file_id, 0, 100, fields("nonexistent_field")); let ts: serde_json::Value = serde_json::from_str(&json).unwrap(); // Unknown fields resolve to empty data (SensorField::Unknown) @@ -200,7 +216,7 @@ fn get_timeseries_invalid_session() { serde_json::from_str(&propwash_web::add_file(&data, "test.bbl")).unwrap(); let file_id = r["file_id"].as_u64().unwrap() as u32; - let json = propwash_web::get_timeseries(file_id, 999, 100, "gyro[roll]"); + let json = propwash_web::get_timeseries(file_id, 999, 100, fields("gyro[roll]")); let ts: serde_json::Value = serde_json::from_str(&json).unwrap(); assert!(ts["error"].is_string()); } @@ -217,7 +233,8 @@ fn get_spectrogram_returns_axes() { serde_json::from_str(&propwash_web::add_file(&data, "test.bbl")).unwrap(); let file_id = r["file_id"].as_u64().unwrap() as u32; - let json = propwash_web::get_spectrogram(file_id, 0, "roll,pitch,yaw"); + let json = + propwash_web::get_spectrogram(file_id, 0, fields("gyro[roll],gyro[pitch],gyro[yaw]")); let sg: serde_json::Value = serde_json::from_str(&json).unwrap(); // Either axes data or an error for too-short logs @@ -258,7 +275,7 @@ fn get_raw_frames_returns_data() { serde_json::from_str(&propwash_web::add_file(&data, "test.bbl")).unwrap(); let file_id = r["file_id"].as_u64().unwrap() as u32; - let json = propwash_web::get_raw_frames(file_id, 0, 0, 10, "gyro[roll],motor[0]"); + let json = propwash_web::get_raw_frames(file_id, 0, 0, 10, fields("gyro[roll],motor[0]")); let raw: serde_json::Value = serde_json::from_str(&json).unwrap(); assert_eq!(raw["start"], 0); @@ -278,7 +295,7 @@ fn get_raw_frames_clamped_to_total() { let file_id = r["file_id"].as_u64().unwrap() as u32; // Request beyond total frames - let json = propwash_web::get_raw_frames(file_id, 0, 999_999, 100, "gyro[roll]"); + let json = propwash_web::get_raw_frames(file_id, 0, 999_999, 100, fields("gyro[roll]")); let raw: serde_json::Value = serde_json::from_str(&json).unwrap(); let frames = raw["frames"].as_array().unwrap(); diff --git a/web/src/sensor-field.ts b/web/src/sensor-field.ts new file mode 100644 index 0000000..f6f3669 --- /dev/null +++ b/web/src/sensor-field.ts @@ -0,0 +1,107 @@ +import type { SensorField, Axis, RcChannel } from "../pkg/propwash_web.js"; + +const AXIS_MAP: Record = { + roll: "Roll", + pitch: "Pitch", + yaw: "Yaw", +}; + +const RC_CHANNEL_MAP: Record = { + roll: "Roll", + pitch: "Pitch", + yaw: "Yaw", + throttle: "Throttle", +}; + +const UNIT_FIELDS: Record = { + time: "Time", + vbat: "Vbat", + altitude: "Altitude", + gps_speed: "GpsSpeed", + gps_lat: "GpsLat", + gps_lng: "GpsLng", + heading: "Heading", + rssi: "Rssi", +}; + +function parseAxisIndex(name: string): Axis | undefined { + const m = name.match(/\[(\w+)\]$/); + if (!m) return undefined; + return AXIS_MAP[m[1]]; +} + +function parseNumericIndex(name: string): number | undefined { + const m = name.match(/\[(\d+)\]$/); + if (!m) return undefined; + return Number.parseInt(m[1], 10); +} + +function parseRcChannel(name: string): RcChannel | undefined { + const m = name.match(/\[(\w+)\]$/); + if (!m) return undefined; + return RC_CHANNEL_MAP[m[1]]; +} + +/** + * Parse a canonical field name (e.g. "gyro[roll]", "motor[0]", "vbat") + * into a typed SensorField. Mirrors the Rust SensorField::parse logic. + */ +export function parseSensorField(name: string): SensorField { + const unit = UNIT_FIELDS[name]; + if (unit) return unit; + + if (name.startsWith("gyro[")) { + const axis = parseAxisIndex(name); + if (axis) return { Gyro: axis }; + } + if (name.startsWith("gyro_unfilt[")) { + const axis = parseAxisIndex(name); + if (axis) return { GyroUnfilt: axis }; + } + if (name.startsWith("setpoint[")) { + const axis = parseAxisIndex(name); + if (axis) return { Setpoint: axis }; + } + if (name.startsWith("accel[")) { + const axis = parseAxisIndex(name); + if (axis) return { Accel: axis }; + } + if (name.startsWith("pid_p[")) { + const axis = parseAxisIndex(name); + if (axis) return { PidP: axis }; + } + if (name.startsWith("pid_i[")) { + const axis = parseAxisIndex(name); + if (axis) return { PidI: axis }; + } + if (name.startsWith("pid_d[")) { + const axis = parseAxisIndex(name); + if (axis) return { PidD: axis }; + } + if (name.startsWith("feedforward[")) { + const axis = parseAxisIndex(name); + if (axis) return { Feedforward: axis }; + } + if (name.startsWith("motor[")) { + const idx = parseNumericIndex(name); + if (idx !== undefined) return { Motor: idx }; + } + if (name.startsWith("erpm[")) { + const idx = parseNumericIndex(name); + if (idx !== undefined) return { ERpm: idx }; + } + if (name.startsWith("rc[")) { + const ch = parseRcChannel(name); + if (ch) return { Rc: ch }; + } + + return { Unknown: name }; +} + +/** + * Parse a comma-separated list of canonical field names into an array + * of typed SensorField values, ready to hand to a WASM bridge call. + */ +export function parseSensorFields(csv: string): SensorField[] { + return csv.split(",").map((s) => parseSensorField(s.trim())); +} diff --git a/web/src/views/raw.ts b/web/src/views/raw.ts index 514f188..2571056 100644 --- a/web/src/views/raw.ts +++ b/web/src/views/raw.ts @@ -1,4 +1,5 @@ import { get_raw_frames } from "../../pkg/propwash_web.js"; +import { parseSensorFields } from "../sensor-field.js"; import type { SessionRef } from "../types.js"; import { $ } from "../state.js"; @@ -33,7 +34,8 @@ export function loadRawPage(ref: SessionRef, start: number): void { const selected = Array.from(select.selectedOptions).map((o) => o.value); if (selected.length === 0) return; - const json = get_raw_frames(ref.fileId, ref.sessionIdx, start, RAW_PAGE_SIZE, selected.join(",")); + const fields = parseSensorFields(selected.join(",")); + const json = get_raw_frames(ref.fileId, ref.sessionIdx, start, RAW_PAGE_SIZE, fields); const data = JSON.parse(json); if (data.error) return; diff --git a/web/src/views/spectrum.ts b/web/src/views/spectrum.ts index 44f70db..777fa85 100644 --- a/web/src/views/spectrum.ts +++ b/web/src/views/spectrum.ts @@ -1,4 +1,5 @@ import { get_spectrogram } from "../../pkg/propwash_web.js"; +import type { SensorField } from "../../pkg/propwash_web.js"; import { heatColor } from "../format.js"; import type { SessionRef, VibrationAnalysis, Spectrum } from "../types.js"; import { $, chartWidth, filterConfig, echartsInstances } from "../state.js"; @@ -220,7 +221,8 @@ export function renderSpectrogram(ref: SessionRef): void { const container = $("#spectrogram-plots"); container.innerHTML = ""; - const json = get_spectrogram(ref.fileId, ref.sessionIdx, "roll,pitch,yaw"); + const axes: SensorField[] = [{ Gyro: "Roll" }, { Gyro: "Pitch" }, { Gyro: "Yaw" }]; + const json = get_spectrogram(ref.fileId, ref.sessionIdx, axes); const data = JSON.parse(json); if (data.error || !data.axes || data.axes.length === 0) { container.innerHTML = '

No spectrogram data available.

'; diff --git a/web/src/views/timeline.ts b/web/src/views/timeline.ts index bf512df..e94eb7f 100644 --- a/web/src/views/timeline.ts +++ b/web/src/views/timeline.ts @@ -1,5 +1,6 @@ import { get_timeseries } from "../../pkg/propwash_web.js"; import { eventColor } from "../format.js"; +import { parseSensorFields } from "../sensor-field.js"; import type { SessionRef, FlightEvent } from "../types.js"; import { $, $$, chartWidth, activeSession, activeSessionResult, renderedViews, tsPlots, setTsPlots, setTsSync } from "../state.js"; import { FIELD_GROUPS, TS_MAX_POINTS, pidFieldGroup } from "../chart-config.js"; @@ -96,7 +97,7 @@ export function renderTimeseries(ref: SessionRef, group: string): void { const g = resolveFieldGroup(group); if (!g) return; - const allFields = g.fields.join(","); + const allFields = parseSensorFields(g.fields.join(",")); const json = get_timeseries(ref.fileId, ref.sessionIdx, TS_MAX_POINTS, allFields); const ts = JSON.parse(json); if (ts.error) { diff --git a/web/src/wasm.d.ts b/web/src/wasm.d.ts index 72486f6..76784b8 100644 --- a/web/src/wasm.d.ts +++ b/web/src/wasm.d.ts @@ -1,11 +1,39 @@ declare module "../pkg/propwash_web.js" { + export type Axis = "Roll" | "Pitch" | "Yaw"; + export type RcChannel = "Roll" | "Pitch" | "Yaw" | "Throttle"; + export type MotorIndex = number; + + export type SensorField = + | "Time" + | "Vbat" + | "Altitude" + | "GpsSpeed" + | "GpsLat" + | "GpsLng" + | "Heading" + | "Rssi" + | { Gyro: Axis } + | { GyroUnfilt: Axis } + | { Setpoint: Axis } + | { Accel: Axis } + | { PidP: Axis } + | { PidI: Axis } + | { PidD: Axis } + | { Feedforward: Axis } + | { Motor: MotorIndex } + | { ERpm: MotorIndex } + | { Rc: RcChannel } + | { Unknown: string }; + + export type SensorFields = SensorField[]; + export default function init(options?: { module_or_path: string }): Promise; export function add_file(data: Uint8Array, filename: string): string; export function clear_workspace(): void; - export function get_timeseries(file_id: number, session_idx: number, max_points: number, field_list: string): string; - export function get_spectrogram(file_id: number, session_idx: number, axis_list: string): string; + export function get_timeseries(file_id: number, session_idx: number, max_points: number, field_list: SensorFields): string; + export function get_spectrogram(file_id: number, session_idx: number, axis_list: SensorFields): string; export function get_filter_config(file_id: number, session_idx: number): string; - export function get_raw_frames(file_id: number, session_idx: number, start: number, count: number, field_list: string): string; + export function get_raw_frames(file_id: number, session_idx: number, start: number, count: number, field_list: SensorFields): string; export function get_trend(): string; export function get_step_overlay(file_id: number, session_idx: number): string; }