Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

69 changes: 30 additions & 39 deletions propwash-cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use std::process;
use std::str::FromStr;

use clap::{Parser, Subcommand, ValueEnum};
use propwash_core::analysis::episodes;
Expand Down Expand Up @@ -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: {}",
Expand Down Expand Up @@ -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<Vec<f64>> = resolved
.iter()
.map(|(_, field)| session.field(field))
.collect();
let columns: Vec<Vec<f64>> = selected.iter().map(|(_, f)| session.field(f)).collect();

let n_frames = session.frame_count();
let time_data = session.field(&SensorField::Time);
Expand All @@ -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,
Expand All @@ -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,
});
}
Expand Down
8 changes: 8 additions & 0 deletions propwash-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
19 changes: 7 additions & 12 deletions propwash-core/examples/bench.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use std::time::Instant;

use propwash_core::types::{Axis, SensorField};
use propwash_core::units::DegPerSec;

fn main() {
let args: Vec<String> = std::env::args().collect();
Expand Down Expand Up @@ -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::<DegPerSec, f64>(&s.gyro.values.roll));
std::hint::black_box(bytemuck::cast_slice::<DegPerSec, f64>(&s.gyro.values.pitch));
std::hint::black_box(bytemuck::cast_slice::<DegPerSec, f64>(&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");
}
Loading
Loading