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
84 changes: 76 additions & 8 deletions src/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,61 @@ use std::path::Path;
use serde::{Deserialize, Serialize};

/// Export options for various output formats
///
/// Controls which export formats are generated and where files are written.
///
/// # Fields
/// - `csv`: Export flight data to CSV format (requires `csv` feature)
/// - `gpx`: Export GPS coordinates to GPX format for mapping
/// - `event`: Export events to JSON format
/// - `output_dir`: Optional custom output directory (defaults to input file's parent directory)
/// - `force_export`: Skip all filtering heuristics and always export
///
/// # Examples
/// ```rust
/// use bbl_parser::ExportOptions;
///
/// // Export everything to CSV with default location
/// let opts = ExportOptions {
/// csv: true,
/// gpx: false,
/// event: false,
/// output_dir: None,
/// force_export: false,
/// };
/// ```
#[derive(Debug, Clone, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct ExportOptions {
/// Enable CSV export of flight data
pub csv: bool,
/// Enable GPX export of GPS coordinates
pub gpx: bool,
/// Enable JSON export of flight events
pub event: bool,
/// Optional custom output directory (defaults to input file parent)
pub output_dir: Option<String>,
/// If true, export all logs without applying filtering heuristics
pub force_export: bool,
}

/// Result of an export operation, containing paths of all files that were created.
///
/// Any path that is `None` indicates that export format was not requested or
/// no data was available for export (e.g., empty GPS coordinates for GPX export).
#[derive(Debug, Clone, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct ExportReport {
/// Path to the main CSV data file (None if CSV export was not performed)
pub csv_path: Option<std::path::PathBuf>,
/// Path to the CSV headers file (None if CSV export was not performed)
pub headers_path: Option<std::path::PathBuf>,
/// Path to the GPX file (None if GPX export was not performed or GPS data was empty)
pub gpx_path: Option<std::path::PathBuf>,
/// Path to the event JSON file (None if event export was not performed or no events were found)
pub event_path: Option<std::path::PathBuf>,
}

/// Extract the base filename from an input path with consistent fallback.
/// Used by all export functions and path computation helpers to ensure
/// consistent naming across CSV, GPX, and event exports.
Expand Down Expand Up @@ -139,11 +184,15 @@ impl CsvFieldMap {
}

/// Export BBL log to CSV format
///
/// # Returns
/// An `ExportReport` containing paths to the CSV and headers files that were created,
/// or an error if the export failed.
pub fn export_to_csv(
log: &BBLLog,
input_path: &Path,
export_options: &ExportOptions,
) -> Result<()> {
) -> Result<ExportReport> {
let base_name = extract_base_name(input_path);

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

Ok(())
Ok(ExportReport {
csv_path: Some(flight_csv_path),
headers_path: Some(header_csv_path),
gpx_path: None,
event_path: None,
})
}

/// Export headers to CSV file
Expand Down Expand Up @@ -368,9 +422,9 @@ pub fn export_to_gpx(
home_coordinates: &[GpsHomeCoordinate],
export_options: &ExportOptions,
log_start_datetime: Option<&str>,
) -> Result<()> {
) -> Result<ExportReport> {
if gps_coordinates.is_empty() {
return Ok(());
return Ok(ExportReport::default());
}

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

Ok(())
Ok(ExportReport {
csv_path: None,
headers_path: None,
gpx_path: Some(gpx_path),
event_path: None,
})
}

/// Export event data to file
///
/// # Returns
/// An `ExportReport` containing the path to the event file that was created,
/// or an error if the export failed. Returns `None` for `event_path` if no events were exported.
pub fn export_to_event(
input_path: &Path,
log_index: usize,
total_logs: usize,
event_frames: &[EventFrame],
export_options: &ExportOptions,
) -> Result<()> {
) -> Result<ExportReport> {
if event_frames.is_empty() {
return Ok(());
return Ok(ExportReport::default());
}

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

Ok(())
Ok(ExportReport {
csv_path: None,
headers_path: None,
gpx_path: None,
event_path: Some(event_path),
})
}

#[cfg(test)]
Expand Down
193 changes: 193 additions & 0 deletions src/filters.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
//! Export filtering heuristics for identifying logs worth exporting
//!
//! This module provides intelligent filtering functions to help identify flight logs
//! that should be skipped during export due to being ground tests, arm checks, or other
//! non-flight data.
//!
//! # Usage
//!
//! These filters are controlled via `ExportOptions`. CLI users get filtering enabled by
//! default for convenience, while library consumers can opt in/out as needed.

use crate::types::BBLLog;

