From 9c1662fbc9e6d25d5f1659ce88df97a5b6f6c799 Mon Sep 17 00:00:00 2001 From: Leah Wilson Date: Fri, 1 May 2026 14:22:30 -0700 Subject: [PATCH 1/3] Tackle 4 of 6 post-merge TODOs: motor_poles, fold session events, field_names, dead_code audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #5 motor_poles: SessionMeta gets `motor_poles: Option`. BF build populates it from the `motor_poles` header; AP/PX4/MAVLink leave None. analysis::fft::analyze_vibration_unified reads `session.meta.motor_poles.unwrap_or(14)` instead of hard-coding 14, so real-world FFT eRPM→Hz conversion uses the right pole count for BF logs (HD camera builds with 7-pole motors, 6-pole quads, etc.). #3 fold session events: `analysis::analyze` now folds Session.events (format-native: ModeChange, Armed/Disarmed, LogMessage, Failsafe, GpsRescue, Crash, Custom) into FlightAnalysis.events as FirmwareMessage variants with synthetic levels. Severities map through cleanly; mode/armed/custom states get "info"; failsafe/crash get "critical"; gps rescue gets "warning". Frontend timeline now sees state changes alongside behavior-detected events without a shape change. Added a fold regression test. #1 field_names cleanup: was a stub listing only "time" + "gyro[*]" + extras keys. Now iterates every populated typed slot — gyro/accel/ setpoint/attitude per-axis, motors[i] / erpm[i] per channel, rc sticks + throttle, vbat / current / rssi, all GPS columns, plus extras. CLI `info` against btfl_002.bbl now lists 27 fields (was 14). Used by `propwash dump` field name resolution and the WASM raw-data tab. #6 dead_code audit on analysis/util.rs: stripped the stale TODO banner. Strategy enum and resample/resample_zoh shims kept as public API with allow(dead_code) on the subset not currently called by analysis (StepFill/Nearest variants, the TimeSeries- taking shims) so the public surface stays available for future callers without the lint flagging it. Documentation updated to explain why each is kept. Tests: 150 lib (+1 fold test) + 21 integration + 20 CLI + 22 web + 7 fft. All green; clippy clean; fmt clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- propwash-core/src/analysis/fft.rs | 8 +- propwash-core/src/analysis/mod.rs | 150 +++++++++++++++++++++- propwash-core/src/analysis/util.rs | 49 +++---- propwash-core/src/format/ap/build.rs | 1 + propwash-core/src/format/bf/build.rs | 8 ++ propwash-core/src/format/mavlink/build.rs | 1 + propwash-core/src/format/px4/build.rs | 1 + propwash-core/src/session.rs | 93 +++++++++++++- 8 files changed, 275 insertions(+), 36 deletions(-) diff --git a/propwash-core/src/analysis/fft.rs b/propwash-core/src/analysis/fft.rs index fdab951..c531347 100644 --- a/propwash-core/src/analysis/fft.rs +++ b/propwash-core/src/analysis/fft.rs @@ -288,9 +288,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(); 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..59cab9b 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, 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..b8ac225 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,17 +507,93 @@ 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. + /// Returns canonical names of all populated typed streams plus any + /// `extras` keys. Format-agnostic: a name appearing here means + /// [`Self::field`] will return non-empty data for it. + /// + /// Used by user-facing tools (CLI `info`, `dump`, WASM raw-data tab) + /// to enumerate available signals. 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 } From e677609bd2202863894c98f40f77d1f7cae11c4e Mon Sep 17 00:00:00 2001 From: Leah Wilson Date: Fri, 1 May 2026 14:32:09 -0700 Subject: [PATCH 2/3] TODO #2: SensorField/MotorIndex/RcChannel/Unit recede into crate-internal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the public stringly-typed Session::field(SensorField) bridge with field_by_name(&str), the recommended entry point for tools that take user-supplied field names at runtime (CLI dump, WASM raw-data tab, WASM spectrogram). field() stays around as crate-internal for the BF parser tables that key on SensorField, but is no longer part of the public API. Surface changes: propwash-core public API: - Session::field_by_name(&str) -> Vec (new) — recommended. - Session::field(&SensorField) (now pub(crate)) — internal only. - analysis::fft::compute_spectrogram now takes &[(&str, &str)] (axis label, canonical Session field name) instead of &[(&str, &SensorField)]. Routes through field_by_name internally. - types::SensorField, MotorIndex, RcChannel demoted to pub(crate). - types::Unit deleted (was unused — only referenced by the deleted SensorField::unit() method). - types::RcChannel::index() deleted (unused). Migrations: propwash-cli/src/main.rs: - info command: hardcoded ERpm/GyroUnfilt field() lookups replaced with typed checks (motors.esc.is_some() and an extras key). - dump command: drops the SensorField::parse + field(field) two-step in favor of field_by_name(name) + filter empty results. propwash-web/src/lib.rs: - get_dump and get_filter_config: field_by_name. - get_spectrogram: builds (label, canonical) string pairs for the new compute_spectrogram signature. propwash-core/tests/perf.rs + examples/bench.rs: switch to field_by_name with canonical field names ("gyro[roll]" etc.). Tests: 150 lib + 21 integration + 20 CLI + 22 web + 7 fft. All green; clippy clean; fmt clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- propwash-cli/src/main.rs | 50 ++++++--------- propwash-core/examples/bench.rs | 11 +--- propwash-core/src/analysis/fft.rs | 46 +++----------- propwash-core/src/session.rs | 24 ++++++-- propwash-core/src/types.rs | 99 +++--------------------------- propwash-core/tests/integration.rs | 16 ++--- propwash-core/tests/perf.rs | 11 +--- propwash-web/src/lib.rs | 40 ++++++------ 8 files changed, 82 insertions(+), 215 deletions(-) diff --git a/propwash-cli/src/main.rs b/propwash-cli/src/main.rs index 177bdea..8776584 100644 --- a/propwash-cli/src/main.rs +++ b/propwash-cli/src/main.rs @@ -2,7 +2,6 @@ use std::process; use clap::{Parser, Subcommand, ValueEnum}; use propwash_core::analysis::episodes; -use propwash_core::types::SensorField; use serde::Serialize; #[derive(Clone, Copy, Default, ValueEnum)] @@ -134,31 +133,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,8 +702,9 @@ 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 + // Filter by user-supplied prefixes; unresolvable names yield empty + // columns from `field_by_name` and are dropped naturally below. + let selected_fields: Vec<&str> = field_names .iter() .filter(|name| { if field_prefixes.is_empty() { @@ -723,17 +712,16 @@ fn cmd_dump( } field_prefixes.iter().any(|prefix| name.starts_with(prefix)) }) - .filter_map(|name| SensorField::parse(name).ok().map(|f| (name.as_str(), f))) + .map(String::as_str) .collect(); - let selected_fields: Vec<&str> = resolved.iter().map(|(name, _)| *name).collect(); - let columns: Vec> = resolved + let columns: Vec> = selected_fields .iter() - .map(|(_, field)| session.field(field)) + .map(|name| session.field_by_name(name)) .collect(); let n_frames = session.frame_count(); - let time_data = session.field(&SensorField::Time); + let time_data = session.field_by_name("time"); let frame_range_arg = if frame_start == 0 && frame_end.is_none() { None diff --git a/propwash-core/examples/bench.rs b/propwash-core/examples/bench.rs index 404d2b7..211bddb 100644 --- a/propwash-core/examples/bench.rs +++ b/propwash-core/examples/bench.rs @@ -1,7 +1,5 @@ use std::time::Instant; -use propwash_core::types::{Axis, SensorField}; - fn main() { let args: Vec = std::env::args().collect(); if args.len() < 2 { @@ -41,16 +39,11 @@ fn main() { // Benchmark field extraction let s = &log.sessions[0]; - let fields = [ - SensorField::Time, - SensorField::Gyro(Axis::Roll), - SensorField::Gyro(Axis::Pitch), - SensorField::Gyro(Axis::Yaw), - ]; + let fields = ["time", "gyro[roll]", "gyro[pitch]", "gyro[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.field_by_name(f)); } } let field_elapsed = start.elapsed(); diff --git a/propwash-core/src/analysis/fft.rs b/propwash-core/src/analysis/fft.rs index c531347..d372bd2 100644 --- a/propwash-core/src/analysis/fft.rs +++ b/propwash-core/src/analysis/fft.rs @@ -4,40 +4,11 @@ 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; + +// `field_as_f64` removed — `compute_spectrogram` now takes field names +// as `&str` and routes them through `Session::field_by_name`. #[derive(Debug, Clone, Serialize)] pub struct FrequencySpectrum { @@ -724,10 +695,7 @@ pub struct Spectrogram { /// 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. -pub fn compute_spectrogram( - session: &Session, - axes: &[(&str, &SensorField)], -) -> Option { +pub fn compute_spectrogram(session: &Session, axes: &[(&str, &str)]) -> Option { let sample_rate = session.sample_rate_hz(); if sample_rate <= 0.0 { return None; @@ -755,7 +723,7 @@ pub fn compute_spectrogram( let mut result_axes = Vec::new(); for &(axis_name, field) in axes { - let raw = field_as_f64(session, field); + let raw = session.field_by_name(field); if raw.len() < SPEC_WINDOW { continue; } diff --git a/propwash-core/src/session.rs b/propwash-core/src/session.rs index b8ac225..0272028 100644 --- a/propwash-core/src/session.rs +++ b/propwash-core/src/session.rs @@ -598,14 +598,26 @@ impl Session { names } - /// Bridge: legacy stringly-typed field accessor. + /// Look up a field by canonical name (e.g. `"gyro[roll]"`, + /// `"motor[0]"`, `"vbat"`) and return its values as a `Vec`. + /// Returns an empty `Vec` for names that don't resolve. /// - /// 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. + /// This is the recommended entry point for tools that take a + /// user-supplied field name at runtime (CLI `dump`, WASM raw-data + /// tab). Internal analysis code should use the typed accessors + /// (`session.gyro.values.roll`, etc.) directly. + pub fn field_by_name(&self, name: &str) -> Vec { + match SensorField::parse(name) { + Ok(f) => self.field(&f), + Err(_) => Vec::new(), + } + } + + /// Crate-internal: look up a field by typed `SensorField` enum. + /// External callers use [`Self::field_by_name`] (which routes + /// through `SensorField::parse` then this method). #[allow(clippy::too_many_lines)] // declarative variant-to-typed-field routing - pub fn field(&self, field: &SensorField) -> Vec { + pub(crate) fn field(&self, field: &SensorField) -> Vec { match field { SensorField::Time => self.gyro.time_us.iter().map(|&t| t.az::()).collect(), SensorField::Gyro(axis) => { diff --git a/propwash-core/src/types.rs b/propwash-core/src/types.rs index ebf0150..aa38c94 100644 --- a/propwash-core/src/types.rs +++ b/propwash-core/src/types.rs @@ -48,24 +48,13 @@ impl fmt::Display for Axis { /// RC channel: roll, pitch, yaw, or throttle. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum RcChannel { +pub(crate) enum RcChannel { Roll, Pitch, Yaw, 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 { @@ -79,7 +68,7 @@ impl fmt::Display for RcChannel { /// Typed motor index. Prevents mixing with axis indices or other ordinals. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct MotorIndex(pub usize); +pub(crate) struct MotorIndex(pub usize); impl fmt::Display for MotorIndex { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -89,54 +78,16 @@ 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. +/// Crate-internal: external consumers use [`Session::field_by_name`] +/// to look up fields by string name. Kept inside the crate because +/// the BF parser uses it as a typed key for field-position lookup +/// tables. /// /// Known fields get proper variants with typed indices. /// Unknown fields preserve the original header string. #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[non_exhaustive] -pub enum SensorField { +pub(crate) enum SensorField { Time, Gyro(Axis), Motor(MotorIndex), @@ -160,42 +111,6 @@ pub enum SensorField { } 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, - } - } - /// Parses a canonical field name into a `SensorField`. /// /// # Errors diff --git a/propwash-core/tests/integration.rs b/propwash-core/tests/integration.rs index 15d6621..1d1056f 100644 --- a/propwash-core/tests/integration.rs +++ b/propwash-core/tests/integration.rs @@ -204,16 +204,14 @@ 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"); let s = &sessions[0]; - let heading = s.field(&SensorField::Heading); + let heading = s.field_by_name("heading"); assert_eq!( heading.len(), s.attitude.values.yaw.len(), - "field(Heading) should source from attitude.yaw, not gps.heading" + "field_by_name(\"heading\") should source from attitude.yaw, not gps.heading" ); } @@ -325,14 +323,12 @@ 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, &str)> = vec![ + ("roll", "gyro[roll]"), + ("pitch", "gyro[pitch]"), + ("yaw", "gyro[yaw]"), ]; - let axis_refs: Vec<(&str, &SensorField)> = axes_input.iter().map(|(n, f)| (*n, f)).collect(); for fixture in [ "fc-blackbox/btfl_002.bbl", diff --git a/propwash-core/tests/perf.rs b/propwash-core/tests/perf.rs index af0c4df..42d9b86 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), - ]; + let fields = ["time", "gyro[roll]", "gyro[pitch]", "gyro[yaw]"]; for (name, fixture) in [ ("BF", "fc-blackbox/btfl_001.bbl"), @@ -163,7 +156,7 @@ fn perf_field_extraction_all_formats() { let start = Instant::now(); for _ in 0..100 { for f in &fields { - std::hint::black_box(session.field(f)); + std::hint::black_box(session.field_by_name(f)); } } let elapsed = start.elapsed(); diff --git a/propwash-web/src/lib.rs b/propwash-web/src/lib.rs index 9b5febf..7f99b69 100644 --- a/propwash-web/src/lib.rs +++ b/propwash-web/src/lib.rs @@ -5,7 +5,7 @@ use serde::Serialize; use wasm_bindgen::prelude::*; use propwash_core::analysis::{self, fft, trend, FlightAnalysis}; -use propwash_core::types::{Log, SensorField}; +use propwash_core::types::Log; // --------------------------------------------------------------------------- // Workspace state — supports multiple loaded files @@ -214,15 +214,15 @@ pub fn get_timeseries( let mut fields: HashMap> = HashMap::new(); for &name in &requested { - let Ok(field) = SensorField::parse(name) else { + let raw = session.field_by_name(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 time_raw = session.field_by_name("time"); let t0 = time_raw.first().copied().unwrap_or(0.0); let time_s: Vec = time_raw .iter() @@ -247,21 +247,24 @@ pub fn get_timeseries( #[wasm_bindgen] pub fn get_spectrogram(file_id: u32, session_idx: usize, axis_list: &str) -> String { with_session(file_id, session_idx, |session| { - let axes: Vec<(&str, SensorField)> = axis_list + // Frontend passes "roll"/"pitch"/"yaw" shorthand for gyro axes; + // expand to the canonical Session field names. + let resolved: Vec<(String, String)> = axis_list .split(',') - .filter_map(|a| { - let field_name = match a { - "roll" => "gyro[roll]", - "pitch" => "gyro[pitch]", - "yaw" => "gyro[yaw]", - other => other, + .map(|a| { + let canonical = match a { + "roll" => "gyro[roll]".to_string(), + "pitch" => "gyro[pitch]".to_string(), + "yaw" => "gyro[yaw]".to_string(), + other => other.to_string(), }; - SensorField::parse(field_name).ok().map(|f| (a, f)) + (a.to_string(), canonical) }) .collect(); - - let axis_refs: Vec<(&str, &SensorField)> = - axes.iter().map(|(name, field)| (*name, field)).collect(); + let axis_refs: Vec<(&str, &str)> = resolved + .iter() + .map(|(label, canonical)| (label.as_str(), canonical.as_str())) + .collect(); let Some(spectrogram) = fft::compute_spectrogram(session, &axis_refs) else { return r#"{"error":"no spectrogram data"}"#.to_string(); @@ -296,11 +299,10 @@ pub fn get_raw_frames( let end = (start + count).min(total); - let resolved: Vec = requested + let columns: Vec> = requested .iter() - .filter_map(|&name| SensorField::parse(name).ok()) + .map(|&name| session.field_by_name(name)) .collect(); - let columns: Vec> = resolved.iter().map(|f| session.field(f)).collect(); let frames: Vec> = (start..end) .map(|frame_idx| { From 2963af781e1dd9543e9113e7c5b66bd730908d4b Mon Sep 17 00:00:00 2001 From: Leah Wilson Date: Mon, 4 May 2026 17:20:39 -0700 Subject: [PATCH 3/3] Replace field_by_name with typed SensorField across all consumers SensorField, MotorIndex, RcChannel are now public (with FromStr, Serialize, Deserialize, and feature-gated tsify-next derives). Session::field(&SensorField) is the sole lookup-by-handle method; field_by_name is gone. WASM bridge takes a typed SensorFields newtype that crosses the wasm-bindgen boundary as a TS discriminated union (SensorField[]), generated automatically by tsify-next. JS callsites construct typed field handles via a new parseSensorField helper that mirrors the Rust parser. CLI dump enumerates names via field_names(), prefix-filters, then parses each survivor into SensorField (extras keys / attitude fall back to Unknown, which yields an empty column). compute_spectrogram takes typed (label, &[f64]) axes; perf + bench tests measure the zero-cost cast_slice path with a 1ms ceiling. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 61 +++++++++++++++ propwash-cli/src/main.rs | 37 ++++----- propwash-core/Cargo.toml | 8 ++ propwash-core/examples/bench.rs | 14 ++-- propwash-core/src/analysis/fft.rs | 50 ++++++------- propwash-core/src/format/bf/build.rs | 4 +- propwash-core/src/session.rs | 53 +++++++------ propwash-core/src/types.rs | 61 +++++++++++---- propwash-core/tests/integration.rs | 19 ++--- propwash-core/tests/perf.rs | 21 +++--- propwash-web/Cargo.toml | 5 +- propwash-web/src/lib.rs | 78 ++++++++++--------- propwash-web/tests/bridge.rs | 37 ++++++--- web/src/sensor-field.ts | 107 +++++++++++++++++++++++++++ web/src/views/raw.ts | 4 +- web/src/views/spectrum.ts | 4 +- web/src/views/timeline.ts | 3 +- web/src/wasm.d.ts | 34 ++++++++- 18 files changed, 441 insertions(+), 159 deletions(-) create mode 100644 web/src/sensor-field.ts 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 8776584..c01dac1 100644 --- a/propwash-cli/src/main.rs +++ b/propwash-cli/src/main.rs @@ -1,7 +1,9 @@ use std::process; +use std::str::FromStr; use clap::{Parser, Subcommand, ValueEnum}; use propwash_core::analysis::episodes; +use propwash_core::types::SensorField; use serde::Serialize; #[derive(Clone, Copy, Default, ValueEnum)] @@ -702,26 +704,27 @@ fn cmd_dump( let field_names = session.field_names(); - // Filter by user-supplied prefixes; unresolvable names yield empty - // columns from `field_by_name` and are dropped naturally below. - let selected_fields: Vec<&str> = 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) }) - .map(String::as_str) .collect(); - let columns: Vec> = selected_fields - .iter() - .map(|name| session.field_by_name(name)) - .collect(); + let columns: Vec> = selected.iter().map(|(_, f)| session.field(f)).collect(); let n_frames = session.frame_count(); - let time_data = session.field_by_name("time"); + let time_data = session.field(&SensorField::Time); let frame_range_arg = if frame_start == 0 && frame_end.is_none() { None @@ -741,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, @@ -761,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 211bddb..c0692ac 100644 --- a/propwash-core/examples/bench.rs +++ b/propwash-core/examples/bench.rs @@ -1,5 +1,7 @@ use std::time::Instant; +use propwash_core::units::DegPerSec; + fn main() { let args: Vec = std::env::args().collect(); if args.len() < 2 { @@ -37,16 +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 = ["time", "gyro[roll]", "gyro[pitch]", "gyro[yaw]"]; let start = Instant::now(); for _ in 0..10 { - for f in &fields { - std::hint::black_box(s.field_by_name(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 d372bd2..1ffb99a 100644 --- a/propwash-core/src/analysis/fft.rs +++ b/propwash-core/src/analysis/fft.rs @@ -7,9 +7,6 @@ use super::events::{EventKind, FlightEvent}; use crate::types::{Axis, Session}; use crate::units::DegPerSec; -// `field_as_f64` removed — `compute_spectrogram` now takes field names -// as `&str` and routes them through `Session::field_by_name`. - #[derive(Debug, Clone, Serialize)] pub struct FrequencySpectrum { pub axis: Axis, @@ -685,34 +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. /// -/// **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. -pub fn compute_spectrogram(session: &Session, axes: &[(&str, &str)]) -> Option { - let sample_rate = session.sample_rate_hz(); - if sample_rate <= 0.0 { +/// Returns `None` if `sample_rate_hz` is non-positive or no axis +/// produced enough samples for one window. +/// +/// **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( + sample_rate_hz: f64, + time_us_axis: &[u64], + axes: &[(&str, &[f64])], +) -> Option { + 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); @@ -722,8 +723,7 @@ pub fn compute_spectrogram(session: &Session, axes: &[(&str, &str)]) -> Option Option (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/session.rs b/propwash-core/src/session.rs index 0272028..59971eb 100644 --- a/propwash-core/src/session.rs +++ b/propwash-core/src/session.rs @@ -507,12 +507,17 @@ impl Session { (0.0, 1.0) } - /// Returns canonical names of all populated typed streams plus any - /// `extras` keys. Format-agnostic: a name appearing here means - /// [`Self::field`] will return non-empty data for it. + /// 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`]). /// - /// Used by user-facing tools (CLI `info`, `dump`, WASM raw-data tab) - /// to enumerate available signals. + /// 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(); // Time is the implicit anchor for any non-empty triaxial stream. @@ -598,26 +603,28 @@ impl Session { names } - /// Look up a field by canonical name (e.g. `"gyro[roll]"`, - /// `"motor[0]"`, `"vbat"`) and return its values as a `Vec`. - /// Returns an empty `Vec` for names that don't resolve. + /// 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). /// - /// This is the recommended entry point for tools that take a - /// user-supplied field name at runtime (CLI `dump`, WASM raw-data - /// tab). Internal analysis code should use the typed accessors - /// (`session.gyro.values.roll`, etc.) directly. - pub fn field_by_name(&self, name: &str) -> Vec { - match SensorField::parse(name) { - Ok(f) => self.field(&f), - Err(_) => Vec::new(), - } - } - - /// Crate-internal: look up a field by typed `SensorField` enum. - /// External callers use [`Self::field_by_name`] (which routes - /// through `SensorField::parse` then this method). + /// **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; + /// ``` + /// + /// 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(crate) fn field(&self, field: &SensorField) -> Vec { + pub fn field(&self, field: &SensorField) -> Vec { match field { SensorField::Time => self.gyro.time_us.iter().map(|&t| t.az::()).collect(), SensorField::Gyro(axis) => { diff --git a/propwash-core/src/types.rs b/propwash-core/src/types.rs index aa38c94..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,8 +52,10 @@ impl fmt::Display for Axis { } /// RC channel: roll, pitch, yaw, or throttle. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub(crate) enum RcChannel { +#[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, Yaw, @@ -67,8 +74,13 @@ impl fmt::Display for RcChannel { } /// Typed motor index. Prevents mixing with axis indices or other ordinals. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub(crate) struct MotorIndex(pub usize); +/// +/// 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 { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -78,16 +90,24 @@ impl fmt::Display for MotorIndex { /// Format-agnostic sensor field identifier. /// -/// Crate-internal: external consumers use [`Session::field_by_name`] -/// to look up fields by string name. Kept inside the crate because -/// the BF parser uses it as a typed key for field-position lookup -/// tables. +/// 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(crate) enum SensorField { +pub enum SensorField { Time, Gyro(Axis), Motor(MotorIndex), @@ -110,8 +130,19 @@ pub(crate) enum SensorField { Unknown(String), } +impl FromStr for SensorField { + type Err = String; + + fn from_str(s: &str) -> Result { + Self::parse(s) + } +} + impl SensorField { - /// Parses a canonical field name into a `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 1d1056f..905c2db 100644 --- a/propwash-core/tests/integration.rs +++ b/propwash-core/tests/integration.rs @@ -205,13 +205,14 @@ fn ap_heading_uses_airframe_attitude_not_gps_cog() { #[test] fn field_heading_prefers_attitude_over_gps_cog() { + use propwash_core::types::SensorField; let sessions = decode("ardupilot/dronekit-copter-log171.bin"); let s = &sessions[0]; - let heading = s.field_by_name("heading"); + let heading = s.field(&SensorField::Heading); assert_eq!( heading.len(), s.attitude.values.yaw.len(), - "field_by_name(\"heading\") should source from attitude.yaw, not gps.heading" + "field(Heading) should source from attitude.yaw, not gps.heading" ); } @@ -323,12 +324,7 @@ fn mavlink_unit_sanity() { #[test] fn spectrogram_smoke_each_format() { use propwash_core::analysis::fft::compute_spectrogram; - - let axis_refs: Vec<(&str, &str)> = vec![ - ("roll", "gyro[roll]"), - ("pitch", "gyro[pitch]"), - ("yaw", "gyro[yaw]"), - ]; + use propwash_core::units::DegPerSec; for fixture in [ "fc-blackbox/btfl_002.bbl", @@ -337,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 42d9b86..b82bd37 100644 --- a/propwash-core/tests/perf.rs +++ b/propwash-core/tests/perf.rs @@ -140,7 +140,7 @@ fn perf_mavlink_dronekit() { #[test] #[ignore] fn perf_field_extraction_all_formats() { - let fields = ["time", "gyro[roll]", "gyro[pitch]", "gyro[yaw]"]; + use propwash_core::units::DegPerSec; for (name, fixture) in [ ("BF", "fc-blackbox/btfl_001.bbl"), @@ -151,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_by_name(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 7f99b69..44780fc 100644 --- a/propwash-web/src/lib.rs +++ b/propwash-web/src/lib.rs @@ -1,11 +1,21 @@ 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; +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,11 +221,10 @@ pub fn get_timeseries( 1 }; - let requested: Vec<&str> = field_list.split(',').collect(); let mut fields: HashMap> = HashMap::new(); - for &name in &requested { - let raw = session.field_by_name(name); + for name in &requested { + let raw = session.field(name); if raw.is_empty() { continue; } @@ -222,12 +232,19 @@ pub fn get_timeseries( fields.insert(name.to_string(), decimated); } - let time_raw = session.field_by_name("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,30 +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| { - // Frontend passes "roll"/"pitch"/"yaw" shorthand for gyro axes; - // expand to the canonical Session field names. - let resolved: Vec<(String, String)> = axis_list - .split(',') - .map(|a| { - let canonical = match a { - "roll" => "gyro[roll]".to_string(), - "pitch" => "gyro[pitch]".to_string(), - "yaw" => "gyro[yaw]".to_string(), - other => other.to_string(), - }; - (a.to_string(), canonical) - }) + let owned: Vec<(String, Vec)> = requested + .iter() + .map(|f| (f.to_string(), session.field(f))) .collect(); - let axis_refs: Vec<(&str, &str)> = resolved + let axes: Vec<(&str, &[f64])> = owned .iter() - .map(|(label, canonical)| (label.as_str(), canonical.as_str())) + .map(|(label, samples)| (label.as_str(), samples.as_slice())) .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(); }; @@ -291,18 +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 columns: Vec> = requested - .iter() - .map(|&name| session.field_by_name(name)) - .collect(); + let columns: Vec> = requested.iter().map(|f| session.field(f)).collect(); let frames: Vec> = (start..end) .map(|frame_idx| { @@ -314,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; }