Skip to content

Commit fb3f39c

Browse files
authored
feat: unify parsing, export, and filtering API (#34)
- Remove all parsing and filtering duplication from CLI - Move all general-purpose parsing and filtering to library (filters.rs, parser/main.rs) - Implement ExportReport return type for all export functions - Enhance documentation and public API in lib.rs - CLI now uses only library parsing, export, and filtering logic - All tests, clippy, and formatting pass; release build is clean - Fixes integer division bug in filtering heuristics (duration/fps)
1 parent be7d89a commit fb3f39c

5 files changed

Lines changed: 364 additions & 344 deletions

File tree

src/export.rs

Lines changed: 76 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,61 @@ use std::path::Path;
1515
use serde::{Deserialize, Serialize};
1616

1717
/// Export options for various output formats
18+
///
19+
/// Controls which export formats are generated and where files are written.
20+
///
21+
/// # Fields
22+
/// - `csv`: Export flight data to CSV format (requires `csv` feature)
23+
/// - `gpx`: Export GPS coordinates to GPX format for mapping
24+
/// - `event`: Export events to JSON format
25+
/// - `output_dir`: Optional custom output directory (defaults to input file's parent directory)
26+
/// - `force_export`: Skip all filtering heuristics and always export
27+
///
28+
/// # Examples
29+
/// ```rust
30+
/// use bbl_parser::ExportOptions;
31+
///
32+
/// // Export everything to CSV with default location
33+
/// let opts = ExportOptions {
34+
/// csv: true,
35+
/// gpx: false,
36+
/// event: false,
37+
/// output_dir: None,
38+
/// force_export: false,
39+
/// };
40+
/// ```
1841
#[derive(Debug, Clone, Default)]
1942
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
2043
pub struct ExportOptions {
44+
/// Enable CSV export of flight data
2145
pub csv: bool,
46+
/// Enable GPX export of GPS coordinates
2247
pub gpx: bool,
48+
/// Enable JSON export of flight events
2349
pub event: bool,
50+
/// Optional custom output directory (defaults to input file parent)
2451
pub output_dir: Option<String>,
52+
/// If true, export all logs without applying filtering heuristics
2553
pub force_export: bool,
2654
}
2755

56+
/// Result of an export operation, containing paths of all files that were created.
57+
///
58+
/// Any path that is `None` indicates that export format was not requested or
59+
/// no data was available for export (e.g., empty GPS coordinates for GPX export).
60+
#[derive(Debug, Clone, Default)]
61+
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
62+
pub struct ExportReport {
63+
/// Path to the main CSV data file (None if CSV export was not performed)
64+
pub csv_path: Option<std::path::PathBuf>,
65+
/// Path to the CSV headers file (None if CSV export was not performed)
66+
pub headers_path: Option<std::path::PathBuf>,
67+
/// Path to the GPX file (None if GPX export was not performed or GPS data was empty)
68+
pub gpx_path: Option<std::path::PathBuf>,
69+
/// Path to the event JSON file (None if event export was not performed or no events were found)
70+
pub event_path: Option<std::path::PathBuf>,
71+
}
72+
2873
/// Extract the base filename from an input path with consistent fallback.
2974
/// Used by all export functions and path computation helpers to ensure
3075
/// consistent naming across CSV, GPX, and event exports.
@@ -139,11 +184,15 @@ impl CsvFieldMap {
139184
}
140185

141186
/// Export BBL log to CSV format
187+
///
188+
/// # Returns
189+
/// An `ExportReport` containing paths to the CSV and headers files that were created,
190+
/// or an error if the export failed.
142191
pub fn export_to_csv(
143192
log: &BBLLog,
144193
input_path: &Path,
145194
export_options: &ExportOptions,
146-
) -> Result<()> {
195+
) -> Result<ExportReport> {
147196
let base_name = extract_base_name(input_path);
148197

149198
let output_dir = if let Some(ref dir) = export_options.output_dir {
@@ -171,7 +220,12 @@ pub fn export_to_csv(
171220
let flight_csv_path = output_dir.join(format!("{base_name}{log_suffix}.csv"));
172221
export_flight_data_to_csv(log, &flight_csv_path)?;
173222

174-
Ok(())
223+
Ok(ExportReport {
224+
csv_path: Some(flight_csv_path),
225+
headers_path: Some(header_csv_path),
226+
gpx_path: None,
227+
event_path: None,
228+
})
175229
}
176230

177231
/// Export headers to CSV file
@@ -368,9 +422,9 @@ pub fn export_to_gpx(
368422
home_coordinates: &[GpsHomeCoordinate],
369423
export_options: &ExportOptions,
370424
log_start_datetime: Option<&str>,
371-
) -> Result<()> {
425+
) -> Result<ExportReport> {
372426
if gps_coordinates.is_empty() {
373-
return Ok(());
427+
return Ok(ExportReport::default());
374428
}
375429

376430
// Use compute_export_paths to ensure consistent naming with CSV exports
@@ -432,19 +486,28 @@ pub fn export_to_gpx(
432486
writeln!(gpx_file, "</trkseg></trk>")?;
433487
writeln!(gpx_file, "</gpx>")?;
434488

435-
Ok(())
489+
Ok(ExportReport {
490+
csv_path: None,
491+
headers_path: None,
492+
gpx_path: Some(gpx_path),
493+
event_path: None,
494+
})
436495
}
437496

438497
/// Export event data to file
498+
///
499+
/// # Returns
500+
/// An `ExportReport` containing the path to the event file that was created,
501+
/// or an error if the export failed. Returns `None` for `event_path` if no events were exported.
439502
pub fn export_to_event(
440503
input_path: &Path,
441504
log_index: usize,
442505
total_logs: usize,
443506
event_frames: &[EventFrame],
444507
export_options: &ExportOptions,
445-
) -> Result<()> {
508+
) -> Result<ExportReport> {
446509
if event_frames.is_empty() {
447-
return Ok(());
510+
return Ok(ExportReport::default());
448511
}
449512

450513
// Use compute_export_paths to ensure consistent naming with CSV exports
@@ -470,7 +533,12 @@ pub fn export_to_event(
470533
)?;
471534
}
472535

473-
Ok(())
536+
Ok(ExportReport {
537+
csv_path: None,
538+
headers_path: None,
539+
gpx_path: None,
540+
event_path: Some(event_path),
541+
})
474542
}
475543

476544
#[cfg(test)]

src/filters.rs

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
//! Export filtering heuristics for identifying logs worth exporting
2+
//!
3+
//! This module provides intelligent filtering functions to help identify flight logs
4+
//! that should be skipped during export due to being ground tests, arm checks, or other
5+
//! non-flight data.
6+
//!
7+
//! # Usage
8+
//!
9+
//! These filters are controlled via `ExportOptions`. CLI users get filtering enabled by
10+
//! default for convenience, while library consumers can opt in/out as needed.
11+
12+
use crate::types::BBLLog;
13+
14+
/// Determines if a log should be skipped for export based on duration and frame count
15+
///
16+
/// Uses smart filtering: <5s always skip, 5-15s keep if good data density (>1500fps), >15s always keep
17+
/// This helps eliminate ground tests, arm checks, and other non-flight activities.
18+
///
19+
/// # Arguments
20+
/// * `log` - The BBL log to evaluate
21+
/// * `force_export` - If true, never skips (overrides all heuristics)
22+
///
23+
/// # Returns
24+
/// Tuple of (should_skip, reason_description)
25+
pub fn should_skip_export(log: &BBLLog, force_export: bool) -> (bool, String) {
26+
if force_export {
27+
return (false, String::new()); // Never skip when forced
28+
}
29+
30+
const VERY_SHORT_DURATION_MS: u64 = 5_000; // 5 seconds - always skip
31+
const SHORT_DURATION_MS: u64 = 15_000; // 15 seconds - threshold for normal logs
32+
const MIN_DATA_DENSITY_FPS: f64 = 1500.0; // Minimum fps for short logs
33+
const FALLBACK_MIN_FRAMES: u32 = 7_500; // ~5 seconds at 1500 fps (fallback when no duration)
34+
35+
// Check if we have duration information
36+
if log.stats.start_time_us > 0 && log.stats.end_time_us > log.stats.start_time_us {
37+
let duration_us = log
38+
.stats
39+
.end_time_us
40+
.saturating_sub(log.stats.start_time_us);
41+
// Use floating-point duration to avoid precision loss and division by zero
42+
let duration_s = duration_us as f64 / 1_000_000.0;
43+
44+
// Guard against division by zero or very small durations
45+
if duration_s <= 0.0 {
46+
return (true, "duration too small or invalid".to_string());
47+
}
48+
49+
let duration_ms = duration_us / 1000;
50+
let fps = log.stats.total_frames as f64 / duration_s;
51+
52+
// Very short logs: < 5 seconds → Always skip
53+
if duration_ms < VERY_SHORT_DURATION_MS {
54+
return (true, format!("too short ({:.1}s < 5.0s)", duration_s));
55+
}
56+
57+
// Short logs: 5-15 seconds → Keep if sufficient data density (>1500 fps)
58+
if duration_ms < SHORT_DURATION_MS {
59+
if fps < MIN_DATA_DENSITY_FPS {
60+
return (
61+
true,
62+
format!(
63+
"insufficient data density ({:.0}fps < {:.0}fps for {:.1}s log)",
64+
fps, MIN_DATA_DENSITY_FPS, duration_s
65+
),
66+
);
67+
}
68+
// Good data density, keep it
69+
return (false, String::new());
70+
}
71+
72+
// Normal logs: > 15 seconds → Check for minimal gyro activity (ground tests)
73+
if duration_ms >= SHORT_DURATION_MS {
74+
let (is_minimal_movement, max_variance) = has_minimal_gyro_activity(log);
75+
if is_minimal_movement {
76+
return (
77+
true,
78+
format!(
79+
"minimal gyro activity ({:.1} variance) - likely ground test",
80+
max_variance
81+
),
82+
);
83+
}
84+
}
85+
86+
return (false, String::new());
87+
}
88+
89+
// No duration information available, fall back to frame count
90+
// Skip if very low frame count (equivalent to <5s at minimum viable fps)
91+
if log.stats.total_frames < FALLBACK_MIN_FRAMES {
92+
return (
93+
true,
94+
format!(
95+
"too few frames ({} < {}) and no duration info",
96+
log.stats.total_frames, FALLBACK_MIN_FRAMES
97+
),
98+
);
99+
}
100+
101+
// Sufficient frames without duration info, keep it
102+
(false, String::new())
103+
}
104+
105+
/// Analyzes gyro variance to detect ground tests vs actual flight
106+
///
107+
/// Returns true if the log appears to be a static ground test (minimal movement)
108+
///
109+
/// # Arguments
110+
/// * `log` - The BBL log to analyze
111+
///
112+
/// # Returns
113+
/// Tuple of (is_minimal_movement, max_variance_value)
114+
pub fn has_minimal_gyro_activity(log: &BBLLog) -> (bool, f64) {
115+
// Conservative thresholds to avoid false-skips
116+
const MIN_SAMPLES_FOR_ANALYSIS: usize = 15; // Reduced for limited sample data
117+
const VERY_LOW_GYRO_VARIANCE_THRESHOLD: f64 = 0.3; // More aggressive threshold for ground test detection
118+
119+
let mut gyro_x_values = Vec::new();
120+
let mut gyro_y_values = Vec::new();
121+
let mut gyro_z_values = Vec::new();
122+
123+
// First try to use debug_frames if available (contains more comprehensive data)
124+
if let Some(debug_frames) = &log.debug_frames {
125+
// Collect gyro data from I and P frames in debug_frames
126+
for (frame_type, frames) in debug_frames {
127+
if *frame_type == 'I' || *frame_type == 'P' {
128+
for frame in frames {
129+
if let Some(gyro_x) = frame.data.get("gyroADC[0]") {
130+
if let Some(gyro_y) = frame.data.get("gyroADC[1]") {
131+
if let Some(gyro_z) = frame.data.get("gyroADC[2]") {
132+
gyro_x_values.push(*gyro_x as f64);
133+
gyro_y_values.push(*gyro_y as f64);
134+
gyro_z_values.push(*gyro_z as f64);
135+
}
136+
}
137+
}
138+
}
139+
}
140+
}
141+
}
142+
143+
// Fallback to frames if debug_frames not available or insufficient data
144+
if gyro_x_values.len() < MIN_SAMPLES_FOR_ANALYSIS {
145+
for frame in &log.frames {
146+
if let Some(gyro_x) = frame.data.get("gyroADC[0]") {
147+
if let Some(gyro_y) = frame.data.get("gyroADC[1]") {
148+
if let Some(gyro_z) = frame.data.get("gyroADC[2]") {
149+
gyro_x_values.push(*gyro_x as f64);
150+
gyro_y_values.push(*gyro_y as f64);
151+
gyro_z_values.push(*gyro_z as f64);
152+
}
153+
}
154+
}
155+
}
156+
}
157+
158+
// Need sufficient data points for reliable analysis
159+
if gyro_x_values.len() < MIN_SAMPLES_FOR_ANALYSIS {
160+
return (false, 0.0); // Not enough data, don't skip (conservative approach)
161+
}
162+
163+
// Calculate variance for each axis
164+
let variance_x = calculate_variance(&gyro_x_values);
165+
let variance_y = calculate_variance(&gyro_y_values);
166+
let variance_z = calculate_variance(&gyro_z_values);
167+
168+
// Use the maximum variance across all axes
169+
let max_variance = variance_x.max(variance_y).max(variance_z);
170+
171+
// Very conservative: only skip if ALL axes show extremely low variance
172+
let is_minimal = max_variance < VERY_LOW_GYRO_VARIANCE_THRESHOLD;
173+
174+
(is_minimal, max_variance)
175+
}
176+
177+
/// Calculate variance of a dataset
178+
///
179+
/// # Arguments
180+
/// * `values` - Slice of f64 values to compute variance for
181+
///
182+
/// # Returns
183+
/// The variance of the dataset
184+
pub fn calculate_variance(values: &[f64]) -> f64 {
185+
if values.len() < 2 {
186+
return 0.0;
187+
}
188+
189+
let mean = values.iter().sum::<f64>() / values.len() as f64;
190+
let variance = values.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / values.len() as f64;
191+
192+
variance
193+
}

0 commit comments

Comments
 (0)