/// Determines if a log should be skipped for export based on duration and frame count
///
/// Uses smart filtering: <5s always skip, 5-15s keep if good data density (>1500fps), >15s always keep
/// This helps eliminate ground tests, arm checks, and other non-flight activities.
///
/// # Arguments
/// * `log` - The BBL log to evaluate
/// * `force_export` - If true, never skips (overrides all heuristics)
///
/// # Returns
/// Tuple of (should_skip, reason_description)
pub fn should_skip_export(log: &BBLLog, force_export: bool) -> (bool, String) {
if force_export {
return (false, String::new()); // Never skip when forced
}

const VERY_SHORT_DURATION_MS: u64 = 5_000; // 5 seconds - always skip
const SHORT_DURATION_MS: u64 = 15_000; // 15 seconds - threshold for normal logs
const MIN_DATA_DENSITY_FPS: f64 = 1500.0; // Minimum fps for short logs
const FALLBACK_MIN_FRAMES: u32 = 7_500; // ~5 seconds at 1500 fps (fallback when no duration)

// Check if we have duration information
if log.stats.start_time_us > 0 && log.stats.end_time_us > log.stats.start_time_us {
let duration_us = log
.stats
.end_time_us
.saturating_sub(log.stats.start_time_us);
// Use floating-point duration to avoid precision loss and division by zero
let duration_s = duration_us as f64 / 1_000_000.0;

// Guard against division by zero or very small durations
if duration_s <= 0.0 {
return (true, "duration too small or invalid".to_string());
}

let duration_ms = duration_us / 1000;
let fps = log.stats.total_frames as f64 / duration_s;

// Very short logs: < 5 seconds → Always skip
if duration_ms < VERY_SHORT_DURATION_MS {
return (true, format!("too short ({:.1}s < 5.0s)", duration_s));
}

// Short logs: 5-15 seconds → Keep if sufficient data density (>1500 fps)
if duration_ms < SHORT_DURATION_MS {
if fps < MIN_DATA_DENSITY_FPS {
return (
true,
format!(
"insufficient data density ({:.0}fps < {:.0}fps for {:.1}s log)",
fps, MIN_DATA_DENSITY_FPS, duration_s
),
);
}
// Good data density, keep it
return (false, String::new());
}

// Normal logs: > 15 seconds → Check for minimal gyro activity (ground tests)
if duration_ms >= SHORT_DURATION_MS {
let (is_minimal_movement, max_variance) = has_minimal_gyro_activity(log);
if is_minimal_movement {
return (
true,
format!(
"minimal gyro activity ({:.1} variance) - likely ground test",
max_variance
),
);
}
}

return (false, String::new());
}

// No duration information available, fall back to frame count
// Skip if very low frame count (equivalent to <5s at minimum viable fps)
if log.stats.total_frames < FALLBACK_MIN_FRAMES {
return (
true,
format!(
"too few frames ({} < {}) and no duration info",
log.stats.total_frames, FALLBACK_MIN_FRAMES
),
);
}

// Sufficient frames without duration info, keep it
(false, String::new())
}

/// Analyzes gyro variance to detect ground tests vs actual flight
///
/// Returns true if the log appears to be a static ground test (minimal movement)
///
/// # Arguments
/// * `log` - The BBL log to analyze
///
/// # Returns
/// Tuple of (is_minimal_movement, max_variance_value)
pub fn has_minimal_gyro_activity(log: &BBLLog) -> (bool, f64) {
// Conservative thresholds to avoid false-skips
const MIN_SAMPLES_FOR_ANALYSIS: usize = 15; // Reduced for limited sample data
const VERY_LOW_GYRO_VARIANCE_THRESHOLD: f64 = 0.3; // More aggressive threshold for ground test detection

let mut gyro_x_values = Vec::new();
let mut gyro_y_values = Vec::new();
let mut gyro_z_values = Vec::new();

// First try to use debug_frames if available (contains more comprehensive data)
if let Some(debug_frames) = &log.debug_frames {
// Collect gyro data from I and P frames in debug_frames
for (frame_type, frames) in debug_frames {
if *frame_type == 'I' || *frame_type == 'P' {
for frame in frames {
if let Some(gyro_x) = frame.data.get("gyroADC[0]") {
if let Some(gyro_y) = frame.data.get("gyroADC[1]") {
if let Some(gyro_z) = frame.data.get("gyroADC[2]") {
gyro_x_values.push(*gyro_x as f64);
gyro_y_values.push(*gyro_y as f64);
gyro_z_values.push(*gyro_z as f64);
}
}
}
}
}
}
}

// Fallback to frames if debug_frames not available or insufficient data
if gyro_x_values.len() < MIN_SAMPLES_FOR_ANALYSIS {
for frame in &log.frames {
if let Some(gyro_x) = frame.data.get("gyroADC[0]") {
if let Some(gyro_y) = frame.data.get("gyroADC[1]") {
if let Some(gyro_z) = frame.data.get("gyroADC[2]") {
gyro_x_values.push(*gyro_x as f64);
gyro_y_values.push(*gyro_y as f64);
gyro_z_values.push(*gyro_z as f64);
}
}
}
}
}

// Need sufficient data points for reliable analysis
if gyro_x_values.len() < MIN_SAMPLES_FOR_ANALYSIS {
return (false, 0.0); // Not enough data, don't skip (conservative approach)
}

// Calculate variance for each axis
let variance_x = calculate_variance(&gyro_x_values);
let variance_y = calculate_variance(&gyro_y_values);
let variance_z = calculate_variance(&gyro_z_values);

// Use the maximum variance across all axes
let max_variance = variance_x.max(variance_y).max(variance_z);

// Very conservative: only skip if ALL axes show extremely low variance
let is_minimal = max_variance < VERY_LOW_GYRO_VARIANCE_THRESHOLD;

(is_minimal, max_variance)
}

/// Calculate variance of a dataset
///
/// # Arguments
/// * `values` - Slice of f64 values to compute variance for
///
/// # Returns
/// The variance of the dataset
pub fn calculate_variance(values: &[f64]) -> f64 {
if values.len() < 2 {
return 0.0;
}

let mean = values.iter().sum::<f64>() / values.len() as f64;
let variance = values.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / values.len() as f64;

variance
}
Loading