diff --git a/.claude/settings.json b/.claude/settings.json index 0d6ce43..99b5560 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -16,7 +16,19 @@ "Bash(wc:*)", "Bash(grep:*)", "Bash(ls:*)", - "Bash(mkdir:*)" + "Bash(mkdir:*)", + "Bash(git config:*)", + "Bash(/Users/colegentry/Development/UltraLog/.githooks/pre-commit)", + "Bash(./scripts/bump-version.sh:*)", + "Bash(mkdir:*)", + "Bash(xxd:*)", + "Bash(cat:*)", + "Bash(cargo run:*)", + "Bash(cargo doc:*)", + "Bash(cargo check:*)", + "Bash(while read line)", + "Bash(do echo \"=== $line ===\")", + "Bash(done)" ] } } \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index f67f6a8..d47193a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3605,7 +3605,7 @@ dependencies = [ [[package]] name = "ultralog" -version = "1.7.2" +version = "2.0.0" dependencies = [ "anyhow", "dirs", diff --git a/Cargo.toml b/Cargo.toml index 168b82e..dcb23c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ultralog" -version = "1.7.2" +version = "2.0.0" edition = "2021" description = "A high-performance ECU log viewer written in Rust" authors = ["Cole Gentry"] diff --git a/README.md b/README.md index 8eae3ef..5c594c4 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A high-performance, cross-platform ECU log viewer written in Rust. ![CI](https://github.com/SomethingNew71/UltraLog/actions/workflows/ci.yml/badge.svg) ![License](https://img.shields.io/badge/license-AGPL--3.0-blue.svg) -![Version](https://img.shields.io/badge/version-1.7.2-green.svg) +![Version](https://img.shields.io/badge/version-2.0.0-green.svg) --- @@ -131,11 +131,18 @@ Configurable units for 8 measurement categories: ### Additional Tools - **Scatter Plot** - XY scatter visualization for channel correlation analysis +- **Histogram** - 2D heatmap visualization with configurable grid sizes (10x10 to 25x25) for analyzing channel distributions +- **Analysis Tools** - Built-in signal processing and statistics: + - **Filters** - Moving average, Kalman filter, and other signal processing tools + - **Statistics** - Min/max, percentiles, standard deviation calculations + - **AFR Analysis** - Air-Fuel Ratio analysis with target comparison + - **Derived Channels** - Calculated channels from existing data - **Normalization Editor** - Create custom field name mappings for cross-ECU comparison - **Field Normalization** - Maps ECU-specific channel names to standard names (e.g., "Act_AFR" → "AFR") ### Accessibility - **Colorblind mode** - Wong's optimized color palette designed for deuteranopia, protanopia, and tritanopia +- **Font scaling** - Four font sizes (S, M, L, XL) for improved readability - **Custom font** - Clear, readable Outfit typeface - **Toast notifications** - Non-intrusive feedback for user actions @@ -170,10 +177,19 @@ Configurable units for 8 measurement categories: - **Supported data:** All logged channels with lap times, GPS data, and metadata ### Link ECU - Full Support + - **File type:** Link log format (`.llg`) - **Features:** Binary format parser for Link G4/G4+/G4X ECUs - **Supported data:** All ECU parameters including RPM, MAP, AFR, ignition timing, temperatures, and custom channels +### Emerald ECU - Full Support + +- **File type:** Binary format (`.lg1` data + `.lg2` channel definitions) +- **Features:** Native binary format parser for Emerald K6/M3D ECUs +- **Supported devices:** Emerald K6, M3D, and compatible ECU models +- **Supported data:** TPS, Air Temp, MAP, Lambda, Oil/Fuel Pressure, Oil/Fuel Temp, Exhaust Temp, Boost Target/Duty, RPM, Coolant Temp, Battery Voltage, Ignition Advance, Injector Pulse Width, and more +- **Note:** Both `.lg1` (data) and `.lg2` (channel definitions) files must be in the same directory + ### Coming Soon - MegaSquirt - AEM @@ -286,15 +302,28 @@ cargo build --release ## User Guide +### Interface Overview + +UltraLog v2.0 features a VS Code-style interface with an activity bar on the far left: + +**Activity Bar Panels:** +- **Files** (folder icon) - File list, drop zone, loaded files management +- **Channels** (list icon) - Channel selection, search, selected channel cards +- **Tools** (wrench icon) - Access to Scatter Plot, Histogram, and Analysis Tools +- **Settings** (gear icon) - All application settings in one place (units, display options, accessibility) + ### Loading Log Files -**Supported file extensions:** `.csv`, `.log`, `.txt`, `.mlg` +**Supported file extensions:** `.csv`, `.log`, `.txt`, `.mlg`, `.xrk`, `.drk`, `.llg`, `.lg1/.lg2` UltraLog automatically detects the ECU format based on file contents: - **Haltech:** Identified by `%DataLog%` header - **ECUMaster:** Identified by semicolon/tab-delimited CSV with `TIME` column - **RomRaider:** Identified by comma-delimited CSV starting with `Time` column - **Speeduino/rusEFI:** Identified by `MLVLG` binary header +- **AiM:** Identified by `Free and open source — no subscriptions, no licenses, just download and go.

- v1.7.2 + v2.0.0 Open Source diff --git a/exampleLogs/emerald/EM Log MG ZS Turbo idle and rev.lg1 b/exampleLogs/emerald/EM Log MG ZS Turbo idle and rev.lg1 new file mode 100644 index 0000000..4878549 Binary files /dev/null and b/exampleLogs/emerald/EM Log MG ZS Turbo idle and rev.lg1 differ diff --git a/exampleLogs/emerald/EM Log MG ZS Turbo idle and rev.lg2 b/exampleLogs/emerald/EM Log MG ZS Turbo idle and rev.lg2 new file mode 100644 index 0000000..44806a3 --- /dev/null +++ b/exampleLogs/emerald/EM Log MG ZS Turbo idle and rev.lg2 @@ -0,0 +1,22 @@ +[chan1] +19 +[chan2] +46 +[chan3] +2 +[chan4] +20 +[chan5] +1 +[chan6] +31 +[chan7] +32 +[chan8] +17 +[ValU] +0 +2 +0 +0 +0 diff --git a/exampleLogs/emerald/EM Log MG ZS Turbo short drive back diff channels.lg1 b/exampleLogs/emerald/EM Log MG ZS Turbo short drive back diff channels.lg1 new file mode 100644 index 0000000..5563241 Binary files /dev/null and b/exampleLogs/emerald/EM Log MG ZS Turbo short drive back diff channels.lg1 differ diff --git a/exampleLogs/emerald/EM Log MG ZS Turbo short drive back diff channels.lg2 b/exampleLogs/emerald/EM Log MG ZS Turbo short drive back diff channels.lg2 new file mode 100644 index 0000000..29003b3 --- /dev/null +++ b/exampleLogs/emerald/EM Log MG ZS Turbo short drive back diff channels.lg2 @@ -0,0 +1,22 @@ +[chan1] +19 +[chan2] +47 +[chan3] +12 +[chan4] +20 +[chan5] +1 +[chan6] +29 +[chan7] +32 +[chan8] +24 +[ValU] +0 +2 +0 +0 +0 diff --git a/exampleLogs/emerald/EM Log MG ZS Turbo short drive.lg1 b/exampleLogs/emerald/EM Log MG ZS Turbo short drive.lg1 new file mode 100644 index 0000000..76263b8 Binary files /dev/null and b/exampleLogs/emerald/EM Log MG ZS Turbo short drive.lg1 differ diff --git a/exampleLogs/emerald/EM Log MG ZS Turbo short drive.lg2 b/exampleLogs/emerald/EM Log MG ZS Turbo short drive.lg2 new file mode 100644 index 0000000..490d4a4 --- /dev/null +++ b/exampleLogs/emerald/EM Log MG ZS Turbo short drive.lg2 @@ -0,0 +1,22 @@ +[chan1] +19 +[chan2] +45 +[chan3] +2 +[chan4] +20 +[chan5] +1 +[chan6] +31 +[chan7] +32 +[chan8] +17 +[ValU] +0 +2 +0 +0 +0 diff --git a/src/analysis/afr.rs b/src/analysis/afr.rs new file mode 100644 index 0000000..15a9bc7 --- /dev/null +++ b/src/analysis/afr.rs @@ -0,0 +1,823 @@ +//! Air-Fuel Ratio analysis algorithms. +//! +//! Provides AFR-related analysis including fuel trim drift detection (CUSUM), +//! rich/lean zone detection, and AFR deviation analysis. +//! +//! Supports both AFR and Lambda units with automatic detection. + +use super::*; +use std::collections::HashMap; + +// ============================================================================ +// Lambda/AFR unit detection and conversion +// ============================================================================ + +/// Stoichiometric AFR for gasoline (14.7:1) +pub const STOICH_AFR_GASOLINE: f64 = 14.7; + +/// Stoichiometric Lambda (always 1.0 by definition) +pub const STOICH_LAMBDA: f64 = 1.0; + +/// Detected fuel mixture unit type +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum FuelMixtureUnit { + /// Air-Fuel Ratio (typical range 10-20 for gasoline) + Afr, + /// Lambda (typical range 0.7-1.3, stoich = 1.0) + Lambda, +} + +impl FuelMixtureUnit { + /// Get the stoichiometric value for this unit type + pub fn stoichiometric(&self) -> f64 { + match self { + FuelMixtureUnit::Afr => STOICH_AFR_GASOLINE, + FuelMixtureUnit::Lambda => STOICH_LAMBDA, + } + } + + /// Get appropriate rich/lean thresholds for this unit type + pub fn default_thresholds(&self) -> (f64, f64) { + match self { + // AFR: ±0.5 from 14.7 (roughly ±3.4%) + FuelMixtureUnit::Afr => (0.5, 0.5), + // Lambda: ±0.03 from 1.0 (roughly ±3%) + FuelMixtureUnit::Lambda => (0.03, 0.03), + } + } + + /// Convert a value to AFR (for consistent analysis) + pub fn to_afr(&self, value: f64) -> f64 { + match self { + FuelMixtureUnit::Afr => value, + FuelMixtureUnit::Lambda => value * STOICH_AFR_GASOLINE, + } + } + + /// Get unit name for display + pub fn unit_name(&self) -> &'static str { + match self { + FuelMixtureUnit::Afr => "AFR", + FuelMixtureUnit::Lambda => "λ", + } + } +} + +/// Detect whether data is in Lambda or AFR units based on value distribution +/// +/// Uses the median value to determine unit type: +/// - Lambda: typically 0.7-1.3 (median around 1.0) +/// - AFR: typically 10-20 (median around 14.7) +pub fn detect_fuel_mixture_unit(data: &[f64]) -> FuelMixtureUnit { + if data.is_empty() { + return FuelMixtureUnit::Afr; // Default assumption + } + + // Calculate median for robust detection (less affected by outliers) + let mut sorted = data.to_vec(); + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + #[allow(clippy::manual_is_multiple_of)] + let median = if sorted.len() % 2 == 0 { + (sorted[sorted.len() / 2 - 1] + sorted[sorted.len() / 2]) / 2.0 + } else { + sorted[sorted.len() / 2] + }; + + // Lambda values cluster around 1.0, AFR values cluster around 14.7 + // Use 3.0 as the threshold (well above Lambda range, well below AFR range) + if median < 3.0 { + FuelMixtureUnit::Lambda + } else { + FuelMixtureUnit::Afr + } +} + +/// Fuel Trim Drift Analyzer using CUSUM algorithm +/// +/// Detects gradual drift in long-term fuel trim that may indicate +/// injector degradation, air leaks, or sensor aging. +#[derive(Clone)] +pub struct FuelTrimDriftAnalyzer { + /// Channel to analyze (typically LTFT or STFT) + pub channel: String, + /// Allowable slack parameter k (typically 0.5σ) + /// Controls sensitivity to small shifts + pub k: f64, + /// Decision threshold h (typically 4-5σ) + /// Higher values = fewer false alarms, slower detection + pub h: f64, + /// Baseline calculation window (percentage of data from start) + pub baseline_pct: f64, +} + +impl Default for FuelTrimDriftAnalyzer { + fn default() -> Self { + Self { + channel: "LTFT".to_string(), + k: 2.5, // 0.5 * typical 5% σ + h: 20.0, // 4 * typical 5% σ + baseline_pct: 10.0, // Use first 10% for baseline + } + } +} + +impl Analyzer for FuelTrimDriftAnalyzer { + fn id(&self) -> &str { + "fuel_trim_drift" + } + + fn name(&self) -> &str { + "Fuel Trim Drift Detection" + } + + fn description(&self) -> &str { + "CUSUM algorithm detecting gradual long-term fuel trim drift indicating \ + injector degradation, air leaks, or sensor aging. Returns drift indicator: \ + +1 = rich drift, -1 = lean drift, 0 = normal." + } + + fn category(&self) -> &str { + "AFR" + } + + fn required_channels(&self) -> Vec<&str> { + vec![&self.channel] + } + + fn analyze(&self, log: &Log) -> Result { + let data = require_channel(log, &self.channel)?; + require_min_length(&data, 100)?; + + let (result_data, computation_time) = + timed_analyze(|| cusum_drift_detection(&data, self.k, self.h, self.baseline_pct)); + + // Count drift events for warnings + let high_drift_samples = result_data.drift_flags.iter().filter(|&&x| x > 0.5).count(); + let low_drift_samples = result_data + .drift_flags + .iter() + .filter(|&&x| x < -0.5) + .count(); + let total = data.len(); + + let mut warnings = vec![]; + + if high_drift_samples > total / 20 { + warnings.push(format!( + "Sustained positive drift detected ({:.1}% of samples) - running rich, check for over-fueling", + 100.0 * high_drift_samples as f64 / total as f64 + )); + } + if low_drift_samples > total / 20 { + warnings.push(format!( + "Sustained negative drift detected ({:.1}% of samples) - running lean, check for air leaks", + 100.0 * low_drift_samples as f64 / total as f64 + )); + } + + Ok(AnalysisResult { + name: format!("{} Drift", self.channel), + unit: "drift".to_string(), + values: result_data.drift_flags, + metadata: AnalysisMetadata { + algorithm: "CUSUM".to_string(), + parameters: vec![ + ("k".to_string(), format!("{:.2}", self.k)), + ("h".to_string(), format!("{:.2}", self.h)), + ( + "baseline_μ".to_string(), + format!("{:.2}%", result_data.baseline_mean), + ), + ( + "baseline_σ".to_string(), + format!("{:.2}%", result_data.baseline_stdev), + ), + ], + warnings, + computation_time_ms: computation_time, + }, + }) + } + + fn get_config(&self) -> AnalyzerConfig { + let mut params = HashMap::new(); + params.insert("channel".to_string(), self.channel.clone()); + params.insert("k".to_string(), self.k.to_string()); + params.insert("h".to_string(), self.h.to_string()); + params.insert("baseline_pct".to_string(), self.baseline_pct.to_string()); + + AnalyzerConfig { + id: self.id().to_string(), + name: self.name().to_string(), + parameters: params, + } + } + + fn set_config(&mut self, config: &AnalyzerConfig) { + if let Some(ch) = config.parameters.get("channel") { + self.channel = ch.clone(); + } + if let Some(v) = config.parameters.get("k") { + if let Ok(val) = v.parse() { + self.k = val; + } + } + if let Some(v) = config.parameters.get("h") { + if let Ok(val) = v.parse() { + self.h = val; + } + } + if let Some(v) = config.parameters.get("baseline_pct") { + if let Ok(val) = v.parse() { + self.baseline_pct = val; + } + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +/// Rich/Lean Zone Analyzer +/// +/// Detects periods where AFR/Lambda deviates significantly from target, +/// classifying into rich, lean, and stoichiometric zones. +/// Automatically detects whether data is in AFR or Lambda units. +#[derive(Clone)] +pub struct RichLeanZoneAnalyzer { + /// AFR/Lambda channel to analyze + pub channel: String, + /// Target value (default: auto-detect based on data) + /// Set to 0.0 for auto-detection + pub target: f64, + /// Rich threshold (below target - this value) + /// Set to 0.0 for auto-detection based on unit type + pub rich_threshold: f64, + /// Lean threshold (above target + this value) + /// Set to 0.0 for auto-detection based on unit type + pub lean_threshold: f64, +} + +impl Default for RichLeanZoneAnalyzer { + fn default() -> Self { + Self { + channel: "AFR".to_string(), + target: 0.0, // Auto-detect + rich_threshold: 0.0, // Auto-detect + lean_threshold: 0.0, // Auto-detect + } + } +} + +impl Analyzer for RichLeanZoneAnalyzer { + fn id(&self) -> &str { + "rich_lean_zone" + } + + fn name(&self) -> &str { + "Rich/Lean Zone Detection" + } + + fn description(&self) -> &str { + "Classifies AFR/Lambda readings into rich (-1), stoichiometric (0), and lean (+1) zones \ + based on deviation from target. Automatically detects AFR vs Lambda units." + } + + fn category(&self) -> &str { + "AFR" + } + + fn required_channels(&self) -> Vec<&str> { + vec![&self.channel] + } + + fn analyze(&self, log: &Log) -> Result { + let data = require_channel(log, &self.channel)?; + require_min_length(&data, 10)?; + + // Auto-detect unit type + let unit = detect_fuel_mixture_unit(&data); + + // Use configured values or auto-detect based on unit type + let target = if self.target > 0.0 { + self.target + } else { + unit.stoichiometric() + }; + + let (rich_thresh, lean_thresh) = if self.rich_threshold > 0.0 && self.lean_threshold > 0.0 { + (self.rich_threshold, self.lean_threshold) + } else { + unit.default_thresholds() + }; + + let rich_limit = target - rich_thresh; + let lean_limit = target + lean_thresh; + + let (zones, computation_time) = timed_analyze(|| { + data.iter() + .map(|&value| { + if value < rich_limit { + -1.0 // Rich + } else if value > lean_limit { + 1.0 // Lean + } else { + 0.0 // Stoichiometric + } + }) + .collect::>() + }); + + // Count time in each zone + let rich_count = zones.iter().filter(|&&z| z < -0.5).count(); + let lean_count = zones.iter().filter(|&&z| z > 0.5).count(); + let stoich_count = zones.iter().filter(|&&z| z.abs() < 0.5).count(); + let total = zones.len() as f64; + + let rich_pct = 100.0 * rich_count as f64 / total; + let lean_pct = 100.0 * lean_count as f64 / total; + let stoich_pct = 100.0 * stoich_count as f64 / total; + + let mut warnings = vec![]; + + if rich_pct > 30.0 { + warnings.push(format!( + "Excessive rich operation ({:.1}%) - may indicate over-fueling or cold conditions", + rich_pct + )); + } + if lean_pct > 30.0 { + warnings.push(format!( + "Excessive lean operation ({:.1}%) - check for air leaks or fuel delivery issues", + lean_pct + )); + } + + // Format limits based on detected unit + let precision = if unit == FuelMixtureUnit::Lambda { + 2 + } else { + 1 + }; + + Ok(AnalysisResult { + name: format!("{} Zone", self.channel), + unit: "zone".to_string(), + values: zones, + metadata: AnalysisMetadata { + algorithm: "Threshold Classification".to_string(), + parameters: vec![ + ("detected_unit".to_string(), unit.unit_name().to_string()), + ( + "target".to_string(), + format!("{:.prec$}", target, prec = precision), + ), + ( + "rich_limit".to_string(), + format!("{:.prec$}", rich_limit, prec = precision), + ), + ( + "lean_limit".to_string(), + format!("{:.prec$}", lean_limit, prec = precision), + ), + ("rich_pct".to_string(), format!("{:.1}%", rich_pct)), + ("stoich_pct".to_string(), format!("{:.1}%", stoich_pct)), + ("lean_pct".to_string(), format!("{:.1}%", lean_pct)), + ], + warnings, + computation_time_ms: computation_time, + }, + }) + } + + fn get_config(&self) -> AnalyzerConfig { + let mut params = HashMap::new(); + params.insert("channel".to_string(), self.channel.clone()); + params.insert("target".to_string(), self.target.to_string()); + params.insert( + "rich_threshold".to_string(), + self.rich_threshold.to_string(), + ); + params.insert( + "lean_threshold".to_string(), + self.lean_threshold.to_string(), + ); + + AnalyzerConfig { + id: self.id().to_string(), + name: self.name().to_string(), + parameters: params, + } + } + + fn set_config(&mut self, config: &AnalyzerConfig) { + if let Some(ch) = config.parameters.get("channel") { + self.channel = ch.clone(); + } + if let Some(v) = config.parameters.get("target") { + if let Ok(val) = v.parse() { + self.target = val; + } + } + // Support legacy "target_afr" parameter name + if let Some(v) = config.parameters.get("target_afr") { + if let Ok(val) = v.parse() { + self.target = val; + } + } + if let Some(v) = config.parameters.get("rich_threshold") { + if let Ok(val) = v.parse() { + self.rich_threshold = val; + } + } + if let Some(v) = config.parameters.get("lean_threshold") { + if let Ok(val) = v.parse() { + self.lean_threshold = val; + } + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +/// AFR/Lambda Deviation Analyzer +/// +/// Computes percentage deviation from target AFR/Lambda, useful for +/// fuel table correction calculations. +/// Automatically detects whether data is in AFR or Lambda units. +#[derive(Clone)] +pub struct AfrDeviationAnalyzer { + /// AFR/Lambda channel to analyze + pub channel: String, + /// Target value (default: auto-detect based on data) + /// Set to 0.0 for auto-detection + pub target: f64, +} + +impl Default for AfrDeviationAnalyzer { + fn default() -> Self { + Self { + channel: "AFR".to_string(), + target: 0.0, // Auto-detect + } + } +} + +impl Analyzer for AfrDeviationAnalyzer { + fn id(&self) -> &str { + "afr_deviation" + } + + fn name(&self) -> &str { + "AFR/Lambda Deviation %" + } + + fn description(&self) -> &str { + "Computes percentage deviation from target AFR/Lambda. Positive = lean, negative = rich. \ + Automatically detects AFR vs Lambda units. Useful for fuel table corrections." + } + + fn category(&self) -> &str { + "AFR" + } + + fn required_channels(&self) -> Vec<&str> { + vec![&self.channel] + } + + fn analyze(&self, log: &Log) -> Result { + let data = require_channel(log, &self.channel)?; + require_min_length(&data, 2)?; + + // Auto-detect unit type + let unit = detect_fuel_mixture_unit(&data); + + // Use configured target or auto-detect based on unit type + let target = if self.target > 0.0 { + self.target + } else { + unit.stoichiometric() + }; + + if target <= 0.0 { + return Err(AnalysisError::InvalidParameter( + "Target must be positive".to_string(), + )); + } + + let (deviations, computation_time) = timed_analyze(|| { + data.iter() + .map(|&value| ((value - target) / target) * 100.0) + .collect::>() + }); + + // Compute statistics on deviations + let stats = super::statistics::compute_descriptive_stats(&deviations); + + let mut warnings = vec![]; + + if stats.mean.abs() > 5.0 { + let direction = if stats.mean > 0.0 { "lean" } else { "rich" }; + warnings.push(format!( + "Significant average {} bias ({:.1}%) - consider fuel table adjustment", + direction, stats.mean + )); + } + + if stats.stdev > 10.0 { + warnings.push(format!( + "High {} variability (σ={:.1}%) - check sensor or tune stability", + unit.unit_name(), + stats.stdev + )); + } + + // Format target based on detected unit + let precision = if unit == FuelMixtureUnit::Lambda { + 2 + } else { + 1 + }; + + Ok(AnalysisResult { + name: format!("{} Deviation", self.channel), + unit: "%".to_string(), + values: deviations, + metadata: AnalysisMetadata { + algorithm: "Percentage Deviation".to_string(), + parameters: vec![ + ("detected_unit".to_string(), unit.unit_name().to_string()), + ( + "target".to_string(), + format!("{:.prec$}", target, prec = precision), + ), + ("mean_deviation".to_string(), format!("{:.2}%", stats.mean)), + ("stdev".to_string(), format!("{:.2}%", stats.stdev)), + ("max_deviation".to_string(), format!("{:.2}%", stats.max)), + ("min_deviation".to_string(), format!("{:.2}%", stats.min)), + ], + warnings, + computation_time_ms: computation_time, + }, + }) + } + + fn get_config(&self) -> AnalyzerConfig { + let mut params = HashMap::new(); + params.insert("channel".to_string(), self.channel.clone()); + params.insert("target".to_string(), self.target.to_string()); + + AnalyzerConfig { + id: self.id().to_string(), + name: self.name().to_string(), + parameters: params, + } + } + + fn set_config(&mut self, config: &AnalyzerConfig) { + if let Some(ch) = config.parameters.get("channel") { + self.channel = ch.clone(); + } + if let Some(v) = config.parameters.get("target") { + if let Ok(val) = v.parse() { + self.target = val; + } + } + // Support legacy "target_afr" parameter name + if let Some(v) = config.parameters.get("target_afr") { + if let Ok(val) = v.parse() { + self.target = val; + } + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +// ============================================================================ +// Core AFR algorithm implementations +// ============================================================================ + +/// Result of CUSUM drift detection +struct CusumResult { + drift_flags: Vec, + baseline_mean: f64, + baseline_stdev: f64, +} + +/// CUSUM (Cumulative Sum) drift detection algorithm +/// +/// Detects gradual shifts from baseline mean. +/// - k: allowable slack (sensitivity), typically 0.5σ +/// - h: decision threshold, typically 4-5σ +fn cusum_drift_detection(data: &[f64], k: f64, h: f64, baseline_pct: f64) -> CusumResult { + if data.is_empty() { + return CusumResult { + drift_flags: vec![], + baseline_mean: 0.0, + baseline_stdev: 1.0, + }; + } + + // Calculate baseline statistics from initial portion of data + let baseline_len = ((data.len() as f64 * baseline_pct / 100.0) as usize).max(10); + let baseline_data = &data[..baseline_len.min(data.len())]; + + let baseline_mean: f64 = baseline_data.iter().sum::() / baseline_data.len() as f64; + let baseline_variance: f64 = baseline_data + .iter() + .map(|x| (x - baseline_mean).powi(2)) + .sum::() + / (baseline_data.len() - 1).max(1) as f64; + let baseline_stdev = baseline_variance.sqrt().max(0.001); + + // CUSUM calculation + let mut s_high = 0.0; + let mut s_low = 0.0; + let mut drift_flags = Vec::with_capacity(data.len()); + + for &x in data { + // Update CUSUM statistics + s_high = (s_high + (x - baseline_mean) - k).max(0.0); + s_low = (s_low + (-x + baseline_mean) - k).max(0.0); + + // Determine drift direction + let flag = if s_high > h { + 1.0 // Positive drift (running rich if this is fuel trim) + } else if s_low > h { + -1.0 // Negative drift (running lean if this is fuel trim) + } else { + 0.0 // Normal + }; + drift_flags.push(flag); + + // Reset after detection (optional - prevents persistent flagging) + if s_high > h { + s_high = 0.0; + } + if s_low > h { + s_low = 0.0; + } + } + + CusumResult { + drift_flags, + baseline_mean, + baseline_stdev, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_detect_afr_unit() { + // Typical AFR data (around 14.7) + let afr_data = vec![14.5, 14.7, 14.9, 15.0, 14.2, 14.8]; + assert_eq!(detect_fuel_mixture_unit(&afr_data), FuelMixtureUnit::Afr); + + // Wide range AFR data + let afr_wide = vec![12.0, 13.5, 14.7, 16.0, 18.0]; + assert_eq!(detect_fuel_mixture_unit(&afr_wide), FuelMixtureUnit::Afr); + } + + #[test] + fn test_detect_lambda_unit() { + // Typical Lambda data (around 1.0) + let lambda_data = vec![0.95, 1.0, 1.02, 0.98, 1.05, 0.97]; + assert_eq!( + detect_fuel_mixture_unit(&lambda_data), + FuelMixtureUnit::Lambda + ); + + // Rich lambda data + let lambda_rich = vec![0.75, 0.80, 0.85, 0.90]; + assert_eq!( + detect_fuel_mixture_unit(&lambda_rich), + FuelMixtureUnit::Lambda + ); + + // Lean lambda data + let lambda_lean = vec![1.05, 1.10, 1.15, 1.20]; + assert_eq!( + detect_fuel_mixture_unit(&lambda_lean), + FuelMixtureUnit::Lambda + ); + } + + #[test] + fn test_fuel_mixture_unit_stoichiometric() { + assert!((FuelMixtureUnit::Afr.stoichiometric() - 14.7).abs() < 0.01); + assert!((FuelMixtureUnit::Lambda.stoichiometric() - 1.0).abs() < 0.01); + } + + #[test] + fn test_fuel_mixture_unit_to_afr() { + // AFR stays the same + assert!((FuelMixtureUnit::Afr.to_afr(14.7) - 14.7).abs() < 0.01); + + // Lambda 1.0 converts to 14.7 + assert!((FuelMixtureUnit::Lambda.to_afr(1.0) - 14.7).abs() < 0.01); + + // Lambda 0.9 (rich) converts to ~13.2 + assert!((FuelMixtureUnit::Lambda.to_afr(0.9) - 13.23).abs() < 0.1); + } + + #[test] + fn test_cusum_stable() { + // Stable data around 0 should not trigger drift + let data: Vec = (0..200).map(|_| 0.5).collect(); + let result = cusum_drift_detection(&data, 2.5, 20.0, 10.0); + + let drift_count = result.drift_flags.iter().filter(|&&x| x != 0.0).count(); + assert_eq!(drift_count, 0, "Stable data should have no drift"); + } + + #[test] + fn test_cusum_drift_up() { + // Data that drifts upward + let mut data: Vec = vec![0.0; 100]; + data.extend(vec![10.0; 100]); // Step change + + let result = cusum_drift_detection(&data, 2.5, 20.0, 10.0); + + let drift_count = result.drift_flags.iter().filter(|&&x| x > 0.0).count(); + assert!(drift_count > 0, "Upward drift should be detected"); + } + + #[test] + fn test_rich_lean_zones_afr() { + // Test with AFR data + let afr_data = vec![14.7, 14.0, 15.5, 14.7, 13.5, 16.0]; + let unit = detect_fuel_mixture_unit(&afr_data); + assert_eq!(unit, FuelMixtureUnit::Afr); + + let (rich_thresh, lean_thresh) = unit.default_thresholds(); + let target = unit.stoichiometric(); + let rich_limit = target - rich_thresh; + let lean_limit = target + lean_thresh; + + // Count expected zones + let rich = afr_data.iter().filter(|&&a| a < rich_limit).count(); + let lean = afr_data.iter().filter(|&&a| a > lean_limit).count(); + + assert!(rich > 0, "Should detect rich conditions"); + assert!(lean > 0, "Should detect lean conditions"); + } + + #[test] + fn test_rich_lean_zones_lambda() { + // Test with Lambda data + let lambda_data = vec![1.0, 0.95, 1.05, 1.0, 0.90, 1.10]; + let unit = detect_fuel_mixture_unit(&lambda_data); + assert_eq!(unit, FuelMixtureUnit::Lambda); + + let (rich_thresh, lean_thresh) = unit.default_thresholds(); + let target = unit.stoichiometric(); + let rich_limit = target - rich_thresh; + let lean_limit = target + lean_thresh; + + // Count expected zones + let rich = lambda_data.iter().filter(|&&a| a < rich_limit).count(); + let lean = lambda_data.iter().filter(|&&a| a > lean_limit).count(); + + assert!(rich > 0, "Should detect rich conditions in lambda data"); + assert!(lean > 0, "Should detect lean conditions in lambda data"); + } + + #[test] + fn test_afr_deviation() { + let afr_data = vec![14.7, 15.435, 13.965]; // 0%, +5%, -5% + let target = 14.7; + + let deviations: Vec = afr_data + .iter() + .map(|&afr| ((afr - target) / target) * 100.0) + .collect(); + + assert!((deviations[0] - 0.0).abs() < 0.1); + assert!((deviations[1] - 5.0).abs() < 0.1); + assert!((deviations[2] + 5.0).abs() < 0.1); + } + + #[test] + fn test_lambda_deviation() { + let lambda_data = vec![1.0, 1.05, 0.95]; // 0%, +5%, -5% + let target = 1.0; + + let deviations: Vec = lambda_data + .iter() + .map(|&lambda| ((lambda - target) / target) * 100.0) + .collect(); + + assert!((deviations[0] - 0.0).abs() < 0.1); + assert!((deviations[1] - 5.0).abs() < 0.1); + assert!((deviations[2] + 5.0).abs() < 0.1); + } +} diff --git a/src/analysis/derived.rs b/src/analysis/derived.rs new file mode 100644 index 0000000..6af8434 --- /dev/null +++ b/src/analysis/derived.rs @@ -0,0 +1,556 @@ +//! Derived calculation algorithms. +//! +//! Computes derived engine metrics like Volumetric Efficiency, +//! Injector Duty Cycle, and other calculated values. + +use super::*; +use std::collections::HashMap; + +/// Volumetric Efficiency Analyzer +/// +/// Estimates VE from MAF and engine parameters, or from MAP/IAT using +/// speed-density equations. +#[derive(Clone)] +pub struct VolumetricEfficiencyAnalyzer { + /// RPM channel + pub rpm_channel: String, + /// MAP channel (kPa) + pub map_channel: String, + /// IAT channel (°C or K - set is_iat_kelvin accordingly) + pub iat_channel: String, + /// Engine displacement in liters + pub displacement_l: f64, + /// Whether IAT is already in Kelvin (false = Celsius) + pub is_iat_kelvin: bool, +} + +impl Default for VolumetricEfficiencyAnalyzer { + fn default() -> Self { + Self { + rpm_channel: "RPM".to_string(), + map_channel: "MAP".to_string(), + iat_channel: "IAT".to_string(), + displacement_l: 2.0, // Default 2.0L engine + is_iat_kelvin: false, + } + } +} + +impl Analyzer for VolumetricEfficiencyAnalyzer { + fn id(&self) -> &str { + "volumetric_efficiency" + } + + fn name(&self) -> &str { + "Volumetric Efficiency" + } + + fn description(&self) -> &str { + "Estimates Volumetric Efficiency (VE) from MAP, RPM, and IAT using the \ + speed-density equation. VE = (MAP × 2) / (ρ_ref × Displacement × RPM × 60). \ + Requires engine displacement to be configured." + } + + fn category(&self) -> &str { + "Derived" + } + + fn required_channels(&self) -> Vec<&str> { + vec![&self.rpm_channel, &self.map_channel, &self.iat_channel] + } + + fn analyze(&self, log: &Log) -> Result { + let rpm = require_channel(log, &self.rpm_channel)?; + let map = require_channel(log, &self.map_channel)?; + let iat = require_channel(log, &self.iat_channel)?; + + if rpm.len() != map.len() || rpm.len() != iat.len() { + return Err(AnalysisError::ComputationError( + "Channels have different lengths".to_string(), + )); + } + + require_min_length(&rpm, 2)?; + + if self.displacement_l <= 0.0 { + return Err(AnalysisError::InvalidParameter( + "Displacement must be positive".to_string(), + )); + } + + let (ve_values, computation_time) = timed_analyze(|| { + compute_volumetric_efficiency(&rpm, &map, &iat, self.displacement_l, self.is_iat_kelvin) + }); + + // Compute statistics + let stats = super::statistics::compute_descriptive_stats(&ve_values); + + let mut warnings = vec![]; + + // VE typically ranges from 70-110% for naturally aspirated, + // and can exceed 100% for forced induction + if stats.max > 150.0 { + warnings.push(format!( + "Very high VE detected (max {:.1}%) - verify displacement setting or check for sensor issues", + stats.max + )); + } + if stats.min < 20.0 && stats.min > 0.0 { + warnings.push(format!( + "Very low VE detected (min {:.1}%) - possible manifold leak or sensor issue", + stats.min + )); + } + + Ok(AnalysisResult { + name: "Volumetric Efficiency".to_string(), + unit: "%".to_string(), + values: ve_values, + metadata: AnalysisMetadata { + algorithm: "Speed-Density".to_string(), + parameters: vec![ + ( + "displacement_l".to_string(), + format!("{:.2}L", self.displacement_l), + ), + ("mean_ve".to_string(), format!("{:.1}%", stats.mean)), + ("max_ve".to_string(), format!("{:.1}%", stats.max)), + ("min_ve".to_string(), format!("{:.1}%", stats.min)), + ], + warnings, + computation_time_ms: computation_time, + }, + }) + } + + fn get_config(&self) -> AnalyzerConfig { + let mut params = HashMap::new(); + params.insert("rpm_channel".to_string(), self.rpm_channel.clone()); + params.insert("map_channel".to_string(), self.map_channel.clone()); + params.insert("iat_channel".to_string(), self.iat_channel.clone()); + params.insert( + "displacement_l".to_string(), + self.displacement_l.to_string(), + ); + params.insert("is_iat_kelvin".to_string(), self.is_iat_kelvin.to_string()); + + AnalyzerConfig { + id: self.id().to_string(), + name: self.name().to_string(), + parameters: params, + } + } + + fn set_config(&mut self, config: &AnalyzerConfig) { + if let Some(ch) = config.parameters.get("rpm_channel") { + self.rpm_channel = ch.clone(); + } + if let Some(ch) = config.parameters.get("map_channel") { + self.map_channel = ch.clone(); + } + if let Some(ch) = config.parameters.get("iat_channel") { + self.iat_channel = ch.clone(); + } + if let Some(v) = config.parameters.get("displacement_l") { + if let Ok(val) = v.parse() { + self.displacement_l = val; + } + } + if let Some(v) = config.parameters.get("is_iat_kelvin") { + self.is_iat_kelvin = v.parse().unwrap_or(false); + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +/// Injector Duty Cycle Analyzer +/// +/// Calculates injector duty cycle as a percentage, important for +/// determining if injectors are reaching their limit. +#[derive(Clone)] +pub struct InjectorDutyCycleAnalyzer { + /// Injector pulse width channel (milliseconds) + pub pulse_width_channel: String, + /// RPM channel + pub rpm_channel: String, +} + +impl Default for InjectorDutyCycleAnalyzer { + fn default() -> Self { + Self { + pulse_width_channel: "IPW".to_string(), // Injector Pulse Width + rpm_channel: "RPM".to_string(), + } + } +} + +impl Analyzer for InjectorDutyCycleAnalyzer { + fn id(&self) -> &str { + "injector_duty_cycle" + } + + fn name(&self) -> &str { + "Injector Duty Cycle" + } + + fn description(&self) -> &str { + "Calculates injector duty cycle (%) from pulse width and RPM. \ + Formula: IDC = (PW_ms × RPM) / 1200 for 4-stroke engines. \ + Warning issued above 80% (traditional) or 95% (high-performance)." + } + + fn category(&self) -> &str { + "Derived" + } + + fn required_channels(&self) -> Vec<&str> { + vec![&self.pulse_width_channel, &self.rpm_channel] + } + + fn analyze(&self, log: &Log) -> Result { + let pw = require_channel(log, &self.pulse_width_channel)?; + let rpm = require_channel(log, &self.rpm_channel)?; + + if pw.len() != rpm.len() { + return Err(AnalysisError::ComputationError( + "Channels have different lengths".to_string(), + )); + } + + require_min_length(&pw, 2)?; + + let (idc_values, computation_time) = + timed_analyze(|| compute_injector_duty_cycle(&pw, &rpm)); + + // Compute statistics + let stats = super::statistics::compute_descriptive_stats(&idc_values); + + let mut warnings = vec![]; + + // Count samples at various duty cycle thresholds + let above_80 = idc_values.iter().filter(|&&v| v > 80.0).count(); + let above_95 = idc_values.iter().filter(|&&v| v > 95.0).count(); + let at_100 = idc_values.iter().filter(|&&v| v >= 100.0).count(); + let total = idc_values.len(); + + if at_100 > 0 { + warnings.push(format!( + "CRITICAL: Injectors at 100% duty cycle ({:.1}% of time) - \ + fueling capacity exceeded, engine running lean!", + 100.0 * at_100 as f64 / total as f64 + )); + } else if above_95 > 0 { + warnings.push(format!( + "High duty cycle (>95%) detected ({:.1}% of time) - \ + approaching injector limits", + 100.0 * above_95 as f64 / total as f64 + )); + } else if above_80 > total / 10 { + warnings.push(format!( + "Elevated duty cycle (>80%) for {:.1}% of samples - \ + consider larger injectors for additional power", + 100.0 * above_80 as f64 / total as f64 + )); + } + + Ok(AnalysisResult { + name: "Injector Duty Cycle".to_string(), + unit: "%".to_string(), + values: idc_values, + metadata: AnalysisMetadata { + algorithm: "PW × RPM / 1200".to_string(), + parameters: vec![ + ("mean_idc".to_string(), format!("{:.1}%", stats.mean)), + ("max_idc".to_string(), format!("{:.1}%", stats.max)), + ("samples_above_80".to_string(), format!("{}", above_80)), + ("samples_above_95".to_string(), format!("{}", above_95)), + ], + warnings, + computation_time_ms: computation_time, + }, + }) + } + + fn get_config(&self) -> AnalyzerConfig { + let mut params = HashMap::new(); + params.insert( + "pulse_width_channel".to_string(), + self.pulse_width_channel.clone(), + ); + params.insert("rpm_channel".to_string(), self.rpm_channel.clone()); + + AnalyzerConfig { + id: self.id().to_string(), + name: self.name().to_string(), + parameters: params, + } + } + + fn set_config(&mut self, config: &AnalyzerConfig) { + if let Some(ch) = config.parameters.get("pulse_width_channel") { + self.pulse_width_channel = ch.clone(); + } + if let Some(ch) = config.parameters.get("rpm_channel") { + self.rpm_channel = ch.clone(); + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +/// Lambda Calculator +/// +/// Converts AFR to Lambda for easier analysis across different fuels. +#[derive(Clone)] +pub struct LambdaCalculator { + /// AFR channel to convert + pub afr_channel: String, + /// Stoichiometric AFR for the fuel (14.7 for gasoline, 14.6 for E10, etc.) + pub stoich_afr: f64, +} + +impl Default for LambdaCalculator { + fn default() -> Self { + Self { + afr_channel: "AFR".to_string(), + stoich_afr: 14.7, + } + } +} + +impl Analyzer for LambdaCalculator { + fn id(&self) -> &str { + "lambda_calculator" + } + + fn name(&self) -> &str { + "Lambda Calculator" + } + + fn description(&self) -> &str { + "Converts AFR to Lambda (λ = AFR / Stoich). Lambda of 1.0 = stoichiometric. \ + Useful for comparing fueling across different fuel types." + } + + fn category(&self) -> &str { + "Derived" + } + + fn required_channels(&self) -> Vec<&str> { + vec![&self.afr_channel] + } + + fn analyze(&self, log: &Log) -> Result { + let afr = require_channel(log, &self.afr_channel)?; + require_min_length(&afr, 2)?; + + if self.stoich_afr <= 0.0 { + return Err(AnalysisError::InvalidParameter( + "Stoichiometric AFR must be positive".to_string(), + )); + } + + let (lambda_values, computation_time) = timed_analyze(|| { + afr.iter() + .map(|&a| a / self.stoich_afr) + .collect::>() + }); + + let stats = super::statistics::compute_descriptive_stats(&lambda_values); + + let mut warnings = vec![]; + + if stats.min < 0.7 { + warnings.push(format!( + "Very rich lambda detected (min {:.2}) - check for flooding or \ + over-fueling conditions", + stats.min + )); + } + if stats.max > 1.3 { + warnings.push(format!( + "Very lean lambda detected (max {:.2}) - risk of detonation, \ + check fueling", + stats.max + )); + } + + Ok(AnalysisResult { + name: "Lambda".to_string(), + unit: "λ".to_string(), + values: lambda_values, + metadata: AnalysisMetadata { + algorithm: "AFR / Stoich".to_string(), + parameters: vec![ + ("stoich_afr".to_string(), format!("{:.1}", self.stoich_afr)), + ("mean_lambda".to_string(), format!("{:.3}", stats.mean)), + ("min_lambda".to_string(), format!("{:.3}", stats.min)), + ("max_lambda".to_string(), format!("{:.3}", stats.max)), + ], + warnings, + computation_time_ms: computation_time, + }, + }) + } + + fn get_config(&self) -> AnalyzerConfig { + let mut params = HashMap::new(); + params.insert("afr_channel".to_string(), self.afr_channel.clone()); + params.insert("stoich_afr".to_string(), self.stoich_afr.to_string()); + + AnalyzerConfig { + id: self.id().to_string(), + name: self.name().to_string(), + parameters: params, + } + } + + fn set_config(&mut self, config: &AnalyzerConfig) { + if let Some(ch) = config.parameters.get("afr_channel") { + self.afr_channel = ch.clone(); + } + if let Some(v) = config.parameters.get("stoich_afr") { + if let Ok(val) = v.parse() { + self.stoich_afr = val; + } + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +// ============================================================================ +// Core derived calculation implementations +// ============================================================================ + +/// Compute Volumetric Efficiency from speed-density equation +/// +/// VE% = (Actual air mass / Theoretical air mass) × 100 +/// +/// For speed-density calculation: +/// VE% = (MAP × 2) / (P_ref × Disp × RPM × (T_ref/T_actual) × 1/60) × 100 +/// +/// Simplified: VE% ≈ (MAP / 101.325) × (298 / T_actual_K) × 100 +/// This gives a relative VE assuming standard conditions. +fn compute_volumetric_efficiency( + rpm: &[f64], + map: &[f64], + iat: &[f64], + _displacement_l: f64, + is_iat_kelvin: bool, +) -> Vec { + // Reference conditions + const P_REF: f64 = 101.325; // Standard pressure in kPa + const T_REF: f64 = 298.0; // Standard temperature in Kelvin (25°C) + + rpm.iter() + .zip(map.iter()) + .zip(iat.iter()) + .map(|((&r, &m), &t)| { + // Convert IAT to Kelvin if needed + let t_kelvin = if is_iat_kelvin { t } else { t + 273.15 }; + + // Avoid division by zero + if r <= 0.0 || t_kelvin <= 0.0 { + return 0.0; + } + + // Speed-density VE calculation + // This is a simplified model that gives relative VE + // VE = (MAP / P_ref) × (T_ref / T_actual) × 100 + // This gives how much air we're getting compared to standard conditions + + // More accurate would use MAF if available: + // VE = (MAF_actual × 120) / (ρ_std × Disp_cc × RPM) × 100 + + // For now, use the MAP-based estimate + let ve = (m / P_REF) * (T_REF / t_kelvin) * 100.0; + + // Clamp to reasonable range (negative values not physical) + ve.max(0.0) + }) + .collect() +} + +/// Compute Injector Duty Cycle +/// +/// For 4-stroke engines, each injector fires once per 2 revolutions: +/// Time per injection cycle = 60,000 / (RPM / 2) = 120,000 / RPM (in ms) +/// +/// IDC% = (Pulse_Width_ms / Time_per_cycle_ms) × 100 +/// IDC% = (PW × RPM) / 120,000 × 100 +/// IDC% = (PW × RPM) / 1200 +fn compute_injector_duty_cycle(pulse_width: &[f64], rpm: &[f64]) -> Vec { + pulse_width + .iter() + .zip(rpm.iter()) + .map(|(&pw, &r)| { + if r <= 0.0 { + return 0.0; + } + // IDC = (PW_ms × RPM) / 1200 + let idc = (pw * r) / 1200.0; + idc.clamp(0.0, 100.0) // Clamp to 0-100% + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_injector_duty_cycle() { + // At 6000 RPM with 10ms pulse width + // IDC = (10 × 6000) / 1200 = 50% + let pw = vec![10.0]; + let rpm = vec![6000.0]; + let idc = compute_injector_duty_cycle(&pw, &rpm); + assert!((idc[0] - 50.0).abs() < 0.01); + + // At 7200 RPM with 10ms pulse width + // IDC = (10 × 7200) / 1200 = 60% + let pw = vec![10.0]; + let rpm = vec![7200.0]; + let idc = compute_injector_duty_cycle(&pw, &rpm); + assert!((idc[0] - 60.0).abs() < 0.01); + + // At redline 8000 RPM with 15ms + // IDC = (15 × 8000) / 1200 = 100% + let pw = vec![15.0]; + let rpm = vec![8000.0]; + let idc = compute_injector_duty_cycle(&pw, &rpm); + assert!((idc[0] - 100.0).abs() < 0.01); + } + + #[test] + fn test_volumetric_efficiency() { + // At atmospheric pressure and standard temp, VE should be ~100% + let rpm = vec![3000.0]; + let map = vec![101.325]; // 1 atm + let iat = vec![25.0]; // 25°C + + let ve = compute_volumetric_efficiency(&rpm, &map, &iat, 2.0, false); + assert!((ve[0] - 100.0).abs() < 1.0); // Should be close to 100% + + // At half atmospheric pressure, VE should be ~50% + let map = vec![50.0]; + let ve = compute_volumetric_efficiency(&rpm, &map, &iat, 2.0, false); + assert!((ve[0] - 50.0).abs() < 5.0); + } + + #[test] + fn test_lambda_calculation() { + // Lambda = AFR / 14.7 + assert!((14.7_f64 / 14.7 - 1.0).abs() < 0.001); // Stoich = lambda 1.0 + assert!((13.0_f64 / 14.7) < 1.0); // Rich (lambda < 1) + assert!((16.0_f64 / 14.7) > 1.0); // Lean (lambda > 1) + } +} diff --git a/src/analysis/filters.rs b/src/analysis/filters.rs new file mode 100644 index 0000000..8df618d --- /dev/null +++ b/src/analysis/filters.rs @@ -0,0 +1,1235 @@ +//! Filter-based analysis algorithms. +//! +//! Provides signal processing filters for smoothing, noise reduction, +//! and data conditioning. + +use super::*; +use std::collections::{HashMap, VecDeque}; + +/// Moving Average filter analyzer +#[derive(Clone)] +pub struct MovingAverageAnalyzer { + /// Channel to filter + pub channel: String, + /// Window size for averaging + pub window_size: usize, +} + +impl Default for MovingAverageAnalyzer { + fn default() -> Self { + Self { + channel: "RPM".to_string(), + window_size: 5, + } + } +} + +impl Analyzer for MovingAverageAnalyzer { + fn id(&self) -> &str { + "moving_average" + } + + fn name(&self) -> &str { + "Moving Average" + } + + fn description(&self) -> &str { + "Simple moving average filter for smoothing noisy signals. \ + Averages the last N samples to reduce high-frequency noise." + } + + fn category(&self) -> &str { + "Filters" + } + + fn required_channels(&self) -> Vec<&str> { + vec![&self.channel] + } + + fn analyze(&self, log: &Log) -> Result { + let data = require_channel(log, &self.channel)?; + require_min_length(&data, self.window_size)?; + + let (values, computation_time) = timed_analyze(|| moving_average(&data, self.window_size)); + + Ok(AnalysisResult { + name: format!("{} (MA{})", self.channel, self.window_size), + unit: String::new(), // Same unit as input + values, + metadata: AnalysisMetadata { + algorithm: "Simple Moving Average".to_string(), + parameters: vec![ + ("window_size".to_string(), self.window_size.to_string()), + ("channel".to_string(), self.channel.clone()), + ], + warnings: vec![], + computation_time_ms: computation_time, + }, + }) + } + + fn get_config(&self) -> AnalyzerConfig { + let mut params = HashMap::new(); + params.insert("channel".to_string(), self.channel.clone()); + params.insert("window_size".to_string(), self.window_size.to_string()); + + AnalyzerConfig { + id: self.id().to_string(), + name: self.name().to_string(), + parameters: params, + } + } + + fn set_config(&mut self, config: &AnalyzerConfig) { + if let Some(ch) = config.parameters.get("channel") { + self.channel = ch.clone(); + } + if let Some(ws) = config.parameters.get("window_size") { + if let Ok(size) = ws.parse() { + self.window_size = size; + } + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +/// Exponential Moving Average filter analyzer +#[derive(Clone)] +pub struct ExponentialMovingAverageAnalyzer { + /// Channel to filter + pub channel: String, + /// Smoothing factor alpha (0 < alpha <= 1) + /// Higher alpha = more weight to recent values + pub alpha: f64, +} + +impl Default for ExponentialMovingAverageAnalyzer { + fn default() -> Self { + Self { + channel: "RPM".to_string(), + alpha: 0.2, // Equivalent to ~9 period SMA + } + } +} + +impl Analyzer for ExponentialMovingAverageAnalyzer { + fn id(&self) -> &str { + "exponential_moving_average" + } + + fn name(&self) -> &str { + "Exponential Moving Average" + } + + fn description(&self) -> &str { + "Exponentially weighted moving average filter. More recent samples have \ + higher weight. Alpha parameter controls smoothing (0.1=heavy, 0.5=light)." + } + + fn category(&self) -> &str { + "Filters" + } + + fn required_channels(&self) -> Vec<&str> { + vec![&self.channel] + } + + fn analyze(&self, log: &Log) -> Result { + let data = require_channel(log, &self.channel)?; + require_min_length(&data, 2)?; + + if self.alpha <= 0.0 || self.alpha > 1.0 { + return Err(AnalysisError::InvalidParameter( + "Alpha must be between 0 and 1".to_string(), + )); + } + + let (values, computation_time) = + timed_analyze(|| exponential_moving_average(&data, self.alpha)); + + Ok(AnalysisResult { + name: format!("{} (EMA α={:.2})", self.channel, self.alpha), + unit: String::new(), + values, + metadata: AnalysisMetadata { + algorithm: "Exponential Moving Average".to_string(), + parameters: vec![ + ("alpha".to_string(), format!("{:.3}", self.alpha)), + ("channel".to_string(), self.channel.clone()), + ], + warnings: vec![], + computation_time_ms: computation_time, + }, + }) + } + + fn get_config(&self) -> AnalyzerConfig { + let mut params = HashMap::new(); + params.insert("channel".to_string(), self.channel.clone()); + params.insert("alpha".to_string(), self.alpha.to_string()); + + AnalyzerConfig { + id: self.id().to_string(), + name: self.name().to_string(), + parameters: params, + } + } + + fn set_config(&mut self, config: &AnalyzerConfig) { + if let Some(ch) = config.parameters.get("channel") { + self.channel = ch.clone(); + } + if let Some(a) = config.parameters.get("alpha") { + if let Ok(alpha) = a.parse() { + self.alpha = alpha; + } + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +/// Median filter analyzer +#[derive(Clone)] +pub struct MedianFilterAnalyzer { + /// Channel to filter + pub channel: String, + /// Window size (must be odd for symmetric window) + pub window_size: usize, +} + +impl Default for MedianFilterAnalyzer { + fn default() -> Self { + Self { + channel: "RPM".to_string(), + window_size: 5, + } + } +} + +impl Analyzer for MedianFilterAnalyzer { + fn id(&self) -> &str { + "median_filter" + } + + fn name(&self) -> &str { + "Median Filter" + } + + fn description(&self) -> &str { + "Median filter for removing impulse noise (spikes). Replaces each value \ + with the median of neighboring samples. Preserves edges better than averaging." + } + + fn category(&self) -> &str { + "Filters" + } + + fn required_channels(&self) -> Vec<&str> { + vec![&self.channel] + } + + fn analyze(&self, log: &Log) -> Result { + let data = require_channel(log, &self.channel)?; + require_min_length(&data, self.window_size)?; + + // Ensure odd window size + #[allow(clippy::manual_is_multiple_of)] + let window = if self.window_size % 2 == 0 { + self.window_size + 1 + } else { + self.window_size + }; + + let (values, computation_time) = timed_analyze(|| median_filter(&data, window)); + + let mut warnings = vec![]; + #[allow(clippy::manual_is_multiple_of)] + if self.window_size % 2 == 0 { + warnings.push(format!( + "Window size adjusted from {} to {} (must be odd)", + self.window_size, window + )); + } + + Ok(AnalysisResult { + name: format!("{} (Median{})", self.channel, window), + unit: String::new(), + values, + metadata: AnalysisMetadata { + algorithm: "Median Filter".to_string(), + parameters: vec![ + ("window_size".to_string(), window.to_string()), + ("channel".to_string(), self.channel.clone()), + ], + warnings, + computation_time_ms: computation_time, + }, + }) + } + + fn get_config(&self) -> AnalyzerConfig { + let mut params = HashMap::new(); + params.insert("channel".to_string(), self.channel.clone()); + params.insert("window_size".to_string(), self.window_size.to_string()); + + AnalyzerConfig { + id: self.id().to_string(), + name: self.name().to_string(), + parameters: params, + } + } + + fn set_config(&mut self, config: &AnalyzerConfig) { + if let Some(ch) = config.parameters.get("channel") { + self.channel = ch.clone(); + } + if let Some(ws) = config.parameters.get("window_size") { + if let Ok(size) = ws.parse() { + self.window_size = size; + } + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +// ============================================================================ +// Core filter implementations +// ============================================================================ + +/// Simple moving average filter +pub fn moving_average(data: &[f64], window_size: usize) -> Vec { + if data.is_empty() || window_size == 0 { + return data.to_vec(); + } + + let mut result = Vec::with_capacity(data.len()); + let mut sum: f64 = 0.0; + let mut window: VecDeque = VecDeque::with_capacity(window_size); + + for &value in data { + window.push_back(value); + sum += value; + + if window.len() > window_size { + sum -= window.pop_front().unwrap(); + } + + result.push(sum / window.len() as f64); + } + + result +} + +/// Exponential moving average filter +pub fn exponential_moving_average(data: &[f64], alpha: f64) -> Vec { + if data.is_empty() { + return vec![]; + } + + let mut result = Vec::with_capacity(data.len()); + let mut ema = data[0]; + + for &value in data { + ema = alpha * value + (1.0 - alpha) * ema; + result.push(ema); + } + + result +} + +/// Median filter +pub fn median_filter(data: &[f64], window_size: usize) -> Vec { + if data.is_empty() || window_size == 0 { + return data.to_vec(); + } + + let half_window = window_size / 2; + let mut result = Vec::with_capacity(data.len()); + + for i in 0..data.len() { + let start = i.saturating_sub(half_window); + let end = (i + half_window + 1).min(data.len()); + + let mut window: Vec = data[start..end].to_vec(); + window.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + + #[allow(clippy::manual_is_multiple_of)] + let median = if window.len() % 2 == 0 { + (window[window.len() / 2 - 1] + window[window.len() / 2]) / 2.0 + } else { + window[window.len() / 2] + }; + + result.push(median); + } + + result +} + +/// Butterworth lowpass filter analyzer +/// +/// Implements a digital Butterworth lowpass filter with configurable +/// cutoff frequency and filter order. Uses forward-backward filtering +/// for zero phase distortion. +#[derive(Clone)] +pub struct ButterworthLowpassAnalyzer { + /// Channel to filter + pub channel: String, + /// Cutoff frequency as fraction of sample rate (0.0 to 0.5) + /// e.g., 0.1 = cutoff at 10% of Nyquist frequency + pub cutoff_normalized: f64, + /// Filter order (1-4 recommended) + pub order: usize, +} + +impl Default for ButterworthLowpassAnalyzer { + fn default() -> Self { + Self { + channel: "RPM".to_string(), + cutoff_normalized: 0.1, // 10% of Nyquist + order: 2, + } + } +} + +impl Analyzer for ButterworthLowpassAnalyzer { + fn id(&self) -> &str { + "butterworth_lowpass" + } + + fn name(&self) -> &str { + "Butterworth Lowpass" + } + + fn description(&self) -> &str { + "Butterworth lowpass filter with maximally flat passband response. \ + Uses zero-phase filtering (filtfilt) to eliminate phase distortion. \ + Cutoff is normalized (0-0.5, where 0.5 = Nyquist frequency)." + } + + fn category(&self) -> &str { + "Filters" + } + + fn required_channels(&self) -> Vec<&str> { + vec![&self.channel] + } + + fn analyze(&self, log: &Log) -> Result { + let data = require_channel(log, &self.channel)?; + require_min_length(&data, 10)?; + + if self.cutoff_normalized <= 0.0 || self.cutoff_normalized >= 0.5 { + return Err(AnalysisError::InvalidParameter( + "Cutoff must be between 0 and 0.5 (Nyquist)".to_string(), + )); + } + + if self.order < 1 || self.order > 8 { + return Err(AnalysisError::InvalidParameter( + "Order must be between 1 and 8".to_string(), + )); + } + + let (values, computation_time) = timed_analyze(|| { + butterworth_lowpass_filtfilt(&data, self.cutoff_normalized, self.order) + }); + + let mut warnings = vec![]; + if self.order > 4 { + warnings.push("High filter orders (>4) may cause numerical instability".to_string()); + } + + // Estimate actual cutoff frequency if we know sample rate + let times = log.times(); + let sample_rate_hint = if times.len() >= 2 { + 1.0 / (times[1] - times[0]).max(0.001) + } else { + 0.0 + }; + + let mut params = vec![ + ( + "cutoff_normalized".to_string(), + format!("{:.3}", self.cutoff_normalized), + ), + ("order".to_string(), self.order.to_string()), + ("channel".to_string(), self.channel.clone()), + ]; + + if sample_rate_hint > 0.0 { + let cutoff_hz = self.cutoff_normalized * sample_rate_hint; + params.push(( + "cutoff_hz_approx".to_string(), + format!("{:.1} Hz", cutoff_hz), + )); + } + + Ok(AnalysisResult { + name: format!("{} (Butter{})", self.channel, self.order), + unit: String::new(), + values, + metadata: AnalysisMetadata { + algorithm: "Butterworth Lowpass (filtfilt)".to_string(), + parameters: params, + warnings, + computation_time_ms: computation_time, + }, + }) + } + + fn get_config(&self) -> AnalyzerConfig { + let mut params = HashMap::new(); + params.insert("channel".to_string(), self.channel.clone()); + params.insert( + "cutoff_normalized".to_string(), + self.cutoff_normalized.to_string(), + ); + params.insert("order".to_string(), self.order.to_string()); + + AnalyzerConfig { + id: self.id().to_string(), + name: self.name().to_string(), + parameters: params, + } + } + + fn set_config(&mut self, config: &AnalyzerConfig) { + if let Some(ch) = config.parameters.get("channel") { + self.channel = ch.clone(); + } + if let Some(c) = config.parameters.get("cutoff_normalized") { + if let Ok(cutoff) = c.parse() { + self.cutoff_normalized = cutoff; + } + } + if let Some(o) = config.parameters.get("order") { + if let Ok(order) = o.parse() { + self.order = order; + } + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +/// Butterworth highpass filter analyzer +#[derive(Clone)] +pub struct ButterworthHighpassAnalyzer { + /// Channel to filter + pub channel: String, + /// Cutoff frequency as fraction of sample rate (0.0 to 0.5) + pub cutoff_normalized: f64, + /// Filter order (1-4 recommended) + pub order: usize, +} + +impl Default for ButterworthHighpassAnalyzer { + fn default() -> Self { + Self { + channel: "RPM".to_string(), + cutoff_normalized: 0.05, // 5% of Nyquist + order: 2, + } + } +} + +impl Analyzer for ButterworthHighpassAnalyzer { + fn id(&self) -> &str { + "butterworth_highpass" + } + + fn name(&self) -> &str { + "Butterworth Highpass" + } + + fn description(&self) -> &str { + "Butterworth highpass filter for removing low-frequency drift and DC offset. \ + Uses zero-phase filtering (filtfilt) to eliminate phase distortion." + } + + fn category(&self) -> &str { + "Filters" + } + + fn required_channels(&self) -> Vec<&str> { + vec![&self.channel] + } + + fn analyze(&self, log: &Log) -> Result { + let data = require_channel(log, &self.channel)?; + require_min_length(&data, 10)?; + + if self.cutoff_normalized <= 0.0 || self.cutoff_normalized >= 0.5 { + return Err(AnalysisError::InvalidParameter( + "Cutoff must be between 0 and 0.5 (Nyquist)".to_string(), + )); + } + + if self.order < 1 || self.order > 8 { + return Err(AnalysisError::InvalidParameter( + "Order must be between 1 and 8".to_string(), + )); + } + + let (values, computation_time) = timed_analyze(|| { + butterworth_highpass_filtfilt(&data, self.cutoff_normalized, self.order) + }); + + Ok(AnalysisResult { + name: format!("{} (HP{})", self.channel, self.order), + unit: String::new(), + values, + metadata: AnalysisMetadata { + algorithm: "Butterworth Highpass (filtfilt)".to_string(), + parameters: vec![ + ( + "cutoff_normalized".to_string(), + format!("{:.3}", self.cutoff_normalized), + ), + ("order".to_string(), self.order.to_string()), + ("channel".to_string(), self.channel.clone()), + ], + warnings: vec![], + computation_time_ms: computation_time, + }, + }) + } + + fn get_config(&self) -> AnalyzerConfig { + let mut params = HashMap::new(); + params.insert("channel".to_string(), self.channel.clone()); + params.insert( + "cutoff_normalized".to_string(), + self.cutoff_normalized.to_string(), + ); + params.insert("order".to_string(), self.order.to_string()); + + AnalyzerConfig { + id: self.id().to_string(), + name: self.name().to_string(), + parameters: params, + } + } + + fn set_config(&mut self, config: &AnalyzerConfig) { + if let Some(ch) = config.parameters.get("channel") { + self.channel = ch.clone(); + } + if let Some(c) = config.parameters.get("cutoff_normalized") { + if let Ok(cutoff) = c.parse() { + self.cutoff_normalized = cutoff; + } + } + if let Some(o) = config.parameters.get("order") { + if let Ok(order) = o.parse() { + self.order = order; + } + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +// ============================================================================ +// Butterworth filter implementation (Professional-grade) +// +// This implementation uses: +// - Second-Order Sections (SOS) for numerical stability +// - Proper bilinear transform with frequency pre-warping +// - Correct pole placement from Butterworth prototype +// - Edge padding with reflection to reduce transient artifacts +// - Steady-state initial conditions (lfilter_zi equivalent) +// - Support for orders 1-8 +// ============================================================================ + +use std::f64::consts::PI; + +/// A second-order section (biquad) filter +/// Transfer function: H(z) = (b0 + b1*z^-1 + b2*z^-2) / (1 + a1*z^-1 + a2*z^-2) +#[derive(Clone, Debug)] +struct Sos { + b0: f64, + b1: f64, + b2: f64, + a1: f64, + a2: f64, +} + +impl Sos { + /// Apply this biquad section to data using Direct Form II Transposed + /// This form has better numerical properties than Direct Form I + fn filter(&self, data: &[f64], zi: Option<[f64; 2]>) -> (Vec, [f64; 2]) { + let n = data.len(); + let mut output = Vec::with_capacity(n); + + // State variables (delay elements) + let mut z1 = zi.map(|z| z[0]).unwrap_or(0.0); + let mut z2 = zi.map(|z| z[1]).unwrap_or(0.0); + + for &x in data { + // Direct Form II Transposed + let y = self.b0 * x + z1; + z1 = self.b1 * x - self.a1 * y + z2; + z2 = self.b2 * x - self.a2 * y; + output.push(y); + } + + (output, [z1, z2]) + } + + /// Compute steady-state initial conditions for step response + /// This is equivalent to scipy's lfilter_zi + fn compute_zi(&self, x0: f64) -> [f64; 2] { + // For a step input of value x0, compute the initial state + // that would make the output also be x0 (steady state) + // + // From: y[n] = b0*x[n] + z1[n-1] + // z1[n] = b1*x[n] - a1*y[n] + z2[n-1] + // z2[n] = b2*x[n] - a2*y[n] + // + // At steady state with x[n] = y[n] = x0: + // z2 = (b2 - a2) * x0 + // z1 = (b1 - a1) * x0 + z2 = (b1 - a1 + b2 - a2) * x0 + + let z2 = (self.b2 - self.a2) * x0; + let z1 = (self.b1 - self.a1) * x0 + z2; + [z1, z2] + } +} + +/// Generate Butterworth lowpass second-order sections +/// +/// Uses the standard approach: +/// 1. Compute analog prototype poles on unit circle +/// 2. Apply bilinear transform with frequency pre-warping +/// 3. Return cascaded biquad sections +fn butterworth_lowpass_sos(cutoff: f64, order: usize) -> Vec { + if order == 0 { + return vec![]; + } + + // Pre-warp the cutoff frequency for bilinear transform + // wc = tan(pi * cutoff) where cutoff is normalized frequency (0 to 0.5) + let wc = (PI * cutoff).tan(); + + let mut sections = Vec::new(); + + // For odd orders, we have one first-order section + if order % 2 == 1 { + // First-order section from real pole at s = -1 + // Bilinear transform: s = (1 - z^-1) / (1 + z^-1) * (1/wc) + // H(s) = wc / (s + wc) -> H(z) + + let k = wc / (1.0 + wc); + sections.push(Sos { + b0: k, + b1: k, + b2: 0.0, + a1: (wc - 1.0) / (wc + 1.0), + a2: 0.0, + }); + } + + // Second-order sections from complex conjugate pole pairs + let num_biquads = order / 2; + for i in 0..num_biquads { + // Butterworth poles are evenly spaced on the left half of the unit circle + // For order n, pole angles are: theta_k = pi * (2k + n + 1) / (2n) + // For the k-th biquad (0-indexed), we use poles k and (n-1-k) + + // Pole angle for this biquad section + // Using: theta = pi * (2*i + 1 + (order % 2)) / (2 * order) + pi/2 + let k_index = i as f64; + let theta = PI * (2.0 * k_index + 1.0 + (order % 2) as f64) / (2.0 * order as f64); + + // Analog prototype pole: p = -sin(theta) + j*cos(theta) (on unit circle) + // For Butterworth, the damping factor relates to theta + // Q = 1 / (2 * cos(theta)) but we use the direct pole representation + + // The second-order analog section is: + // H(s) = wc^2 / (s^2 + 2*zeta*wc*s + wc^2) + // where zeta = sin(theta) = cos(pi/2 - theta) + + let zeta = theta.sin(); // damping ratio for this section + + // Bilinear transform of second-order lowpass section + // Using s = (2/T) * (1 - z^-1) / (1 + z^-1), with T = 2 (normalized) + // and pre-warped wc + + let wc2 = wc * wc; + let two_zeta_wc = 2.0 * zeta * wc; + + // Denominator: s^2 + 2*zeta*wc*s + wc^2 + // After bilinear: (1 + a1*z^-1 + a2*z^-2) * norm + let denom = 1.0 + two_zeta_wc + wc2; + + let a1 = 2.0 * (wc2 - 1.0) / denom; + let a2 = (1.0 - two_zeta_wc + wc2) / denom; + + // Numerator: wc^2 + // After bilinear: (b0 + b1*z^-1 + b2*z^-2) + let b0 = wc2 / denom; + let b1 = 2.0 * wc2 / denom; + let b2 = wc2 / denom; + + sections.push(Sos { b0, b1, b2, a1, a2 }); + } + + sections +} + +/// Generate Butterworth highpass second-order sections +/// +/// Uses the lowpass-to-highpass transformation in the analog domain: +/// s -> wc/s, then applies bilinear transform +fn butterworth_highpass_sos(cutoff: f64, order: usize) -> Vec { + if order == 0 { + return vec![]; + } + + // Pre-warp the cutoff frequency + let wc = (PI * cutoff).tan(); + + let mut sections = Vec::new(); + + // For odd orders, we have one first-order section + if order % 2 == 1 { + // First-order highpass: H(s) = s / (s + wc) + // After bilinear transform: + let k = 1.0 / (1.0 + wc); + sections.push(Sos { + b0: k, + b1: -k, + b2: 0.0, + a1: (wc - 1.0) / (wc + 1.0), + a2: 0.0, + }); + } + + // Second-order sections + let num_biquads = order / 2; + for i in 0..num_biquads { + let k_index = i as f64; + let theta = PI * (2.0 * k_index + 1.0 + (order % 2) as f64) / (2.0 * order as f64); + let zeta = theta.sin(); + + // Highpass second-order section: H(s) = s^2 / (s^2 + 2*zeta*wc*s + wc^2) + // After bilinear transform: + + let wc2 = wc * wc; + let two_zeta_wc = 2.0 * zeta * wc; + let denom = 1.0 + two_zeta_wc + wc2; + + let a1 = 2.0 * (wc2 - 1.0) / denom; + let a2 = (1.0 - two_zeta_wc + wc2) / denom; + + // Numerator for highpass: s^2 -> (1 - z^-1)^2 / (1 + z^-1)^2 after bilinear + // Normalized: (1 - 2*z^-1 + z^-2) / denom + let norm = 1.0 / denom; + let b0 = norm; + let b1 = -2.0 * norm; + let b2 = norm; + + sections.push(Sos { b0, b1, b2, a1, a2 }); + } + + sections +} + +/// Apply a cascade of SOS sections to data +#[allow(dead_code)] +fn sos_filter(data: &[f64], sos: &[Sos], zi: Option<&[[f64; 2]]>) -> Vec { + if data.is_empty() || sos.is_empty() { + return data.to_vec(); + } + + let mut result = data.to_vec(); + + for (i, section) in sos.iter().enumerate() { + let initial = zi.and_then(|z| z.get(i).copied()); + let (filtered, _) = section.filter(&result, initial); + result = filtered; + } + + result +} + +/// Compute initial conditions for steady-state filtering +fn sos_compute_zi(sos: &[Sos], x0: f64) -> Vec<[f64; 2]> { + sos.iter().map(|s| s.compute_zi(x0)).collect() +} + +/// Reflect-pad the signal to reduce edge transients +/// Pads with reflected values at both ends +fn reflect_pad(data: &[f64], pad_len: usize) -> Vec { + if data.len() < 2 { + return data.to_vec(); + } + + let n = data.len(); + let pad_len = pad_len.min(n - 1); // Can't pad more than data length - 1 + + let mut padded = Vec::with_capacity(n + 2 * pad_len); + + // Left padding: reflect about first element + // data[0] - (data[pad_len] - data[0]), data[0] - (data[pad_len-1] - data[0]), ... + for i in (1..=pad_len).rev() { + padded.push(2.0 * data[0] - data[i]); + } + + // Original data + padded.extend_from_slice(data); + + // Right padding: reflect about last element + for i in 1..=pad_len { + let idx = n - 1 - i; + padded.push(2.0 * data[n - 1] - data[idx]); + } + + padded +} + +/// Zero-phase filtering using forward-backward filtering with edge padding +/// This is equivalent to scipy's filtfilt +fn sosfiltfilt(data: &[f64], sos: &[Sos]) -> Vec { + if data.is_empty() || sos.is_empty() { + return data.to_vec(); + } + + // Check for NaN/Inf in input + if data.iter().any(|&x| !x.is_finite()) { + // Replace non-finite values with interpolated values or zeros + let cleaned: Vec = data + .iter() + .map(|&x| if x.is_finite() { x } else { 0.0 }) + .collect(); + return sosfiltfilt(&cleaned, sos); + } + + let n = data.len(); + + // Padding length: 3 * max(len(a), len(b)) per scipy, which is 3*3 = 9 per section + // For cascaded sections, use 3 * order + let pad_len = (3 * sos.len() * 2).min(n - 1).max(1); + + // Pad the signal + let padded = reflect_pad(data, pad_len); + + // Compute initial conditions based on the padded edge value + let zi_forward = sos_compute_zi(sos, padded[0]); + + // Forward pass with initial conditions + let mut forward = padded.clone(); + for (i, section) in sos.iter().enumerate() { + let (filtered, _) = section.filter(&forward, Some(zi_forward[i])); + forward = filtered; + } + + // Reverse + forward.reverse(); + + // Compute initial conditions for backward pass + let zi_backward = sos_compute_zi(sos, forward[0]); + + // Backward pass with initial conditions + let mut backward = forward; + for (i, section) in sos.iter().enumerate() { + let (filtered, _) = section.filter(&backward, Some(zi_backward[i])); + backward = filtered; + } + + // Reverse back + backward.reverse(); + + // Remove padding + backward[pad_len..pad_len + n].to_vec() +} + +/// Butterworth lowpass filter with zero-phase filtering +/// +/// Uses second-order sections for numerical stability and +/// forward-backward filtering to eliminate phase distortion. +/// +/// # Arguments +/// * `data` - Input signal +/// * `cutoff` - Normalized cutoff frequency (0 < cutoff < 0.5, where 0.5 = Nyquist) +/// * `order` - Filter order (1-8) +/// +/// # Returns +/// Filtered signal with same length as input +pub fn butterworth_lowpass_filtfilt(data: &[f64], cutoff: f64, order: usize) -> Vec { + if data.is_empty() { + return vec![]; + } + + // Clamp order to valid range + let order = order.clamp(1, 8); + + // Clamp cutoff to valid range (with small margin from boundaries) + let cutoff = cutoff.clamp(0.001, 0.499); + + let sos = butterworth_lowpass_sos(cutoff, order); + sosfiltfilt(data, &sos) +} + +/// Butterworth highpass filter with zero-phase filtering +/// +/// Uses second-order sections for numerical stability and +/// forward-backward filtering to eliminate phase distortion. +/// +/// # Arguments +/// * `data` - Input signal +/// * `cutoff` - Normalized cutoff frequency (0 < cutoff < 0.5, where 0.5 = Nyquist) +/// * `order` - Filter order (1-8) +/// +/// # Returns +/// Filtered signal with same length as input +pub fn butterworth_highpass_filtfilt(data: &[f64], cutoff: f64, order: usize) -> Vec { + if data.is_empty() { + return vec![]; + } + + // Clamp order to valid range + let order = order.clamp(1, 8); + + // Clamp cutoff to valid range + let cutoff = cutoff.clamp(0.001, 0.499); + + let sos = butterworth_highpass_sos(cutoff, order); + sosfiltfilt(data, &sos) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_moving_average() { + let data = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let result = moving_average(&data, 3); + + assert_eq!(result.len(), 5); + assert!((result[0] - 1.0).abs() < 0.001); // First value is just itself + assert!((result[1] - 1.5).abs() < 0.001); // (1+2)/2 + assert!((result[2] - 2.0).abs() < 0.001); // (1+2+3)/3 + assert!((result[3] - 3.0).abs() < 0.001); // (2+3+4)/3 + assert!((result[4] - 4.0).abs() < 0.001); // (3+4+5)/3 + } + + #[test] + fn test_exponential_moving_average() { + let data = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let result = exponential_moving_average(&data, 0.5); + + assert_eq!(result.len(), 5); + assert!((result[0] - 1.0).abs() < 0.001); + // EMA grows towards recent values + assert!(result[4] > result[0]); + } + + #[test] + fn test_median_filter() { + // Test with spike + let data = vec![1.0, 1.0, 100.0, 1.0, 1.0]; + let result = median_filter(&data, 3); + + assert_eq!(result.len(), 5); + // The spike should be removed + assert!((result[2] - 1.0).abs() < 0.001); + } + + #[test] + fn test_butterworth_lowpass_removes_high_freq() { + // Create a signal with low frequency + high frequency noise + let data: Vec = (0..200) + .map(|i| { + let t = i as f64 * 0.01; + // 1 Hz signal + 10 Hz noise (at 100 Hz sample rate) + (2.0 * PI * 1.0 * t).sin() + 0.5 * (2.0 * PI * 10.0 * t).sin() + }) + .collect(); + + // Apply lowpass filter with cutoff at 0.05 (5 Hz at 100 Hz sample rate) + let filtered = butterworth_lowpass_filtfilt(&data, 0.05, 4); + + assert_eq!(filtered.len(), data.len()); + + // The filtered signal should have reduced high frequency content + let orig_power: f64 = data.iter().map(|x| x * x).sum::(); + let filt_power: f64 = filtered.iter().map(|x| x * x).sum::(); + + // Filtered should have less power due to noise removal + assert!(filt_power < orig_power); + + // But should retain most of the low-frequency signal + assert!(filt_power > orig_power * 0.3); + } + + #[test] + fn test_butterworth_preserves_dc() { + // DC signal should pass through lowpass unchanged + let data = vec![5.0; 200]; + let filtered = butterworth_lowpass_filtfilt(&data, 0.1, 4); + + // All values should be very close to 5.0 + for &v in &filtered { + assert!((v - 5.0).abs() < 0.01, "DC not preserved: got {}", v); + } + } + + #[test] + fn test_butterworth_highpass_removes_dc() { + // DC + AC signal + let data: Vec = (0..200) + .map(|i| { + let t = i as f64 * 0.01; + 10.0 + (2.0 * PI * 5.0 * t).sin() // DC offset + 5 Hz signal + }) + .collect(); + + // Highpass with cutoff at 0.02 (2 Hz at 100 Hz sample rate) + let filtered = butterworth_highpass_filtfilt(&data, 0.02, 2); + + assert_eq!(filtered.len(), data.len()); + + // Mean should be close to zero (DC removed) + let mean: f64 = filtered.iter().sum::() / filtered.len() as f64; + assert!(mean.abs() < 1.0, "DC not removed: mean = {}", mean); + } + + #[test] + fn test_butterworth_all_orders() { + // Test that all orders 1-8 work without panicking + let data: Vec = (0..100).map(|i| (i as f64 * 0.1).sin()).collect(); + + for order in 1..=8 { + let filtered = butterworth_lowpass_filtfilt(&data, 0.2, order); + assert_eq!(filtered.len(), data.len(), "Order {} failed", order); + + // Verify no NaN values + assert!( + !filtered.iter().any(|x| x.is_nan()), + "Order {} produced NaN", + order + ); + } + } + + #[test] + fn test_butterworth_handles_nan() { + // Data with NaN values should be handled gracefully + let mut data: Vec = (0..100).map(|i| i as f64).collect(); + data[50] = f64::NAN; + + let filtered = butterworth_lowpass_filtfilt(&data, 0.1, 2); + + assert_eq!(filtered.len(), data.len()); + assert!(!filtered.iter().any(|x| x.is_nan()), "Output contains NaN"); + } + + #[test] + fn test_butterworth_edge_transients() { + // Step function - should have minimal edge transients with proper padding + let mut data = vec![0.0; 100]; + data.extend(vec![1.0; 100]); + + let filtered = butterworth_lowpass_filtfilt(&data, 0.1, 2); + + // First few samples should be close to 0 (not wildly oscillating) + assert!(filtered[0].abs() < 0.2, "Edge transient too large at start"); + + // Last few samples should be close to 1 + assert!( + (filtered[filtered.len() - 1] - 1.0).abs() < 0.2, + "Edge transient too large at end" + ); + } + + #[test] + fn test_reflect_pad() { + let data = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let padded = reflect_pad(&data, 2); + + // Should be: [2*1-3, 2*1-2, 1, 2, 3, 4, 5, 2*5-4, 2*5-3] + // = [-1, 0, 1, 2, 3, 4, 5, 6, 7] + assert_eq!(padded.len(), 9); + assert!((padded[0] - (-1.0)).abs() < 0.001); + assert!((padded[1] - 0.0).abs() < 0.001); + assert!((padded[2] - 1.0).abs() < 0.001); + assert!((padded[6] - 5.0).abs() < 0.001); + assert!((padded[7] - 6.0).abs() < 0.001); + assert!((padded[8] - 7.0).abs() < 0.001); + } + + #[test] + fn test_sos_lowpass_coefficients() { + // Verify that the SOS coefficients are reasonable + let sos = butterworth_lowpass_sos(0.2, 2); + + assert_eq!(sos.len(), 1); // Order 2 = 1 biquad + + // DC gain should be 1 (sum of b coeffs / sum of a coeffs) + let section = &sos[0]; + let b_sum = section.b0 + section.b1 + section.b2; + let a_sum = 1.0 + section.a1 + section.a2; + let dc_gain = b_sum / a_sum; + + assert!( + (dc_gain - 1.0).abs() < 0.01, + "DC gain should be 1, got {}", + dc_gain + ); + } + + #[test] + fn test_sos_highpass_coefficients() { + // Verify highpass has zero DC gain + let sos = butterworth_highpass_sos(0.2, 2); + + assert_eq!(sos.len(), 1); + + // DC gain should be 0 for highpass + let section = &sos[0]; + let b_sum = section.b0 + section.b1 + section.b2; + + assert!(b_sum.abs() < 0.01, "HP DC gain should be 0, got {}", b_sum); + } + + #[test] + fn test_butterworth_symmetry() { + // Zero-phase filtering should be symmetric around center + let mut data = vec![0.0; 50]; + data.push(1.0); // Impulse at center + data.extend(vec![0.0; 49]); + + let filtered = butterworth_lowpass_filtfilt(&data, 0.1, 2); + + // Response should be symmetric around the impulse + let center = 50; + for i in 1..20 { + let left = filtered[center - i]; + let right = filtered[center + i]; + assert!( + (left - right).abs() < 0.01, + "Not symmetric at offset {}: left={}, right={}", + i, + left, + right + ); + } + } +} diff --git a/src/analysis/mod.rs b/src/analysis/mod.rs new file mode 100644 index 0000000..17de11e --- /dev/null +++ b/src/analysis/mod.rs @@ -0,0 +1,346 @@ +//! Analysis module for ECU log analysis algorithms. +//! +//! This module provides a unified framework for implementing analysis algorithms +//! that can process log data and produce results that integrate with UltraLog's +//! computed channels system. +//! +//! The architecture follows a trait-based design where each analyzer implements +//! the `Analyzer` trait, enabling: +//! - Dynamic discovery of available analyzers based on loaded channels +//! - Configurable parameters via UI +//! - Results that can be visualized or converted to computed channels + +pub mod afr; +pub mod derived; +pub mod filters; +pub mod statistics; + +use crate::parsers::types::Log; +use std::collections::HashMap; +use std::time::Instant; + +/// Errors that can occur during analysis +#[derive(Debug, Clone)] +pub enum AnalysisError { + /// A required channel is missing from the log data + MissingChannel(String), + /// Not enough data points for the analysis + InsufficientData { needed: usize, got: usize }, + /// Invalid parameter configuration + InvalidParameter(String), + /// General computation error + ComputationError(String), +} + +impl std::fmt::Display for AnalysisError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AnalysisError::MissingChannel(ch) => write!(f, "Missing required channel: {}", ch), + AnalysisError::InsufficientData { needed, got } => { + write!(f, "Insufficient data: need {} points, got {}", needed, got) + } + AnalysisError::InvalidParameter(msg) => write!(f, "Invalid parameter: {}", msg), + AnalysisError::ComputationError(msg) => write!(f, "Computation error: {}", msg), + } + } +} + +impl std::error::Error for AnalysisError {} + +/// Metadata about analysis results for UI display +#[derive(Clone, Debug, Default)] +pub struct AnalysisMetadata { + /// Name of the algorithm used + pub algorithm: String, + /// Key parameters and their values + pub parameters: Vec<(String, String)>, + /// Warning messages about the analysis + pub warnings: Vec, + /// Time taken for computation in milliseconds + pub computation_time_ms: u64, +} + +/// Result of an analysis operation +#[derive(Clone, Debug)] +pub struct AnalysisResult { + /// Name for the result (used as channel name if added) + pub name: String, + /// Unit for the result values + pub unit: String, + /// The computed values (one per timestamp) + pub values: Vec, + /// Metadata about the analysis + pub metadata: AnalysisMetadata, +} + +impl AnalysisResult { + /// Create a new analysis result + pub fn new(name: impl Into, unit: impl Into, values: Vec) -> Self { + Self { + name: name.into(), + unit: unit.into(), + values, + metadata: AnalysisMetadata::default(), + } + } + + /// Add metadata to the result + pub fn with_metadata(mut self, metadata: AnalysisMetadata) -> Self { + self.metadata = metadata; + self + } + + /// Check if the analysis produced any warnings + pub fn has_warnings(&self) -> bool { + !self.metadata.warnings.is_empty() + } +} + +/// Configuration for an analyzer that can be serialized +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct AnalyzerConfig { + /// Unique identifier for the analyzer + pub id: String, + /// Human-readable name + pub name: String, + /// Parameter values as key-value pairs + pub parameters: HashMap, +} + +/// Core trait for all analysis algorithms +pub trait Analyzer: Send + Sync { + /// Unique identifier for this analyzer + fn id(&self) -> &str; + + /// Human-readable algorithm name + fn name(&self) -> &str; + + /// Description for UI tooltips + fn description(&self) -> &str; + + /// Category for grouping in UI (e.g., "Filters", "Statistics", "AFR", "Knock") + fn category(&self) -> &str; + + /// List of required channel names (normalized names preferred) + fn required_channels(&self) -> Vec<&str>; + + /// Optional channels that enhance analysis if present + fn optional_channels(&self) -> Vec<&str> { + vec![] + } + + /// Execute analysis on log data + fn analyze(&self, log: &Log) -> Result; + + /// Get current configuration + fn get_config(&self) -> AnalyzerConfig; + + /// Apply configuration + fn set_config(&mut self, config: &AnalyzerConfig); + + /// Clone into a boxed trait object + fn clone_box(&self) -> Box; +} + +impl Clone for Box { + fn clone(&self) -> Self { + self.clone_box() + } +} + +/// Helper trait for accessing log data by channel name +pub trait LogDataAccess { + /// Get channel values by name (case-insensitive) + fn get_channel_values(&self, name: &str) -> Option>; + + /// Check if a channel exists + fn has_channel(&self, name: &str) -> bool; + + /// Get all channel names + fn channel_names(&self) -> Vec; + + /// Get the time vector + fn times(&self) -> &[f64]; +} + +impl LogDataAccess for Log { + fn get_channel_values(&self, name: &str) -> Option> { + // Find the channel index (case-insensitive) + let channel_idx = self + .channels + .iter() + .position(|c| c.name().eq_ignore_ascii_case(name))?; + + // Extract values from the data matrix + let values: Vec = self + .data + .iter() + .filter_map(|row| row.get(channel_idx).map(|v| v.as_f64())) + .collect(); + + Some(values) + } + + fn has_channel(&self, name: &str) -> bool { + self.channels + .iter() + .any(|c| c.name().eq_ignore_ascii_case(name)) + } + + fn channel_names(&self) -> Vec { + self.channels.iter().map(|c| c.name()).collect() + } + + fn times(&self) -> &[f64] { + &self.times + } +} + +/// Registry of available analyzers +#[derive(Default)] +pub struct AnalyzerRegistry { + analyzers: Vec>, +} + +impl AnalyzerRegistry { + /// Create a new registry with default analyzers + pub fn new() -> Self { + let mut registry = Self { + analyzers: Vec::new(), + }; + + // Register built-in analyzers + registry.register_defaults(); + + registry + } + + /// Register default analyzers + fn register_defaults(&mut self) { + // Filters + self.register(Box::new(filters::MovingAverageAnalyzer::default())); + self.register(Box::new( + filters::ExponentialMovingAverageAnalyzer::default(), + )); + self.register(Box::new(filters::MedianFilterAnalyzer::default())); + self.register(Box::new(filters::ButterworthLowpassAnalyzer::default())); + self.register(Box::new(filters::ButterworthHighpassAnalyzer::default())); + + // Statistics + self.register(Box::new(statistics::DescriptiveStatsAnalyzer::default())); + self.register(Box::new(statistics::CorrelationAnalyzer::default())); + self.register(Box::new(statistics::RateOfChangeAnalyzer::default())); + + // AFR Analysis + self.register(Box::new(afr::FuelTrimDriftAnalyzer::default())); + self.register(Box::new(afr::RichLeanZoneAnalyzer::default())); + self.register(Box::new(afr::AfrDeviationAnalyzer::default())); + + // Derived Calculations + self.register(Box::new(derived::VolumetricEfficiencyAnalyzer::default())); + self.register(Box::new(derived::InjectorDutyCycleAnalyzer::default())); + self.register(Box::new(derived::LambdaCalculator::default())); + } + + /// Register a new analyzer + pub fn register(&mut self, analyzer: Box) { + self.analyzers.push(analyzer); + } + + /// Get all registered analyzers + pub fn all(&self) -> &[Box] { + &self.analyzers + } + + /// Get analyzers available for the given log data + pub fn available_for(&self, log: &Log) -> Vec<&dyn Analyzer> { + self.analyzers + .iter() + .filter(|a| a.required_channels().iter().all(|ch| log.has_channel(ch))) + .map(|a| a.as_ref()) + .collect() + } + + /// Get analyzers by category + pub fn by_category(&self) -> HashMap> { + let mut categories: HashMap> = HashMap::new(); + + for analyzer in &self.analyzers { + categories + .entry(analyzer.category().to_string()) + .or_default() + .push(analyzer.as_ref()); + } + + categories + } + + /// Find an analyzer by ID + pub fn find_by_id(&self, id: &str) -> Option<&dyn Analyzer> { + self.analyzers + .iter() + .find(|a| a.id() == id) + .map(|a| a.as_ref()) + } + + /// Find an analyzer by ID and return a mutable reference + pub fn find_by_id_mut(&mut self, id: &str) -> Option<&mut Box> { + self.analyzers.iter_mut().find(|a| a.id() == id) + } +} + +/// Helper function to measure analysis execution time +pub fn timed_analyze(f: F) -> (T, u64) +where + F: FnOnce() -> T, +{ + let start = Instant::now(); + let result = f(); + let elapsed = start.elapsed().as_millis() as u64; + (result, elapsed) +} + +/// Helper to get a required channel or return an error +pub fn require_channel(log: &Log, name: &str) -> Result, AnalysisError> { + log.get_channel_values(name) + .ok_or_else(|| AnalysisError::MissingChannel(name.to_string())) +} + +/// Helper to check minimum data length +pub fn require_min_length(data: &[f64], min_len: usize) -> Result<(), AnalysisError> { + if data.len() < min_len { + Err(AnalysisError::InsufficientData { + needed: min_len, + got: data.len(), + }) + } else { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_analysis_error_display() { + let err = AnalysisError::MissingChannel("RPM".to_string()); + assert!(err.to_string().contains("RPM")); + + let err = AnalysisError::InsufficientData { + needed: 100, + got: 50, + }; + assert!(err.to_string().contains("100")); + assert!(err.to_string().contains("50")); + } + + #[test] + fn test_analysis_result_new() { + let result = AnalysisResult::new("Test", "units", vec![1.0, 2.0, 3.0]); + assert_eq!(result.name, "Test"); + assert_eq!(result.unit, "units"); + assert_eq!(result.values.len(), 3); + assert!(!result.has_warnings()); + } +} diff --git a/src/analysis/statistics.rs b/src/analysis/statistics.rs new file mode 100644 index 0000000..336c251 --- /dev/null +++ b/src/analysis/statistics.rs @@ -0,0 +1,602 @@ +//! Statistical analysis algorithms. +//! +//! Provides statistical measures, correlation analysis, and data characterization. + +use super::*; +use std::collections::HashMap; + +/// Descriptive statistics analyzer +#[derive(Clone)] +pub struct DescriptiveStatsAnalyzer { + /// Channel to analyze + pub channel: String, +} + +impl Default for DescriptiveStatsAnalyzer { + fn default() -> Self { + Self { + channel: "RPM".to_string(), + } + } +} + +impl Analyzer for DescriptiveStatsAnalyzer { + fn id(&self) -> &str { + "descriptive_stats" + } + + fn name(&self) -> &str { + "Descriptive Statistics" + } + + fn description(&self) -> &str { + "Computes basic statistics: mean, median, standard deviation, min, max, \ + range, and coefficient of variation for a channel." + } + + fn category(&self) -> &str { + "Statistics" + } + + fn required_channels(&self) -> Vec<&str> { + vec![&self.channel] + } + + fn analyze(&self, log: &Log) -> Result { + let data = require_channel(log, &self.channel)?; + require_min_length(&data, 2)?; + + let (stats, computation_time) = timed_analyze(|| compute_descriptive_stats(&data)); + + let mut warnings = vec![]; + + // Warn about high coefficient of variation + if stats.cv > 50.0 { + warnings.push(format!( + "High variability detected (CV={:.1}%) - signal may be noisy", + stats.cv + )); + } + + // Create a "normalized" version of the data for visualization (z-scores) + let z_scores: Vec = data + .iter() + .map(|&x| (x - stats.mean) / stats.stdev.max(0.001)) + .collect(); + + Ok(AnalysisResult { + name: format!("{} Z-Score", self.channel), + unit: "σ".to_string(), + values: z_scores, + metadata: AnalysisMetadata { + algorithm: "Descriptive Statistics".to_string(), + parameters: vec![ + ("mean".to_string(), format!("{:.4}", stats.mean)), + ("median".to_string(), format!("{:.4}", stats.median)), + ("stdev".to_string(), format!("{:.4}", stats.stdev)), + ("min".to_string(), format!("{:.4}", stats.min)), + ("max".to_string(), format!("{:.4}", stats.max)), + ("range".to_string(), format!("{:.4}", stats.range)), + ("cv".to_string(), format!("{:.2}%", stats.cv)), + ("n".to_string(), stats.count.to_string()), + ], + warnings, + computation_time_ms: computation_time, + }, + }) + } + + fn get_config(&self) -> AnalyzerConfig { + let mut params = HashMap::new(); + params.insert("channel".to_string(), self.channel.clone()); + + AnalyzerConfig { + id: self.id().to_string(), + name: self.name().to_string(), + parameters: params, + } + } + + fn set_config(&mut self, config: &AnalyzerConfig) { + if let Some(ch) = config.parameters.get("channel") { + self.channel = ch.clone(); + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +/// Correlation analyzer between two channels +#[derive(Clone)] +pub struct CorrelationAnalyzer { + /// First channel (X) + pub channel_x: String, + /// Second channel (Y) + pub channel_y: String, +} + +impl Default for CorrelationAnalyzer { + fn default() -> Self { + Self { + channel_x: "RPM".to_string(), + channel_y: "MAP".to_string(), + } + } +} + +impl Analyzer for CorrelationAnalyzer { + fn id(&self) -> &str { + "correlation" + } + + fn name(&self) -> &str { + "Channel Correlation" + } + + fn description(&self) -> &str { + "Computes Pearson correlation coefficient between two channels. \ + Values near ±1 indicate strong linear relationship." + } + + fn category(&self) -> &str { + "Statistics" + } + + fn required_channels(&self) -> Vec<&str> { + vec![&self.channel_x, &self.channel_y] + } + + fn analyze(&self, log: &Log) -> Result { + let x = require_channel(log, &self.channel_x)?; + let y = require_channel(log, &self.channel_y)?; + + if x.len() != y.len() { + return Err(AnalysisError::ComputationError( + "Channels have different lengths".to_string(), + )); + } + + require_min_length(&x, 3)?; + + let (correlation, computation_time) = timed_analyze(|| pearson_correlation(&x, &y)); + + let mut warnings = vec![]; + let r = correlation.r; + + // Interpret correlation strength + let strength = if r.abs() > 0.9 { + "very strong" + } else if r.abs() > 0.7 { + "strong" + } else if r.abs() > 0.5 { + "moderate" + } else if r.abs() > 0.3 { + "weak" + } else { + "very weak/none" + }; + + let direction = if r > 0.0 { "positive" } else { "negative" }; + + warnings.push(format!( + "Correlation is {} {} (r={:.3})", + strength, direction, r + )); + + // Create residuals for visualization + let residuals = compute_residuals(&x, &y); + + Ok(AnalysisResult { + name: format!("{} vs {} Residuals", self.channel_x, self.channel_y), + unit: String::new(), + values: residuals, + metadata: AnalysisMetadata { + algorithm: "Pearson Correlation".to_string(), + parameters: vec![ + ("r".to_string(), format!("{:.4}", r)), + ("r²".to_string(), format!("{:.4}", r * r)), + ("channel_x".to_string(), self.channel_x.clone()), + ("channel_y".to_string(), self.channel_y.clone()), + ], + warnings, + computation_time_ms: computation_time, + }, + }) + } + + fn get_config(&self) -> AnalyzerConfig { + let mut params = HashMap::new(); + params.insert("channel_x".to_string(), self.channel_x.clone()); + params.insert("channel_y".to_string(), self.channel_y.clone()); + + AnalyzerConfig { + id: self.id().to_string(), + name: self.name().to_string(), + parameters: params, + } + } + + fn set_config(&mut self, config: &AnalyzerConfig) { + if let Some(ch) = config.parameters.get("channel_x") { + self.channel_x = ch.clone(); + } + if let Some(ch) = config.parameters.get("channel_y") { + self.channel_y = ch.clone(); + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +/// Rate of change analyzer +#[derive(Clone)] +pub struct RateOfChangeAnalyzer { + /// Channel to analyze + pub channel: String, + /// Use time-based derivative (true) or sample-based (false) + pub time_based: bool, +} + +impl Default for RateOfChangeAnalyzer { + fn default() -> Self { + Self { + channel: "RPM".to_string(), + time_based: true, + } + } +} + +impl Analyzer for RateOfChangeAnalyzer { + fn id(&self) -> &str { + "rate_of_change" + } + + fn name(&self) -> &str { + "Rate of Change" + } + + fn description(&self) -> &str { + "Computes the derivative (rate of change) of a channel. Time-based mode \ + gives units per second; sample-based gives units per sample." + } + + fn category(&self) -> &str { + "Statistics" + } + + fn required_channels(&self) -> Vec<&str> { + vec![&self.channel] + } + + fn analyze(&self, log: &Log) -> Result { + let data = require_channel(log, &self.channel)?; + require_min_length(&data, 2)?; + + let times = log.times(); + if times.len() != data.len() { + return Err(AnalysisError::ComputationError( + "Data and time vectors have different lengths".to_string(), + )); + } + + let (derivative, computation_time) = timed_analyze(|| { + if self.time_based { + time_derivative(&data, times) + } else { + sample_derivative(&data) + } + }); + + // Compute statistics on the derivative + let stats = compute_descriptive_stats(&derivative); + + let mut warnings = vec![]; + + // Warn about high rate of change + let max_abs_rate = stats.max.abs().max(stats.min.abs()); + if max_abs_rate > stats.stdev * 5.0 { + warnings.push(format!( + "Extreme rate of change detected: max |dv/dt| = {:.2}", + max_abs_rate + )); + } + + let unit = if self.time_based { "/s" } else { "/sample" }; + + Ok(AnalysisResult { + name: format!("d({})/dt", self.channel), + unit: unit.to_string(), + values: derivative, + metadata: AnalysisMetadata { + algorithm: if self.time_based { + "Time-based Derivative" + } else { + "Sample-based Derivative" + } + .to_string(), + parameters: vec![ + ("channel".to_string(), self.channel.clone()), + ("mean_rate".to_string(), format!("{:.4}", stats.mean)), + ("max_rate".to_string(), format!("{:.4}", stats.max)), + ("min_rate".to_string(), format!("{:.4}", stats.min)), + ], + warnings, + computation_time_ms: computation_time, + }, + }) + } + + fn get_config(&self) -> AnalyzerConfig { + let mut params = HashMap::new(); + params.insert("channel".to_string(), self.channel.clone()); + params.insert("time_based".to_string(), self.time_based.to_string()); + + AnalyzerConfig { + id: self.id().to_string(), + name: self.name().to_string(), + parameters: params, + } + } + + fn set_config(&mut self, config: &AnalyzerConfig) { + if let Some(ch) = config.parameters.get("channel") { + self.channel = ch.clone(); + } + if let Some(tb) = config.parameters.get("time_based") { + self.time_based = tb.parse().unwrap_or(true); + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +// ============================================================================ +// Core statistics implementations +// ============================================================================ + +/// Container for descriptive statistics +#[derive(Clone, Debug, Default)] +pub struct DescriptiveStats { + pub count: usize, + pub mean: f64, + pub median: f64, + pub stdev: f64, + pub min: f64, + pub max: f64, + pub range: f64, + pub cv: f64, // Coefficient of variation (%) +} + +/// Compute descriptive statistics for a dataset +pub fn compute_descriptive_stats(data: &[f64]) -> DescriptiveStats { + if data.is_empty() { + return DescriptiveStats::default(); + } + + let n = data.len(); + + // Mean (Welford's algorithm for numerical stability) + let mean = data.iter().sum::() / n as f64; + + // Variance (two-pass for stability) + let variance = data.iter().map(|x| (x - mean).powi(2)).sum::() / (n - 1).max(1) as f64; + let stdev = variance.sqrt(); + + // Min/Max + let min = data.iter().cloned().fold(f64::INFINITY, f64::min); + let max = data.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + + // Median (requires sorting) + let mut sorted = data.to_vec(); + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + #[allow(clippy::manual_is_multiple_of)] + let median = if n % 2 == 0 { + (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0 + } else { + sorted[n / 2] + }; + + // Coefficient of variation + let cv = if mean.abs() > f64::EPSILON { + (stdev / mean.abs()) * 100.0 + } else { + 0.0 + }; + + DescriptiveStats { + count: n, + mean, + median, + stdev, + min, + max, + range: max - min, + cv, + } +} + +/// Container for correlation results +#[derive(Clone, Debug, Default)] +pub struct CorrelationResult { + pub r: f64, // Pearson correlation coefficient + pub r_squared: f64, // Coefficient of determination +} + +/// Compute Pearson correlation coefficient +pub fn pearson_correlation(x: &[f64], y: &[f64]) -> CorrelationResult { + if x.len() != y.len() || x.len() < 2 { + return CorrelationResult::default(); + } + + let n = x.len() as f64; + let mean_x = x.iter().sum::() / n; + let mean_y = y.iter().sum::() / n; + + let mut cov = 0.0; + let mut var_x = 0.0; + let mut var_y = 0.0; + + for i in 0..x.len() { + let dx = x[i] - mean_x; + let dy = y[i] - mean_y; + cov += dx * dy; + var_x += dx * dx; + var_y += dy * dy; + } + + let denom = (var_x * var_y).sqrt(); + let r = if denom > f64::EPSILON { + cov / denom + } else { + 0.0 + }; + + CorrelationResult { + r, + r_squared: r * r, + } +} + +/// Compute residuals from linear regression +pub fn compute_residuals(x: &[f64], y: &[f64]) -> Vec { + if x.len() != y.len() || x.len() < 2 { + return vec![]; + } + + let n = x.len() as f64; + let mean_x = x.iter().sum::() / n; + let mean_y = y.iter().sum::() / n; + + // Compute slope and intercept + let mut num = 0.0; + let mut den = 0.0; + for i in 0..x.len() { + let dx = x[i] - mean_x; + num += dx * (y[i] - mean_y); + den += dx * dx; + } + + let slope = if den.abs() > f64::EPSILON { + num / den + } else { + 0.0 + }; + let intercept = mean_y - slope * mean_x; + + // Compute residuals + x.iter() + .zip(y.iter()) + .map(|(&xi, &yi)| yi - (slope * xi + intercept)) + .collect() +} + +/// Compute time-based derivative using central differences +pub fn time_derivative(data: &[f64], times: &[f64]) -> Vec { + if data.len() < 2 || times.len() != data.len() { + return vec![0.0; data.len()]; + } + + let mut result = Vec::with_capacity(data.len()); + + // Forward difference for first point + let dt = times[1] - times[0]; + if dt.abs() > f64::EPSILON { + result.push((data[1] - data[0]) / dt); + } else { + result.push(0.0); + } + + // Central differences for interior points + for i in 1..data.len() - 1 { + let dt = times[i + 1] - times[i - 1]; + if dt.abs() > f64::EPSILON { + result.push((data[i + 1] - data[i - 1]) / dt); + } else { + result.push(0.0); + } + } + + // Backward difference for last point + let dt = times[data.len() - 1] - times[data.len() - 2]; + if dt.abs() > f64::EPSILON { + result.push((data[data.len() - 1] - data[data.len() - 2]) / dt); + } else { + result.push(0.0); + } + + result +} + +/// Compute sample-based derivative (simple differences) +pub fn sample_derivative(data: &[f64]) -> Vec { + if data.len() < 2 { + return vec![0.0; data.len()]; + } + + let mut result = Vec::with_capacity(data.len()); + + // Forward difference for first point + result.push(data[1] - data[0]); + + // Central differences for interior points + for i in 1..data.len() - 1 { + result.push((data[i + 1] - data[i - 1]) / 2.0); + } + + // Backward difference for last point + result.push(data[data.len() - 1] - data[data.len() - 2]); + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_descriptive_stats() { + let data = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let stats = compute_descriptive_stats(&data); + + assert_eq!(stats.count, 5); + assert!((stats.mean - 3.0).abs() < 0.001); + assert!((stats.median - 3.0).abs() < 0.001); + assert!((stats.min - 1.0).abs() < 0.001); + assert!((stats.max - 5.0).abs() < 0.001); + assert!((stats.range - 4.0).abs() < 0.001); + } + + #[test] + fn test_pearson_correlation_perfect() { + let x = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let y = vec![2.0, 4.0, 6.0, 8.0, 10.0]; // Perfect positive correlation + let result = pearson_correlation(&x, &y); + + assert!((result.r - 1.0).abs() < 0.001); + } + + #[test] + fn test_pearson_correlation_negative() { + let x = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let y = vec![10.0, 8.0, 6.0, 4.0, 2.0]; // Perfect negative correlation + let result = pearson_correlation(&x, &y); + + assert!((result.r + 1.0).abs() < 0.001); + } + + #[test] + fn test_time_derivative() { + let data = vec![0.0, 1.0, 4.0, 9.0, 16.0]; // y = x^2 at x=0,1,2,3,4 + let times = vec![0.0, 1.0, 2.0, 3.0, 4.0]; + let derivative = time_derivative(&data, ×); + + // dy/dx = 2x, so at x=2, derivative should be ~4 + assert!((derivative[2] - 4.0).abs() < 0.001); + } +} diff --git a/src/app.rs b/src/app.rs index 5823d37..cfdee2f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -11,13 +11,16 @@ use std::path::PathBuf; use std::sync::mpsc::{channel, Receiver, Sender}; use std::thread; +use crate::analysis::{AnalysisResult, AnalyzerRegistry}; use crate::analytics; use crate::computed::{ComputedChannel, ComputedChannelLibrary, FormulaEditorState}; -use crate::parsers::{Aim, EcuMaster, EcuType, Haltech, Link, Parseable, RomRaider, Speeduino}; +use crate::parsers::{ + Aim, EcuMaster, EcuType, Emerald, Haltech, Link, Parseable, RomRaider, Speeduino, +}; use crate::state::{ - ActiveTool, CacheKey, LoadResult, LoadedFile, LoadingState, ScatterPlotConfig, - ScatterPlotState, SelectedChannel, Tab, ToastType, CHART_COLORS, COLORBLIND_COLORS, - MAX_CHANNELS, + ActivePanel, ActiveTool, CacheKey, FontScale, LoadResult, LoadedFile, LoadingState, + ScatterPlotConfig, ScatterPlotState, SelectedChannel, Tab, ToastType, CHART_COLORS, + COLORBLIND_COLORS, MAX_CHANNELS, }; use crate::units::UnitPreferences; use crate::updater::{DownloadResult, UpdateCheckResult, UpdateState}; @@ -73,6 +76,8 @@ pub struct UltraLogApp { // === Unit Preferences === /// User preferences for display units pub(crate) unit_preferences: UnitPreferences, + /// User preference for UI font size scaling + pub(crate) font_scale: FontScale, // === Custom Field Normalization === /// Custom user-defined field name mappings (source name -> normalized name) pub(crate) custom_normalizations: HashMap, @@ -89,6 +94,9 @@ pub struct UltraLogApp { // === Tool/View Selection === /// Currently active tool/view pub(crate) active_tool: ActiveTool, + // === Panel Selection === + /// Currently active side panel (activity bar selection) + pub(crate) active_panel: ActivePanel, // === Tab Management === /// Open tabs (one per log file being viewed) pub(crate) tabs: Vec, @@ -116,8 +124,21 @@ pub struct UltraLogApp { pub(crate) file_computed_channels: HashMap>, /// Whether to show the computed channels manager dialog pub(crate) show_computed_channels_manager: bool, + /// Search filter for computed channels library + pub(crate) computed_channels_search: String, + /// Whether to show the computed channels help popup + pub(crate) show_computed_channels_help: bool, /// State for the formula editor dialog pub(crate) formula_editor_state: FormulaEditorState, + // === Analysis System === + /// Registry of available analyzers + pub(crate) analyzer_registry: AnalyzerRegistry, + /// Results from running analyzers (stored per file) + pub(crate) analysis_results: HashMap>, + /// Whether to show the analysis panel + pub(crate) show_analysis_panel: bool, + /// Selected category in analysis panel (None = show all) + pub(crate) analysis_selected_category: Option, } impl Default for UltraLogApp { @@ -143,6 +164,7 @@ impl Default for UltraLogApp { field_normalization: true, // Enabled by default for better readability initial_view_seconds: 60.0, // Start with 60 second view unit_preferences: UnitPreferences::default(), + font_scale: FontScale::default(), custom_normalizations: HashMap::new(), show_normalization_editor: false, norm_editor_extend_source: String::new(), @@ -150,6 +172,7 @@ impl Default for UltraLogApp { norm_editor_custom_source: String::new(), norm_editor_custom_target: String::new(), active_tool: ActiveTool::default(), + active_panel: ActivePanel::default(), tabs: Vec::new(), active_tab: None, update_state: UpdateState::default(), @@ -162,7 +185,13 @@ impl Default for UltraLogApp { computed_library: ComputedChannelLibrary::load(), file_computed_channels: HashMap::new(), show_computed_channels_manager: false, + computed_channels_search: String::new(), + show_computed_channels_help: false, formula_editor_state: FormulaEditorState::default(), + analyzer_registry: AnalyzerRegistry::new(), + analysis_results: HashMap::new(), + show_analysis_panel: false, + analysis_selected_category: None, } } } @@ -219,6 +248,12 @@ impl UltraLogApp { palette[color_index % palette.len()] } + /// Get a scaled font size based on user's font scale preference + #[inline] + pub fn scaled_font(&self, base_size: f32) -> f32 { + (base_size * self.font_scale.multiplier()).round() + } + // ======================================================================== // File Loading // ======================================================================== @@ -379,6 +414,17 @@ impl UltraLogApp { e ))), } + } else if Emerald::is_emerald_path(path) + && (Emerald::detect(binary_data) || Emerald::detect_lg2(binary_data)) + { + // Emerald ECU LG1/LG2 format detected + match Emerald::parse_file(path) { + Ok(l) => Ok((l, EcuType::Emerald)), + Err(e) => Err(LoadResult::Error(format!( + "Failed to parse Emerald ECU file: {}", + e + ))), + } } else { // Try parsing as text-based formats // For mmap, we use from_utf8 which doesn't copy the data @@ -474,6 +520,9 @@ impl UltraLogApp { self.active_tab = Some(self.tabs.len() - 1); self.show_toast_success("File loaded successfully"); + + // Switch to Channels panel so user can select channels + self.active_panel = ActivePanel::Channels; } LoadResult::Error(e) => { self.show_toast_error(&format!("Error: {}", e)); @@ -1213,18 +1262,78 @@ impl UltraLogApp { /// Handle keyboard shortcuts fn handle_keyboard_shortcuts(&mut self, ctx: &egui::Context) { - // Only handle shortcuts when we have data loaded - if self.files.is_empty() || self.get_selected_channels().is_empty() { - return; - } - // Don't handle shortcuts when a text field or other widget has keyboard focus if ctx.memory(|m| m.focused().is_some()) { return; } - // Spacebar to toggle play/pause ctx.input(|i| { + let cmd = i.modifiers.command; + let shift = i.modifiers.shift; + + // ⌘O - Open file + if cmd && i.key_pressed(egui::Key::O) { + if let Some(path) = rfd::FileDialog::new() + .add_filter("Log Files", crate::state::SUPPORTED_EXTENSIONS) + .pick_file() + { + self.start_loading_file(path); + } + return; + } + + // ⌘W - Close current tab + if cmd && i.key_pressed(egui::Key::W) { + if let Some(tab_idx) = self.active_tab { + self.close_tab(tab_idx); + } + return; + } + + // ⌘, - Open Settings panel + if cmd && i.key_pressed(egui::Key::Comma) { + self.active_panel = crate::state::ActivePanel::Settings; + return; + } + + // ⌘1/2/3 - Switch tool modes + if cmd && !shift { + if i.key_pressed(egui::Key::Num1) { + self.active_tool = crate::state::ActiveTool::LogViewer; + return; + } + if i.key_pressed(egui::Key::Num2) { + self.active_tool = crate::state::ActiveTool::ScatterPlot; + return; + } + if i.key_pressed(egui::Key::Num3) { + self.active_tool = crate::state::ActiveTool::Histogram; + return; + } + } + + // ⌘⇧F/C/T - Switch panels + if cmd && shift { + if i.key_pressed(egui::Key::F) { + self.active_panel = crate::state::ActivePanel::Files; + return; + } + if i.key_pressed(egui::Key::C) { + self.active_panel = crate::state::ActivePanel::Channels; + return; + } + if i.key_pressed(egui::Key::T) { + self.active_panel = crate::state::ActivePanel::Tools; + return; + } + } + + // Playback shortcuts (require file loaded with channels selected) + if self.files.is_empty() || self.get_selected_channels().is_empty() { + return; + } + + // Spacebar to toggle play/pause if i.key_pressed(egui::Key::Space) { self.is_playing = !self.is_playing; if self.is_playing { @@ -1295,6 +1404,7 @@ impl eframe::App for UltraLogApp { self.render_update_dialog(ctx); self.render_computed_channels_manager(ctx); self.render_formula_editor(ctx); + self.render_analysis_panel(ctx); // Menu bar at top with padding let menu_frame = egui::Frame::NONE.inner_margin(egui::Margin { @@ -1326,45 +1436,51 @@ impl eframe::App for UltraLogApp { self.render_tool_switcher(ui); }); - // Panel background color (matches drop zone card) + // Panel background color let panel_bg = egui::Color32::from_rgb(45, 45, 45); let panel_frame = egui::Frame::NONE .fill(panel_bg) .inner_margin(egui::Margin::symmetric(10, 10)); - // Left sidebar panel (always visible) - egui::SidePanel::left("files_panel") - .default_width(200.0) + // Activity bar (far left, narrow icon strip) + let activity_bar_bg = egui::Color32::from_rgb(35, 35, 35); + let activity_bar_frame = egui::Frame::NONE + .fill(activity_bar_bg) + .inner_margin(egui::Margin::symmetric(4, 8)); + + egui::SidePanel::left("activity_bar") + .exact_width(crate::ui::activity_bar::ACTIVITY_BAR_WIDTH) + .resizable(false) + .frame(activity_bar_frame) + .show(ctx, |ui| { + self.render_activity_bar(ui); + }); + + // Side panel (context-sensitive based on activity bar selection) + egui::SidePanel::left("side_panel") + .default_width(crate::ui::side_panel::SIDE_PANEL_WIDTH) + .min_width(crate::ui::side_panel::SIDE_PANEL_MIN_WIDTH) .resizable(true) .frame(panel_frame) .show(ctx, |ui| { - self.render_sidebar(ui); + self.render_side_panel(ui); }); - // Right panel for channel selection (only in Log Viewer mode) - if self.active_tool == ActiveTool::LogViewer { - egui::SidePanel::right("channels_panel") - .default_width(300.0) - .min_width(200.0) - .resizable(true) - .frame(panel_frame) + // Bottom panel for timeline scrubber (visible in LogViewer and Histogram modes) + let show_timeline = + self.get_time_range().is_some() && self.active_tool != ActiveTool::ScatterPlot; + + if show_timeline { + egui::TopBottomPanel::bottom("timeline_panel") + .resizable(false) + .min_height(60.0) .show(ctx, |ui| { - self.render_channel_selection(ui); + ui.add_space(5.0); + self.render_record_indicator(ui); + ui.separator(); + self.render_timeline_scrubber(ui); + ui.add_space(5.0); }); - - // Bottom panel for timeline scrubber (only in Log Viewer mode) - if self.get_time_range().is_some() && !self.get_selected_channels().is_empty() { - egui::TopBottomPanel::bottom("timeline_panel") - .resizable(false) - .min_height(60.0) - .show(ctx, |ui| { - ui.add_space(5.0); - self.render_record_indicator(ui); - ui.separator(); - self.render_timeline_scrubber(ui); - ui.add_space(5.0); - }); - } } // Main content area - render based on active tool @@ -1388,6 +1504,10 @@ impl eframe::App for UltraLogApp { ui.add_space(10.0); self.render_scatter_plot_view(ui); } + ActiveTool::Histogram => { + ui.add_space(10.0); + self.render_histogram_view(ui); + } } }); } diff --git a/src/bin/test_parser.rs b/src/bin/test_parser.rs index 3e57ca3..d4e366d 100644 --- a/src/bin/test_parser.rs +++ b/src/bin/test_parser.rs @@ -1,8 +1,9 @@ use std::env; use std::fs; +use std::path::Path; // Import from the library -use ultralog::parsers::{EcuMaster, EcuType, Haltech, Link, Parseable, Speeduino}; +use ultralog::parsers::{EcuMaster, EcuType, Emerald, Haltech, Link, Parseable, Speeduino}; fn main() { // Get file path from command line or use default @@ -38,6 +39,16 @@ fn main() { std::process::exit(1); } } + } else if Emerald::is_emerald_path(Path::new(path)) && Emerald::detect(&binary_data) { + println!("\nDetected: Emerald ECU LG1/LG2 format"); + println!("Parsing Emerald ECU log..."); + match Emerald::parse_file(Path::new(path)) { + Ok(log) => (EcuType::Emerald, log), + Err(e) => { + eprintln!("Parse error: {}", e); + std::process::exit(1); + } + } } else { // Try text-based formats let contents = match std::str::from_utf8(&binary_data) { diff --git a/src/computed.rs b/src/computed.rs index 8d62447..166a661 100644 --- a/src/computed.rs +++ b/src/computed.rs @@ -26,6 +26,12 @@ pub struct ComputedChannelTemplate { pub created_at: u64, /// Last modified timestamp (unix seconds) pub modified_at: u64, + /// Whether this is a built-in template (vs user-created) + #[serde(default)] + pub is_builtin: bool, + /// Category for grouping (e.g., "Rate", "Engine", "Anomaly", "Smoothing") + #[serde(default)] + pub category: String, } impl ComputedChannelTemplate { @@ -44,6 +50,34 @@ impl ComputedChannelTemplate { description, created_at: now, modified_at: now, + is_builtin: false, + category: String::new(), + } + } + + /// Create a built-in template with category + pub fn builtin( + name: &str, + formula: &str, + unit: &str, + category: &str, + description: &str, + ) -> Self { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + Self { + id: uuid::Uuid::new_v4().to_string(), + name: name.to_string(), + formula: formula.to_string(), + unit: unit.to_string(), + description: description.to_string(), + created_at: now, + modified_at: now, + is_builtin: true, + category: category.to_string(), } } @@ -201,7 +235,7 @@ impl ComputedChannelLibrary { Self::get_config_dir().map(|p| p.join("computed_channels.json")) } - /// Load the library from disk + /// Load the library from disk, seeding with built-ins if empty pub fn load() -> Self { let path = match Self::get_library_path() { Some(p) => p, @@ -209,33 +243,55 @@ impl ComputedChannelLibrary { tracing::warn!( "Could not determine config directory for computed channels library" ); - return Self::new(); + return Self::new_with_builtins(); } }; if !path.exists() { - tracing::info!("Computed channels library not found, using empty library"); - return Self::new(); + tracing::info!("Computed channels library not found, seeding with built-in templates"); + let library = Self::new_with_builtins(); + // Save the seeded library + if let Err(e) = library.save() { + tracing::warn!("Failed to save initial library: {}", e); + } + return library; } match std::fs::read_to_string(&path) { - Ok(content) => match serde_json::from_str(&content) { + Ok(content) => match serde_json::from_str::(&content) { Ok(library) => { + // If existing library is empty, seed with built-ins + if library.templates.is_empty() { + tracing::info!("Library empty, seeding with built-in templates"); + let seeded = Self::new_with_builtins(); + if let Err(e) = seeded.save() { + tracing::warn!("Failed to save seeded library: {}", e); + } + return seeded; + } tracing::info!("Loaded computed channels library from {:?}", path); library } Err(e) => { tracing::error!("Failed to parse computed channels library: {}", e); - Self::new() + Self::new_with_builtins() } }, Err(e) => { tracing::error!("Failed to read computed channels library: {}", e); - Self::new() + Self::new_with_builtins() } } } + /// Create a new library pre-seeded with built-in templates + pub fn new_with_builtins() -> Self { + Self { + version: Self::CURRENT_VERSION, + templates: get_builtin_templates(), + } + } + /// Save the library to disk pub fn save(&self) -> Result<(), String> { let path = Self::get_library_path() @@ -258,6 +314,86 @@ impl ComputedChannelLibrary { } } +/// Get the default built-in templates for quick computed channels +pub fn get_builtin_templates() -> Vec { + vec![ + // Rate of Change templates + ComputedChannelTemplate::builtin( + "RPM Delta", + "RPM - RPM[-1]", + "RPM/sample", + "Rate", + "RPM change between consecutive samples", + ), + ComputedChannelTemplate::builtin( + "TPS Rate", + "(TPS - TPS@-0.1s) * 10", + "%/s", + "Rate", + "Throttle position rate of change per second", + ), + ComputedChannelTemplate::builtin( + "Boost Rate", + "(MAP - MAP@-0.1s) * 10", + "kPa/s", + "Rate", + "Boost/MAP pressure rate of change per second", + ), + // Engine Calculations + ComputedChannelTemplate::builtin( + "AFR Deviation", + "(AFR - 14.7) / 14.7 * 100", + "%", + "Engine", + "Percent deviation from stoichiometric AFR (14.7)", + ), + ComputedChannelTemplate::builtin( + "Lambda", + "AFR / 14.7", + "λ", + "Engine", + "Lambda value calculated from AFR", + ), + ComputedChannelTemplate::builtin( + "Load Estimate", + "MAP / 101.325 * 100", + "%", + "Engine", + "Engine load estimate from MAP (% of atmospheric)", + ), + // Smoothing + ComputedChannelTemplate::builtin( + "RPM Smoothed", + "(RPM + RPM[-1] + RPM[-2]) / 3", + "RPM", + "Smoothing", + "3-sample moving average of RPM", + ), + // Z-Score / Statistical Outlier Detection + ComputedChannelTemplate::builtin( + "RPM Z-Score", + "(RPM - _mean_RPM) / _stdev_RPM", + "σ", + "Anomaly", + "Z-score of RPM (values > 3 or < -3 are statistical outliers)", + ), + ComputedChannelTemplate::builtin( + "AFR Z-Score", + "(AFR - _mean_AFR) / _stdev_AFR", + "σ", + "Anomaly", + "Z-score of AFR (values > 3 or < -3 are statistical outliers)", + ), + ComputedChannelTemplate::builtin( + "MAP Z-Score", + "(MAP - _mean_MAP) / _stdev_MAP", + "σ", + "Anomaly", + "Z-score of MAP (values > 3 or < -3 are statistical outliers)", + ), + ] +} + /// State for the formula editor dialog #[derive(Clone, Debug, Default)] pub struct FormulaEditorState { @@ -304,6 +440,18 @@ impl FormulaEditorState { self.is_open = true; } + /// Open the editor with a pre-filled pattern (for Quick Create) + pub fn open_with_pattern(&mut self, name: &str, formula: &str, unit: &str, description: &str) { + self.editing_template_id = None; + self.name = name.to_string(); + self.formula = formula.to_string(); + self.unit = unit.to_string(); + self.description = description.to_string(); + self.validation_error = None; + self.preview_values = None; + self.is_open = true; + } + /// Close the editor pub fn close(&mut self) { self.is_open = false; diff --git a/src/expression.rs b/src/expression.rs index 95a669f..18d7131 100644 --- a/src/expression.rs +++ b/src/expression.rs @@ -1,7 +1,8 @@ //! Expression parsing and evaluation engine for computed channels //! //! This module handles parsing mathematical formulas that reference channel data, -//! including support for time-shifted values (both index-based and time-based). +//! including support for time-shifted values (both index-based and time-based), +//! and pre-computed channel statistics for anomaly detection. use crate::computed::{ChannelReference, TimeShift}; use crate::parsers::types::Value; @@ -10,6 +11,71 @@ use regex::Regex; use std::collections::HashMap; use std::sync::LazyLock; +/// Pre-computed statistics for a channel, used for anomaly detection formulas +#[derive(Clone, Debug, Default)] +pub struct ChannelStatistics { + /// Arithmetic mean of all values + pub mean: f64, + /// Standard deviation of all values + pub stdev: f64, + /// Minimum value + pub min: f64, + /// Maximum value + pub max: f64, + /// Range (max - min) + pub range: f64, +} + +/// Compute statistics for a single channel +pub fn compute_channel_statistics( + channel_idx: usize, + log_data: &[Vec], +) -> ChannelStatistics { + if log_data.is_empty() { + return ChannelStatistics::default(); + } + + let values: Vec = log_data + .iter() + .filter_map(|row| row.get(channel_idx).map(|v| v.as_f64())) + .filter(|v| v.is_finite()) + .collect(); + + if values.is_empty() { + return ChannelStatistics::default(); + } + + let n = values.len() as f64; + let sum: f64 = values.iter().sum(); + let mean = sum / n; + + let variance: f64 = values.iter().map(|v| (v - mean).powi(2)).sum::() / n; + let stdev = variance.sqrt(); + + let min = values.iter().cloned().fold(f64::INFINITY, f64::min); + let max = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + + ChannelStatistics { + mean, + stdev, + min, + max, + range: max - min, + } +} + +/// Compute statistics for all channels in a log +pub fn compute_all_channel_statistics( + channel_names: &[String], + log_data: &[Vec], +) -> HashMap { + let mut stats = HashMap::new(); + for (idx, name) in channel_names.iter().enumerate() { + stats.insert(name.clone(), compute_channel_statistics(idx, log_data)); + } + stats +} + /// Regex for parsing quoted channel references with optional time shifts /// Pattern: "Channel Name" (anything in quotes) with optional time shift static QUOTED_CHANNEL_REGEX: LazyLock = LazyLock::new(|| { @@ -30,6 +96,9 @@ const RESERVED_NAMES: &[&str] = &[ "fract", "signum", "max", "min", "pi", "e", "tau", "phi", ]; +/// Prefixes for statistical variables that should not be treated as channel names +const STATS_PREFIXES: &[&str] = &["_mean_", "_stdev_", "_min_", "_max_", "_range_"]; + /// Extract all channel references from a formula pub fn extract_channel_references(formula: &str) -> Vec { let mut references = Vec::new(); @@ -62,6 +131,11 @@ pub fn extract_channel_references(formula: &str) -> Vec { continue; } + // Skip statistical variable references (e.g., _mean_RPM, _stdev_AFR) + if STATS_PREFIXES.iter().any(|p| full_match.starts_with(p)) { + continue; + } + // Skip if this position is inside a quoted reference let start_pos = caps.get(0).unwrap().start(); let is_inside_quoted = references.iter().any(|r| { @@ -144,6 +218,16 @@ pub fn validate_formula(formula: &str, available_channels: &[String]) -> Result< ctx.var(&var_name, 1.0); } + // Also set dummy values for statistical variables (for anomaly detection formulas) + for channel in available_channels { + let safe_name = sanitize_var_name(channel); + ctx.var(format!("_mean_{}", safe_name), 1.0); + ctx.var(format!("_stdev_{}", safe_name), 1.0); + ctx.var(format!("_min_{}", safe_name), 0.0); + ctx.var(format!("_max_{}", safe_name), 2.0); + ctx.var(format!("_range_{}", safe_name), 2.0); + } + match test_formula.parse::() { Ok(expr) => { // Try to evaluate with dummy values @@ -213,11 +297,28 @@ pub fn build_channel_bindings( } /// Evaluate a formula for all records in the log +/// +/// If `statistics` is provided, injects statistical variables for each channel: +/// - `_mean_ChannelName`, `_stdev_ChannelName`, `_min_ChannelName`, `_max_ChannelName`, `_range_ChannelName` pub fn evaluate_all_records( formula: &str, bindings: &HashMap, log_data: &[Vec], times: &[f64], +) -> Result, String> { + evaluate_all_records_with_stats(formula, bindings, log_data, times, None) +} + +/// Evaluate a formula for all records in the log with optional pre-computed statistics +/// +/// If `statistics` is provided, injects statistical variables for each channel: +/// - `_mean_ChannelName`, `_stdev_ChannelName`, `_min_ChannelName`, `_max_ChannelName`, `_range_ChannelName` +pub fn evaluate_all_records_with_stats( + formula: &str, + bindings: &HashMap, + log_data: &[Vec], + times: &[f64], + statistics: Option<&HashMap>, ) -> Result, String> { if log_data.is_empty() { return Ok(Vec::new()); @@ -245,6 +346,18 @@ pub fn evaluate_all_records( ctx.var(&var_name, value); } + // Inject statistics variables if provided + if let Some(stats) = statistics { + for (channel_name, channel_stats) in stats { + let safe_name = sanitize_var_name(channel_name); + ctx.var(format!("_mean_{}", safe_name), channel_stats.mean); + ctx.var(format!("_stdev_{}", safe_name), channel_stats.stdev); + ctx.var(format!("_min_{}", safe_name), channel_stats.min); + ctx.var(format!("_max_{}", safe_name), channel_stats.max); + ctx.var(format!("_range_{}", safe_name), channel_stats.range); + } + } + match expr.eval_with_context(&ctx) { Ok(value) => { // Handle NaN and infinity diff --git a/src/lib.rs b/src/lib.rs index 9045374..6e3ff55 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ //! - [`normalize`] - Field name normalization for standardizing channel names //! - [`updater`] - Auto-update functionality for checking and downloading updates //! - [`analytics`] - Anonymous usage analytics via PostHog +//! - [`analysis`] - Signal processing and statistical analysis algorithms //! - [`ui`] - User interface components //! - `sidebar` - File list and view options //! - `channels` - Channel selection and display @@ -21,6 +22,7 @@ //! - `toast` - Toast notification system //! - `icons` - Custom icon drawing utilities +pub mod analysis; pub mod analytics; pub mod app; pub mod computed; diff --git a/src/main.rs b/src/main.rs index dfdb349..cb5bf10 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,34 @@ use eframe::egui::IconData; use std::sync::Arc; use ultralog::app::UltraLogApp; +/// Set up Linux-specific scaling configuration before window creation. +/// This handles common DPI/scaling issues on X11, especially with KDE Plasma. +#[cfg(target_os = "linux")] +fn setup_linux_scaling() { + // If no X11 scale factor is set, try to detect from common environment variables + // This helps on systems where the scale factor isn't properly detected + if std::env::var("WINIT_X11_SCALE_FACTOR").is_err() { + // Check if GDK_SCALE is set (common on GTK-based systems) + if let Ok(gdk_scale) = std::env::var("GDK_SCALE") { + std::env::set_var("WINIT_X11_SCALE_FACTOR", &gdk_scale); + } + // Check QT_SCALE_FACTOR (common on KDE/Qt systems like Kubuntu) + else if let Ok(qt_scale) = std::env::var("QT_SCALE_FACTOR") { + std::env::set_var("WINIT_X11_SCALE_FACTOR", &qt_scale); + } + // Check QT_AUTO_SCREEN_SCALE_FACTOR + else if std::env::var("QT_AUTO_SCREEN_SCALE_FACTOR").is_ok() { + // Let winit auto-detect, but ensure it uses randr for X11 + std::env::set_var("WINIT_X11_SCALE_FACTOR", "randr"); + } + } +} + +#[cfg(not(target_os = "linux"))] +fn setup_linux_scaling() { + // No-op on non-Linux platforms +} + /// Load the platform-specific application icon fn load_app_icon() -> Option> { // Select the appropriate icon based on platform @@ -63,8 +91,9 @@ fn set_macos_app_name() { fn set_macos_app_name() {} fn main() -> eframe::Result<()> { - // Set macOS app name before anything else + // Set up platform-specific configuration before anything else set_macos_app_name(); + setup_linux_scaling(); // Initialize logging tracing_subscriber::fmt::init(); @@ -83,6 +112,13 @@ fn main() -> eframe::Result<()> { .with_app_id("UltraLog") .with_drag_and_drop(true); + // On Linux, start maximized to avoid sizing/scaling issues across different + // desktop environments and display configurations + #[cfg(target_os = "linux")] + { + viewport = viewport.with_maximized(true); + } + // Set icon if loaded successfully if let Some(icon_data) = icon { viewport = viewport.with_icon(icon_data); diff --git a/src/normalize.rs b/src/normalize.rs index b5b91b0..64f20c9 100644 --- a/src/normalize.rs +++ b/src/normalize.rs @@ -11,7 +11,7 @@ static NORMALIZATION_MAP: LazyLock>> = LazyLock::new(|| { let mut map = HashMap::new(); - // AFR (Air Fuel Ratio) + // AFR (Air Fuel Ratio) - generic/overall readings map.insert( "AFR", vec![ @@ -20,10 +20,14 @@ static NORMALIZATION_MAP: LazyLock>> = "Aft", "Act AFR", "AFR", - "AFR1", - "WB2 AFR 1", "Air Fuel Ratio", "AFR_R_BANK", + // Wideband O2 sensors - overall/combined readings only + "Wideband O2 Overall", + "Wideband O2", + "WB O2", + "O2 Wideband", + "Wideband Overall", ], ); @@ -39,23 +43,41 @@ static NORMALIZATION_MAP: LazyLock>> = ], ); - // AFR 1 - map.insert("AFR 1", vec!["AFR_1", "AFR 1"]); + // AFR 1 - first sensor/bank + map.insert( + "AFR Channel 1", + vec![ + "AFR_1", + "AFR 1", + "AFR1", + "WB2 AFR 1", + "Wideband O2 1", + "WB O2 1", + "Wideband 1", + ], + ); - // AFR 1 Error + // AFR 2 - second sensor/bank map.insert( - "AFR 1 Error", + "AFR Channel 2", vec![ - "AFR_Error", - "AFR_1_Error", - "AFR 1 Error", - "KO2_AFR_CORR", - "AFR_1_Error", + "Aft2", + "AFR 2", + "AFR_2", + "afr_2", + "AFR2", + "WB2 AFR 2", + "Wideband O2 2", + "WB O2 2", + "Wideband 2", ], ); - // AFR 2 - map.insert("AFR 2", vec!["Aft2", "AFR 2", "AFR_2", "afr_2"]); + // AFR 1 Error + map.insert( + "AFR 1 Error", + vec!["AFR_Error", "AFR_1_Error", "AFR 1 Error", "KO2_AFR_CORR"], + ); // Battery Voltage map.insert( @@ -132,6 +154,7 @@ static NORMALIZATION_MAP: LazyLock>> = "IAT - Inlet Air Temp", "IAT Intake Air Temp", "Intake Air Temp", + "Intake Air Temperature", ], ); @@ -461,9 +484,20 @@ mod tests { #[test] fn test_normalize_afr() { + // Generic AFR channels assert_eq!(normalize_channel_name("Act_AFR"), "AFR"); assert_eq!(normalize_channel_name("R_EGO"), "AFR"); assert_eq!(normalize_channel_name("Air Fuel Ratio"), "AFR"); + // Wideband O2 overall/generic should normalize to AFR + assert_eq!(normalize_channel_name("Wideband O2 Overall"), "AFR"); + assert_eq!(normalize_channel_name("Wideband O2"), "AFR"); + assert_eq!(normalize_channel_name("WB O2"), "AFR"); + // Numbered sensors should normalize to AFR Channel 1, AFR Channel 2 (not generic AFR) + assert_eq!(normalize_channel_name("Wideband O2 1"), "AFR Channel 1"); + assert_eq!(normalize_channel_name("Wideband O2 2"), "AFR Channel 2"); + assert_eq!(normalize_channel_name("WB O2 1"), "AFR Channel 1"); + assert_eq!(normalize_channel_name("Wideband 1"), "AFR Channel 1"); + assert_eq!(normalize_channel_name("Wideband 2"), "AFR Channel 2"); } #[test] diff --git a/src/parsers/emerald.rs b/src/parsers/emerald.rs new file mode 100644 index 0000000..3c37027 --- /dev/null +++ b/src/parsers/emerald.rs @@ -0,0 +1,723 @@ +//! Emerald ECU (.lg1/.lg2) binary format parser +//! +//! Emerald K6/M3D ECUs use a proprietary binary format for log files: +//! - .lg2 file: Text file containing channel definitions (which parameters are logged) +//! - .lg1 file: Binary file containing timestamped data records +//! +//! Format structure: +//! - LG2 file: INI-like format with \[chan1\] through \[chan8\] sections mapping to channel IDs +//! - LG1 file: 24-byte records (8-byte OLE timestamp + 8 x 2-byte u16 values) +//! +//! The channel IDs map to specific ECU parameters (RPM, TPS, temperatures, etc.) + +use serde::Serialize; +use std::error::Error; +use std::path::Path; + +use super::types::{Channel, Log, Meta, Value}; + +/// Known Emerald ECU channel IDs and their metadata +/// These are reverse-engineered from observed data patterns +#[derive(Clone, Debug)] +struct ChannelDefinition { + name: &'static str, + unit: &'static str, + /// Scale factor to apply to raw u16 value + scale: f64, + /// Offset to apply after scaling + offset: f64, +} + +/// Get channel definition for a known channel ID +fn get_channel_definition(id: u8) -> ChannelDefinition { + match id { + // Core engine parameters + 1 => ChannelDefinition { + name: "TPS", + unit: "%", + scale: 0.1, + offset: 0.0, + }, + 2 => ChannelDefinition { + name: "Air Temp", + unit: "°C", + scale: 1.0, + offset: 0.0, + }, + 3 => ChannelDefinition { + name: "MAP", + unit: "kPa", + scale: 0.1, + offset: 0.0, + }, + 4 => ChannelDefinition { + name: "Lambda", + unit: "λ", + scale: 0.001, + offset: 0.0, + }, + 5 => ChannelDefinition { + name: "Fuel Pressure", + unit: "bar", + scale: 0.01, + offset: 0.0, + }, + 6 => ChannelDefinition { + name: "Oil Pressure", + unit: "bar", + scale: 0.01, + offset: 0.0, + }, + 7 => ChannelDefinition { + name: "Oil Temp", + unit: "°C", + scale: 1.0, + offset: 0.0, + }, + 8 => ChannelDefinition { + name: "Fuel Temp", + unit: "°C", + scale: 1.0, + offset: 0.0, + }, + 9 => ChannelDefinition { + name: "Exhaust Temp", + unit: "°C", + scale: 1.0, + offset: 0.0, + }, + 10 => ChannelDefinition { + name: "Boost Target", + unit: "kPa", + scale: 0.1, + offset: 0.0, + }, + 11 => ChannelDefinition { + name: "Boost Duty", + unit: "%", + scale: 0.1, + offset: 0.0, + }, + 12 => ChannelDefinition { + name: "Load", + unit: "%", + scale: 0.1, + offset: 0.0, + }, + 13 => ChannelDefinition { + name: "Fuel Cut", + unit: "", + scale: 1.0, + offset: 0.0, + }, + 14 => ChannelDefinition { + name: "Spark Cut", + unit: "", + scale: 1.0, + offset: 0.0, + }, + 15 => ChannelDefinition { + name: "Gear", + unit: "", + scale: 1.0, + offset: 0.0, + }, + 16 => ChannelDefinition { + name: "Speed", + unit: "km/h", + scale: 0.1, + offset: 0.0, + }, + 17 => ChannelDefinition { + name: "Battery", + unit: "V", + scale: 0.01, + offset: 0.0, + }, + 18 => ChannelDefinition { + name: "AFR Target", + unit: "AFR", + scale: 0.1, + offset: 0.0, + }, + 19 => ChannelDefinition { + name: "Coolant Temp", + unit: "°C", + scale: 1.0, + offset: 0.0, + }, + 20 => ChannelDefinition { + name: "RPM", + unit: "RPM", + scale: 1.0, + offset: 0.0, + }, + 21 => ChannelDefinition { + name: "Ignition Advance", + unit: "°", + scale: 0.1, + offset: 0.0, + }, + 22 => ChannelDefinition { + name: "Inj Pulse Width", + unit: "ms", + scale: 0.01, + offset: 0.0, + }, + 23 => ChannelDefinition { + name: "Inj Duty Cycle", + unit: "%", + scale: 0.1, + offset: 0.0, + }, + 24 => ChannelDefinition { + name: "Fuel Pressure", + unit: "kPa", + scale: 0.1, + offset: 0.0, + }, + 25 => ChannelDefinition { + name: "Coolant Temp Corr", + unit: "%", + scale: 0.1, + offset: 0.0, + }, + 26 => ChannelDefinition { + name: "Air Temp Corr", + unit: "%", + scale: 0.1, + offset: 0.0, + }, + 27 => ChannelDefinition { + name: "Acceleration Enrich", + unit: "%", + scale: 0.1, + offset: 0.0, + }, + 28 => ChannelDefinition { + name: "Warmup Enrich", + unit: "%", + scale: 0.1, + offset: 0.0, + }, + 29 => ChannelDefinition { + name: "Ignition Timing", + unit: "°BTDC", + scale: 0.1, + offset: 0.0, + }, + 30 => ChannelDefinition { + name: "Idle Valve", + unit: "%", + scale: 0.1, + offset: 0.0, + }, + 31 => ChannelDefinition { + name: "Inj Duty", + unit: "%", + scale: 0.1, + offset: 0.0, + }, + 32 => ChannelDefinition { + name: "MAP", + unit: "kPa", + scale: 0.1, + offset: 0.0, + }, + 33 => ChannelDefinition { + name: "Barometric Pressure", + unit: "kPa", + scale: 0.1, + offset: 0.0, + }, + 34 => ChannelDefinition { + name: "Aux Input 34", + unit: "", + scale: 1.0, + offset: 0.0, + }, + 35 => ChannelDefinition { + name: "Aux Input 35", + unit: "", + scale: 1.0, + offset: 0.0, + }, + // AFR/Lambda channels + 45 => ChannelDefinition { + name: "AFR", + unit: "AFR", + scale: 0.1, + offset: 0.0, + }, + 46 => ChannelDefinition { + name: "AFR", + unit: "AFR", + scale: 0.1, + offset: 0.0, + }, + 47 => ChannelDefinition { + name: "Lambda", + unit: "λ", + scale: 0.01, + offset: 0.0, + }, + // Default for unknown channels + _ => ChannelDefinition { + name: "Unknown", + unit: "", + scale: 1.0, + offset: 0.0, + }, + } +} + +/// Emerald ECU channel metadata +#[derive(Clone, Debug, Serialize)] +pub struct EmeraldChannel { + pub name: String, + pub unit: String, + pub channel_id: u8, + /// Scale factor applied to convert raw u16 to engineering value + #[serde(skip)] + pub scale: f64, + /// Offset applied after scaling + #[serde(skip)] + pub offset: f64, +} + +impl EmeraldChannel { + /// Get the display unit for this channel + pub fn unit(&self) -> &str { + &self.unit + } +} + +/// Emerald ECU log metadata +#[derive(Clone, Debug, Serialize, Default)] +pub struct EmeraldMeta { + /// Source file name (without extension) + pub source_file: String, + /// Number of records in the log + pub record_count: usize, + /// Duration of the log in seconds + pub duration_seconds: f64, + /// Sample rate in Hz (approximate) + pub sample_rate_hz: f64, +} + +/// Emerald ECU log file parser +pub struct Emerald; + +impl Emerald { + /// Check if a file path looks like an Emerald log file (.lg1 or .lg2) + pub fn is_emerald_path(path: &Path) -> bool { + if let Some(ext) = path.extension() { + let ext_lower = ext.to_string_lossy().to_lowercase(); + ext_lower == "lg1" || ext_lower == "lg2" + } else { + false + } + } + + /// Check if a file path is specifically an LG1 file + pub fn is_lg1_path(path: &Path) -> bool { + if let Some(ext) = path.extension() { + ext.to_string_lossy().to_lowercase() == "lg1" + } else { + false + } + } + + /// Check if a file path is specifically an LG2 file + pub fn is_lg2_path(path: &Path) -> bool { + if let Some(ext) = path.extension() { + ext.to_string_lossy().to_lowercase() == "lg2" + } else { + false + } + } + + /// Detect if binary data is Emerald LG1 format + /// LG1 files have 24-byte records with OLE timestamp at the start + pub fn detect(data: &[u8]) -> bool { + // Must have at least one complete record (24 bytes) + if data.len() < 24 { + return false; + } + + // File size must be a multiple of 24 bytes + if !data.len().is_multiple_of(24) { + return false; + } + + // Check if first 8 bytes look like a valid OLE date + // OLE dates are f64 days since 1899-12-30 + // Valid range: ~35000 (1995) to ~55000 (2050) + let timestamp = f64::from_le_bytes([ + data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7], + ]); + + // Check for reasonable OLE date range + if !(35000.0..=55000.0).contains(×tamp) { + return false; + } + + // Check that subsequent records also have valid timestamps + if data.len() >= 48 { + let timestamp2 = f64::from_le_bytes([ + data[24], data[25], data[26], data[27], data[28], data[29], data[30], data[31], + ]); + + // Second timestamp should be close to first (within 1 day) + if (timestamp2 - timestamp).abs() > 1.0 { + return false; + } + } + + true + } + + /// Detect if text data is Emerald LG2 format (channel definitions) + /// LG2 files have \[chan1\] through \[chan8\] sections + pub fn detect_lg2(data: &[u8]) -> bool { + // Must be valid UTF-8 text + let text = match std::str::from_utf8(data) { + Ok(s) => s, + Err(_) => return false, + }; + + // Must contain [chan1] section marker + if !text.contains("[chan1]") { + return false; + } + + // Should have at least a few channel definitions + let channel_count = (1..=8) + .filter(|i| text.contains(&format!("[chan{}]", i))) + .count(); + + channel_count >= 4 + } + + /// Parse the LG2 channel definition file + fn parse_lg2(contents: &str) -> Result, Box> { + let mut channels: Vec<(u8, u8)> = Vec::new(); + + let lines: Vec<&str> = contents.lines().collect(); + let mut i = 0; + + while i < lines.len() { + let line = lines[i].trim(); + + // Look for [chanN] headers + if line.starts_with("[chan") && line.ends_with(']') { + // Extract channel slot number (1-8) + let slot_str = &line[5..line.len() - 1]; + if let Ok(slot) = slot_str.parse::() { + // Next line should be the channel ID + if i + 1 < lines.len() { + let id_line = lines[i + 1].trim(); + if let Ok(channel_id) = id_line.parse::() { + channels.push((slot, channel_id)); + } + i += 1; + } + } + } + + i += 1; + } + + if channels.is_empty() { + return Err("No channel definitions found in LG2 file".into()); + } + + // Sort by slot number to ensure correct order + channels.sort_by_key(|(slot, _)| *slot); + + Ok(channels) + } + + /// Parse Emerald log files (requires both .lg1 and .lg2) + pub fn parse_file(path: &Path) -> Result> { + // Determine the base path (without extension) + let base_path = path.with_extension(""); + + // Read LG2 file (channel definitions) + let lg2_path = base_path.with_extension("lg2"); + let lg2_contents = std::fs::read_to_string(&lg2_path).map_err(|e| { + format!( + "Cannot read LG2 file '{}': {}. Both .lg1 and .lg2 files are required.", + lg2_path.display(), + e + ) + })?; + + // Parse channel definitions + let channel_defs = Self::parse_lg2(&lg2_contents)?; + + // Read LG1 file (binary data) + let lg1_path = base_path.with_extension("lg1"); + let lg1_data = std::fs::read(&lg1_path).map_err(|e| { + format!( + "Cannot read LG1 file '{}': {}. Both .lg1 and .lg2 files are required.", + lg1_path.display(), + e + ) + })?; + + Self::parse_binary_with_channels(&lg1_data, &channel_defs, path) + } + + /// Parse the LG1 binary data with channel definitions + fn parse_binary_with_channels( + data: &[u8], + channel_defs: &[(u8, u8)], + source_path: &Path, + ) -> Result> { + if !Self::detect(data) { + return Err("Invalid LG1 file - not recognized as Emerald format".into()); + } + + const RECORD_SIZE: usize = 24; + let num_records = data.len() / RECORD_SIZE; + + if num_records == 0 { + return Err("LG1 file contains no data records".into()); + } + + // Build channel metadata + let mut channels: Vec = Vec::with_capacity(8); + for (slot, channel_id) in channel_defs { + let def = get_channel_definition(*channel_id); + let name = if def.name == "Unknown" { + format!("Channel {} (ID {})", slot, channel_id) + } else { + def.name.to_string() + }; + + channels.push(EmeraldChannel { + name, + unit: def.unit.to_string(), + channel_id: *channel_id, + scale: def.scale, + offset: def.offset, + }); + } + + // Parse binary data + let mut times: Vec = Vec::with_capacity(num_records); + let mut data_matrix: Vec> = Vec::with_capacity(num_records); + + let mut first_timestamp: Option = None; + + for i in 0..num_records { + let offset = i * RECORD_SIZE; + + // Read OLE timestamp (8 bytes, f64) + let ole_timestamp = f64::from_le_bytes([ + data[offset], + data[offset + 1], + data[offset + 2], + data[offset + 3], + data[offset + 4], + data[offset + 5], + data[offset + 6], + data[offset + 7], + ]); + + // Convert OLE date to seconds since start + let first_ts = *first_timestamp.get_or_insert(ole_timestamp); + let time_seconds = (ole_timestamp - first_ts) * 24.0 * 60.0 * 60.0; + times.push(time_seconds); + + // Read 8 channel values (16 bytes, 8 x u16) + let mut row: Vec = Vec::with_capacity(channels.len()); + for (ch_idx, channel) in channels.iter().enumerate() { + let value_offset = offset + 8 + (ch_idx * 2); + let raw_value = + u16::from_le_bytes([data[value_offset], data[value_offset + 1]]) as f64; + + // Apply scaling and offset + let scaled_value = raw_value * channel.scale + channel.offset; + row.push(Value::Float(scaled_value)); + } + + data_matrix.push(row); + } + + // Calculate metadata + let duration = times.last().copied().unwrap_or(0.0); + let sample_rate = if duration > 0.0 { + num_records as f64 / duration + } else { + 0.0 + }; + + let source_file = source_path + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_default(); + + let meta = EmeraldMeta { + source_file, + record_count: num_records, + duration_seconds: duration, + sample_rate_hz: sample_rate, + }; + + tracing::info!( + "Parsed Emerald ECU log: {} channels, {} records, {:.1}s duration, {:.1} Hz", + channels.len(), + num_records, + duration, + sample_rate + ); + + Ok(Log { + meta: Meta::Emerald(meta), + channels: channels.into_iter().map(Channel::Emerald).collect(), + times, + data: data_matrix, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_detect_valid_lg1() { + // Create minimal valid LG1 data (one record) + let mut data = vec![0u8; 24]; + + // Write a valid OLE timestamp (e.g., 46022.5 = Dec 2025) + let timestamp: f64 = 46022.5; + data[0..8].copy_from_slice(×tamp.to_le_bytes()); + + // Write some channel values + for i in 0..8 { + let value: u16 = (i * 100) as u16; + let offset = 8 + i * 2; + data[offset..offset + 2].copy_from_slice(&value.to_le_bytes()); + } + + assert!(Emerald::detect(&data)); + } + + #[test] + fn test_detect_invalid_data() { + // Empty data + assert!(!Emerald::detect(&[])); + + // Too short + assert!(!Emerald::detect(&[0u8; 23])); + + // Wrong size (not a multiple of 24) + assert!(!Emerald::detect(&[0u8; 25])); + + // Invalid timestamp (too old) + let mut data = vec![0u8; 24]; + let old_timestamp: f64 = 1000.0; // Way too old + data[0..8].copy_from_slice(&old_timestamp.to_le_bytes()); + assert!(!Emerald::detect(&data)); + + // Invalid timestamp (too new) + let mut data = vec![0u8; 24]; + let future_timestamp: f64 = 100000.0; // Way too far in future + data[0..8].copy_from_slice(&future_timestamp.to_le_bytes()); + assert!(!Emerald::detect(&data)); + } + + #[test] + fn test_parse_lg2() { + let lg2_content = "[chan1]\n19\n[chan2]\n46\n[chan3]\n2\n[chan4]\n20\n[chan5]\n1\n[chan6]\n31\n[chan7]\n32\n[chan8]\n17\n[ValU]\n0\n2\n0\n0\n0\n"; + + let channels = Emerald::parse_lg2(lg2_content).unwrap(); + assert_eq!(channels.len(), 8); + assert_eq!(channels[0], (1, 19)); // Coolant Temp + assert_eq!(channels[1], (2, 46)); // AFR + assert_eq!(channels[2], (3, 2)); // Air Temp + assert_eq!(channels[3], (4, 20)); // RPM + assert_eq!(channels[4], (5, 1)); // TPS + assert_eq!(channels[5], (6, 31)); // Inj Duty + assert_eq!(channels[6], (7, 32)); // MAP + assert_eq!(channels[7], (8, 17)); // Battery + } + + #[test] + fn test_channel_definitions() { + // Test known channel IDs + let rpm = get_channel_definition(20); + assert_eq!(rpm.name, "RPM"); + assert_eq!(rpm.unit, "RPM"); + + let coolant = get_channel_definition(19); + assert_eq!(coolant.name, "Coolant Temp"); + assert_eq!(coolant.unit, "°C"); + + let tps = get_channel_definition(1); + assert_eq!(tps.name, "TPS"); + assert_eq!(tps.unit, "%"); + + // Test unknown channel + let unknown = get_channel_definition(255); + assert_eq!(unknown.name, "Unknown"); + } + + #[test] + fn test_is_emerald_path() { + assert!(Emerald::is_emerald_path(Path::new("test.lg1"))); + assert!(Emerald::is_emerald_path(Path::new("test.lg2"))); + assert!(Emerald::is_emerald_path(Path::new("test.LG1"))); + assert!(Emerald::is_emerald_path(Path::new("/path/to/file.lg2"))); + + assert!(!Emerald::is_emerald_path(Path::new("test.csv"))); + assert!(!Emerald::is_emerald_path(Path::new("test.llg"))); + assert!(!Emerald::is_emerald_path(Path::new("test"))); + } + + #[test] + fn test_parse_emerald_example_files() { + // Try to parse the example files + let base_path = Path::new("exampleLogs/emerald/EM Log MG ZS Turbo idle and rev"); + + // Check if files exist + let lg1_path = base_path.with_extension("lg1"); + let lg2_path = base_path.with_extension("lg2"); + + if !lg1_path.exists() || !lg2_path.exists() { + eprintln!( + "Skipping test: example files not found at {}", + base_path.display() + ); + return; + } + + // Parse the files + let log = Emerald::parse_file(&lg1_path).expect("Should parse successfully"); + + // Verify structure + assert_eq!(log.channels.len(), 8, "Should have 8 channels"); + assert!(!log.times.is_empty(), "Should have time data"); + assert!(!log.data.is_empty(), "Should have data records"); + + // Verify channel names + for channel in &log.channels { + let name = channel.name(); + assert!(!name.is_empty(), "Channel name should not be empty"); + eprintln!("Channel: {} ({})", name, channel.unit()); + } + + // Verify metadata + if let Meta::Emerald(meta) = &log.meta { + eprintln!("Source: {}", meta.source_file); + eprintln!("Records: {}", meta.record_count); + eprintln!("Duration: {:.1}s", meta.duration_seconds); + eprintln!("Sample rate: {:.1} Hz", meta.sample_rate_hz); + } + + eprintln!("Parsed {} data records", log.data.len()); + } +} diff --git a/src/parsers/mod.rs b/src/parsers/mod.rs index dda8c72..2c8eef8 100644 --- a/src/parsers/mod.rs +++ b/src/parsers/mod.rs @@ -1,5 +1,6 @@ pub mod aim; pub mod ecumaster; +pub mod emerald; pub mod haltech; pub mod link; pub mod romraider; @@ -8,6 +9,7 @@ pub mod types; pub use aim::Aim; pub use ecumaster::EcuMaster; +pub use emerald::Emerald; pub use haltech::Haltech; pub use link::Link; pub use romraider::RomRaider; diff --git a/src/parsers/types.rs b/src/parsers/types.rs index c65763a..58b1199 100644 --- a/src/parsers/types.rs +++ b/src/parsers/types.rs @@ -3,6 +3,7 @@ use std::error::Error; use super::aim::{AimChannel, AimMeta}; use super::ecumaster::{EcuMasterChannel, EcuMasterMeta}; +use super::emerald::{EmeraldChannel, EmeraldMeta}; use super::haltech::{HaltechChannel, HaltechMeta}; use super::link::{LinkChannel, LinkMeta}; use super::romraider::{RomRaiderChannel, RomRaiderMeta}; @@ -12,6 +13,7 @@ use super::speeduino::{SpeeduinoChannel, SpeeduinoMeta}; #[derive(Clone, Debug, Serialize, Default)] pub enum Meta { Aim(AimMeta), + Emerald(EmeraldMeta), Haltech(HaltechMeta), EcuMaster(EcuMasterMeta), Link(LinkMeta), @@ -36,6 +38,7 @@ pub struct ComputedChannelInfo { #[derive(Clone, Debug)] pub enum Channel { Aim(AimChannel), + Emerald(EmeraldChannel), Haltech(HaltechChannel), EcuMaster(EcuMasterChannel), Link(LinkChannel), @@ -52,6 +55,7 @@ impl Serialize for Channel { { match self { Channel::Aim(a) => a.serialize(serializer), + Channel::Emerald(e) => e.serialize(serializer), Channel::Haltech(h) => h.serialize(serializer), Channel::EcuMaster(e) => e.serialize(serializer), Channel::Link(l) => l.serialize(serializer), @@ -66,6 +70,7 @@ impl Channel { pub fn name(&self) -> String { match self { Channel::Aim(a) => a.name.clone(), + Channel::Emerald(e) => e.name.clone(), Channel::Haltech(h) => h.name.clone(), Channel::EcuMaster(e) => e.name.clone(), Channel::Link(l) => l.name.clone(), @@ -79,6 +84,7 @@ impl Channel { pub fn id(&self) -> String { match self { Channel::Aim(a) => a.name.clone(), + Channel::Emerald(e) => e.channel_id.to_string(), Channel::Haltech(h) => h.id.clone(), Channel::EcuMaster(e) => e.path.clone(), Channel::Link(l) => l.channel_id.to_string(), @@ -91,6 +97,7 @@ impl Channel { pub fn type_name(&self) -> String { match self { Channel::Aim(_) => "AIM".to_string(), + Channel::Emerald(_) => "Emerald".to_string(), Channel::Haltech(h) => h.r#type.as_ref().to_string(), Channel::EcuMaster(e) => e.path.clone(), Channel::Link(_) => "Link".to_string(), @@ -103,6 +110,7 @@ impl Channel { pub fn display_min(&self) -> Option { match self { Channel::Aim(_) => None, + Channel::Emerald(_) => None, Channel::Haltech(h) => h.display_min, Channel::EcuMaster(_) => None, Channel::Link(_) => None, @@ -115,6 +123,7 @@ impl Channel { pub fn display_max(&self) -> Option { match self { Channel::Aim(_) => None, + Channel::Emerald(_) => None, Channel::Haltech(h) => h.display_max, Channel::EcuMaster(_) => None, Channel::Link(_) => None, @@ -127,6 +136,7 @@ impl Channel { pub fn unit(&self) -> &str { match self { Channel::Aim(a) => a.unit(), + Channel::Emerald(e) => e.unit(), Channel::Haltech(h) => h.unit(), Channel::EcuMaster(e) => e.unit(), Channel::Link(l) => l.unit(), @@ -216,6 +226,7 @@ pub enum EcuType { #[default] Haltech, Aim, + Emerald, EcuMaster, MegaSquirt, Aem, @@ -232,6 +243,7 @@ impl EcuType { match self { EcuType::Haltech => "Haltech", EcuType::Aim => "AIM", + EcuType::Emerald => "Emerald", EcuType::EcuMaster => "ECUMaster", EcuType::MegaSquirt => "MegaSquirt", EcuType::Aem => "AEM", diff --git a/src/state.rs b/src/state.rs index cfb6a5e..f818a2d 100644 --- a/src/state.rs +++ b/src/state.rs @@ -18,8 +18,9 @@ pub const MAX_CHANNELS: usize = 10; pub const MAX_CHART_POINTS: usize = 2000; /// Supported log file extensions (used in file dialogs) -pub const SUPPORTED_EXTENSIONS: &[&str] = - &["csv", "log", "txt", "mlg", "llg", "llg5", "xrk", "drk"]; +pub const SUPPORTED_EXTENSIONS: &[&str] = &[ + "csv", "log", "txt", "mlg", "llg", "llg5", "xrk", "drk", "lg1", "lg2", +]; /// Color palette for chart lines (matches original theme) pub const CHART_COLORS: &[[u8; 3]] = &[ @@ -180,6 +181,8 @@ pub enum ActiveTool { LogViewer, /// Scatter plot view for comparing two variables with color coding ScatterPlot, + /// Histogram view for 2D distribution analysis + Histogram, } impl ActiveTool { @@ -188,6 +191,69 @@ impl ActiveTool { match self { ActiveTool::LogViewer => "Log Viewer", ActiveTool::ScatterPlot => "Scatter Plots", + ActiveTool::Histogram => "Histogram", + } + } +} + +/// The currently active side panel in the activity bar +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub enum ActivePanel { + /// Files panel - file management, loading, file list + #[default] + Files, + /// Channels panel - channel selection and selected channels + Channels, + /// Tools panel - analysis tools, computed channels, export + Tools, + /// Settings panel - all preferences consolidated + Settings, +} + +impl ActivePanel { + /// Get the display name for this panel + pub fn name(&self) -> &'static str { + match self { + ActivePanel::Files => "Files", + ActivePanel::Channels => "Channels", + ActivePanel::Tools => "Tools", + ActivePanel::Settings => "Settings", + } + } + + /// Get the icon character for this panel (using Unicode symbols) + pub fn icon(&self) -> &'static str { + match self { + ActivePanel::Files => "\u{1F4C1}", // Folder icon + ActivePanel::Channels => "\u{1F4CA}", // Chart icon + ActivePanel::Tools => "\u{1F527}", // Wrench icon + ActivePanel::Settings => "\u{2699}", // Gear icon + } + } +} + +/// Font scale preference for UI elements +#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)] +pub enum FontScale { + /// Smaller fonts (0.85x) + Small, + /// Default size (1.0x) + #[default] + Medium, + /// Larger fonts (1.2x) + Large, + /// Extra large fonts (1.4x) + ExtraLarge, +} + +impl FontScale { + /// Get the multiplier for this font scale + pub fn multiplier(&self) -> f32 { + match self { + FontScale::Small => 0.85, + FontScale::Medium => 1.0, + FontScale::Large => 1.2, + FontScale::ExtraLarge => 1.4, } } } @@ -227,6 +293,103 @@ pub struct ScatterPlotState { pub right: ScatterPlotConfig, } +// ============================================================================ +// Histogram Types +// ============================================================================ + +/// Display mode for histogram cell values +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub enum HistogramMode { + /// Show average Z-channel value in cells + #[default] + AverageZ, + /// Show hit count (number of data points) in cells + HitCount, +} + +/// Grid size options for histogram +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub enum HistogramGridSize { + /// 16x16 grid + Size16, + /// 32x32 grid + #[default] + Size32, + /// 64x64 grid + Size64, +} + +impl HistogramGridSize { + /// Get the numeric size value + pub fn size(&self) -> usize { + match self { + HistogramGridSize::Size16 => 16, + HistogramGridSize::Size32 => 32, + HistogramGridSize::Size64 => 64, + } + } + + /// Get display name + pub fn name(&self) -> &'static str { + match self { + HistogramGridSize::Size16 => "16x16", + HistogramGridSize::Size32 => "32x32", + HistogramGridSize::Size64 => "64x64", + } + } +} + +/// Statistics for a selected histogram cell +#[derive(Clone, Default)] +pub struct SelectedHistogramCell { + /// X bin index + pub x_bin: usize, + /// Y bin index + pub y_bin: usize, + /// X axis value range (min, max) for this cell + pub x_range: (f64, f64), + /// Y axis value range (min, max) for this cell + pub y_range: (f64, f64), + /// Number of data points in cell + pub hit_count: u32, + /// Sum of weights (for weighted averaging) + pub cell_weight: f64, + /// Variance of Z values + pub variance: f64, + /// Standard deviation of Z values + pub std_dev: f64, + /// Minimum Z value in cell + pub minimum: f64, + /// Mean Z value in cell + pub mean: f64, + /// Maximum Z value in cell + pub maximum: f64, +} + +/// Configuration for the histogram view +#[derive(Clone, Default)] +pub struct HistogramConfig { + /// Channel index for X axis + pub x_channel: Option, + /// Channel index for Y axis + pub y_channel: Option, + /// Channel index for Z axis (value to average) + pub z_channel: Option, + /// Display mode (average Z vs hit count) + pub mode: HistogramMode, + /// Grid size + pub grid_size: HistogramGridSize, + /// Currently selected cell (for statistics display) + pub selected_cell: Option, +} + +/// State for the histogram view +#[derive(Clone, Default)] +pub struct HistogramState { + /// Histogram configuration + pub config: HistogramConfig, +} + // ============================================================================ // Tab Types // ============================================================================ @@ -252,6 +415,8 @@ pub struct Tab { pub time_range: Option<(f64, f64)>, /// Scatter plot state for this tab (dual heatmaps) pub scatter_plot_state: ScatterPlotState, + /// Histogram state for this tab + pub histogram_state: HistogramState, /// Request to jump the view to a specific time (used for min/max jump buttons) pub jump_to_time: Option, } @@ -274,6 +439,7 @@ impl Tab { chart_interacted: false, time_range: None, scatter_plot_state, + histogram_state: HistogramState::default(), jump_to_time: None, } } diff --git a/src/ui/activity_bar.rs b/src/ui/activity_bar.rs new file mode 100644 index 0000000..96c0b10 --- /dev/null +++ b/src/ui/activity_bar.rs @@ -0,0 +1,240 @@ +//! Activity bar component - VS Code-style vertical icon strip for panel navigation. + +use eframe::egui; + +use crate::app::UltraLogApp; +use crate::state::ActivePanel; + +/// Width of the activity bar in pixels +pub const ACTIVITY_BAR_WIDTH: f32 = 48.0; + +/// Size of icons in the activity bar +const ICON_SIZE: f32 = 24.0; + +/// Padding around icons (reserved for future use) +#[allow(dead_code)] +const ICON_PADDING: f32 = 12.0; + +impl UltraLogApp { + /// Render the activity bar (vertical icon strip on the far left) + pub fn render_activity_bar(&mut self, ui: &mut egui::Ui) { + ui.vertical(|ui| { + ui.add_space(8.0); + + // Render each panel icon + for panel in [ + ActivePanel::Files, + ActivePanel::Channels, + ActivePanel::Tools, + ActivePanel::Settings, + ] { + let is_selected = self.active_panel == panel; + self.render_activity_icon(ui, panel, is_selected); + ui.add_space(4.0); + } + }); + } + + /// Render a single activity bar icon button + fn render_activity_icon(&mut self, ui: &mut egui::Ui, panel: ActivePanel, is_selected: bool) { + let button_size = egui::vec2(ACTIVITY_BAR_WIDTH - 8.0, ACTIVITY_BAR_WIDTH - 8.0); + + // Colors + let bg_color = if is_selected { + egui::Color32::from_rgb(60, 60, 60) + } else { + egui::Color32::TRANSPARENT + }; + + let icon_color = if is_selected { + egui::Color32::WHITE + } else { + egui::Color32::from_rgb(150, 150, 150) + }; + + let hover_bg = egui::Color32::from_rgb(50, 50, 50); + + // Selected indicator bar on the left + let indicator_color = egui::Color32::from_rgb(113, 120, 78); // Olive green accent + + let (rect, response) = ui.allocate_exact_size(button_size, egui::Sense::click()); + + if response.clicked() { + self.active_panel = panel; + } + + let is_hovered = response.hovered(); + + // Draw background + let final_bg = if is_hovered && !is_selected { + hover_bg + } else { + bg_color + }; + + if final_bg != egui::Color32::TRANSPARENT { + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(4), final_bg); + } + + // Draw selection indicator bar on left edge + if is_selected { + let indicator_rect = + egui::Rect::from_min_size(rect.left_top(), egui::vec2(3.0, rect.height())); + ui.painter() + .rect_filled(indicator_rect, egui::CornerRadius::ZERO, indicator_color); + } + + // Draw the icon + let center = rect.center(); + self.draw_panel_icon(ui, center, ICON_SIZE, icon_color, panel); + + // Tooltip on hover + if is_hovered { + response.on_hover_text(panel.name()); + } + } + + /// Draw the icon for a specific panel type + fn draw_panel_icon( + &self, + ui: &egui::Ui, + center: egui::Pos2, + size: f32, + color: egui::Color32, + panel: ActivePanel, + ) { + let painter = ui.painter(); + let half = size / 2.0; + + match panel { + ActivePanel::Files => { + // Folder icon + let folder_width = size * 0.9; + let folder_height = size * 0.7; + let tab_width = folder_width * 0.4; + let tab_height = folder_height * 0.15; + + // Main folder body + let body_rect = egui::Rect::from_center_size( + egui::pos2(center.x, center.y + tab_height / 2.0), + egui::vec2(folder_width, folder_height - tab_height), + ); + painter.rect_stroke( + body_rect, + egui::CornerRadius::same(2), + egui::Stroke::new(1.5, color), + egui::StrokeKind::Outside, + ); + + // Folder tab + let tab_rect = egui::Rect::from_min_size( + egui::pos2(body_rect.left() + 2.0, body_rect.top() - tab_height), + egui::vec2(tab_width, tab_height + 2.0), + ); + painter.rect_filled(tab_rect, egui::CornerRadius::same(1), color); + } + ActivePanel::Channels => { + // Bar chart icon + let bar_width = size * 0.15; + let spacing = size * 0.22; + let base_y = center.y + half * 0.6; + + // Three bars of different heights + let heights = [size * 0.5, size * 0.8, size * 0.35]; + let start_x = center.x - spacing; + + for (i, &height) in heights.iter().enumerate() { + let x = start_x + (i as f32) * spacing; + let bar_rect = egui::Rect::from_min_max( + egui::pos2(x - bar_width / 2.0, base_y - height), + egui::pos2(x + bar_width / 2.0, base_y), + ); + painter.rect_filled(bar_rect, egui::CornerRadius::same(1), color); + } + } + ActivePanel::Tools => { + // Line chart icon (for analysis/computed channels) + let stroke = egui::Stroke::new(2.0, color); + let chart_width = size * 0.8; + let chart_height = size * 0.6; + let left = center.x - chart_width / 2.0; + let right = center.x + chart_width / 2.0; + let top = center.y - chart_height / 2.0; + let bottom = center.y + chart_height / 2.0; + + // Draw axes + painter.line_segment([egui::pos2(left, top), egui::pos2(left, bottom)], stroke); + painter.line_segment( + [egui::pos2(left, bottom), egui::pos2(right, bottom)], + stroke, + ); + + // Draw a line chart curve + let points = [ + egui::pos2(left + chart_width * 0.1, bottom - chart_height * 0.2), + egui::pos2(left + chart_width * 0.3, bottom - chart_height * 0.6), + egui::pos2(left + chart_width * 0.5, bottom - chart_height * 0.4), + egui::pos2(left + chart_width * 0.7, bottom - chart_height * 0.8), + egui::pos2(left + chart_width * 0.9, bottom - chart_height * 0.5), + ]; + + for i in 0..points.len() - 1 { + painter.line_segment([points[i], points[i + 1]], stroke); + } + } + ActivePanel::Settings => { + // Gear/cog icon with rectangular teeth + let body_radius = half * 0.55; + let tooth_outer = half * 0.85; + let tooth_width = 0.35; // Width of each tooth in radians + let teeth = 6; + + // Draw the gear body (filled circle) + painter.circle_stroke(center, body_radius, egui::Stroke::new(2.0, color)); + + // Draw rectangular teeth + for i in 0..teeth { + let angle = (i as f32) * std::f32::consts::TAU / teeth as f32; + + // Calculate the four corners of each tooth + let angle_left = angle - tooth_width / 2.0; + let angle_right = angle + tooth_width / 2.0; + + // Inner corners (on the body circle) + let inner_left = egui::pos2( + center.x + body_radius * angle_left.cos(), + center.y + body_radius * angle_left.sin(), + ); + let inner_right = egui::pos2( + center.x + body_radius * angle_right.cos(), + center.y + body_radius * angle_right.sin(), + ); + + // Outer corners (extending outward) + let outer_left = egui::pos2( + center.x + tooth_outer * angle_left.cos(), + center.y + tooth_outer * angle_left.sin(), + ); + let outer_right = egui::pos2( + center.x + tooth_outer * angle_right.cos(), + center.y + tooth_outer * angle_right.sin(), + ); + + // Draw the tooth as a filled polygon + let tooth_points = vec![inner_left, outer_left, outer_right, inner_right]; + painter.add(egui::Shape::convex_polygon( + tooth_points, + color, + egui::Stroke::NONE, + )); + } + + // Draw center hole + let hole_radius = half * 0.2; + painter.circle_filled(center, hole_radius, egui::Color32::from_rgb(30, 30, 30)); + painter.circle_stroke(center, hole_radius, egui::Stroke::new(1.5, color)); + } + } + } +} diff --git a/src/ui/analysis_panel.rs b/src/ui/analysis_panel.rs new file mode 100644 index 0000000..df69db4 --- /dev/null +++ b/src/ui/analysis_panel.rs @@ -0,0 +1,1151 @@ +//! Analysis Panel UI. +//! +//! Provides a window for users to run analysis algorithms on the active log file, +//! including signal processing filters and statistical analyzers. +//! Features category tabs for filtering and clear separation between tools and results. + +use eframe::egui; + +use crate::analysis::{AnalysisResult, Analyzer, AnalyzerConfig, LogDataAccess}; +use crate::app::UltraLogApp; +use crate::computed::{ComputedChannel, ComputedChannelTemplate}; +use crate::normalize::sort_channels_by_priority; +use crate::parsers::types::ComputedChannelInfo; +use crate::parsers::Channel; +use crate::state::{SelectedChannel, CHART_COLORS}; + +/// Info about an analyzer for display (avoids borrow issues) +struct AnalyzerInfo { + id: String, + name: String, + description: String, + category: String, + config: AnalyzerConfig, +} + +/// Parameter definition for UI rendering +#[derive(Clone)] +struct ParamDef { + key: String, + label: String, + param_type: ParamType, + /// Tooltip with helpful information about expected channel types + tooltip: Option, +} + +#[derive(Clone)] +enum ParamType { + Channel, // Channel selector dropdown + Integer { min: i32, max: i32 }, + Float { min: f64, max: f64 }, + Boolean, +} + +/// Category labels for the tab bar +/// First element is the category ID (must match analyzer.category()), second is display name +const CATEGORIES: &[(&str, &str)] = &[ + ("all", "All"), + ("Filters", "Filters"), + ("Statistics", "Statistics"), + ("AFR", "AFR"), + ("Derived", "Derived"), +]; + +impl UltraLogApp { + /// Render the analysis panel window + pub fn render_analysis_panel(&mut self, ctx: &egui::Context) { + if !self.show_analysis_panel { + return; + } + + let mut open = true; + + egui::Window::new("Analysis Tools") + .open(&mut open) + .resizable(true) + .default_width(550.0) + .default_height(550.0) + .order(egui::Order::Foreground) + .show(ctx, |ui| { + // Check if we have a file loaded + let has_file = self.selected_file.is_some() && !self.files.is_empty(); + + if !has_file { + ui.vertical_centered(|ui| { + ui.add_space(40.0); + ui.label( + egui::RichText::new("No log file loaded") + .color(egui::Color32::GRAY) + .size(16.0), + ); + ui.add_space(8.0); + ui.label( + egui::RichText::new("Load a log file to access analysis tools.") + .color(egui::Color32::GRAY) + .small(), + ); + ui.add_space(40.0); + }); + } else { + // Category tabs at the top + self.render_category_tabs(ui); + + ui.add_space(4.0); + ui.separator(); + ui.add_space(4.0); + + // Main content + self.render_analyzer_list(ui); + } + }); + + if !open { + self.show_analysis_panel = false; + } + } + + /// Render the category filter tabs + fn render_category_tabs(&mut self, ui: &mut egui::Ui) { + ui.horizontal(|ui| { + for (category_id, display_name) in CATEGORIES { + let is_selected = match &self.analysis_selected_category { + None => *category_id == "all", + Some(cat) => cat == *category_id, + }; + + let text = egui::RichText::new(*display_name); + let text = if is_selected { + text.strong() + } else { + text.color(egui::Color32::GRAY) + }; + + let btn = egui::Button::new(text) + .fill(if is_selected { + egui::Color32::from_rgb(60, 70, 60) + } else { + egui::Color32::TRANSPARENT + }) + .corner_radius(4.0); + + if ui.add(btn).clicked() { + if *category_id == "all" { + self.analysis_selected_category = None; + } else { + self.analysis_selected_category = Some(category_id.to_string()); + } + } + } + }); + } + + /// Render the list of available analyzers + fn render_analyzer_list(&mut self, ui: &mut egui::Ui) { + // Get channel names from the currently selected file, with normalization and sorting + let (channel_names, channel_display_names): (Vec, Vec) = + if let Some(file_idx) = self.selected_file { + if let Some(file) = self.files.get(file_idx) { + let raw_names = file.log.channel_names(); + + // Sort channels like the main sidebar: normalized first, then alphabetically + let sorted = sort_channels_by_priority( + raw_names.len(), + |idx| raw_names.get(idx).cloned().unwrap_or_default(), + self.field_normalization, + Some(&self.custom_normalizations), + ); + + // Build parallel vectors: raw names for matching, display names for UI + let mut raw_sorted = Vec::with_capacity(sorted.len()); + let mut display_sorted = Vec::with_capacity(sorted.len()); + + for (idx, display_name, _is_normalized) in sorted { + if let Some(raw_name) = raw_names.get(idx) { + raw_sorted.push(raw_name.clone()); + display_sorted.push(display_name); + } + } + + (raw_sorted, display_sorted) + } else { + (vec![], vec![]) + } + } else { + (vec![], vec![]) + }; + + // Collect analyzer info upfront to avoid borrow issues + let analyzer_infos: Vec = self + .analyzer_registry + .all() + .iter() + .map(|a| AnalyzerInfo { + id: a.id().to_string(), + name: a.name().to_string(), + description: a.description().to_string(), + category: a.category().to_string(), + config: a.get_config(), + }) + .collect(); + + // Filter by selected category + let filtered_infos: Vec<&AnalyzerInfo> = analyzer_infos + .iter() + .filter(|info| match &self.analysis_selected_category { + None => true, // Show all + Some(cat) => info.category == *cat, + }) + .collect(); + + // Track actions to perform after rendering + let mut analyzer_to_run: Option = None; + let mut analyzer_to_run_and_chart: Option = None; + let mut config_updates: Vec<(String, AnalyzerConfig)> = Vec::new(); + let mut result_to_add: Option = None; + let mut result_to_remove: Option = None; + + egui::ScrollArea::vertical() + .id_salt("analysis_panel_scroll") + .show(ui, |ui| { + // Results section - always visible at top if there are results + if let Some(file_idx) = self.selected_file { + if let Some(results) = self.analysis_results.get(&file_idx) { + if !results.is_empty() { + // Results header with count + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(format!("Results ({})", results.len())) + .strong() + .size(15.0), + ); + + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + if ui + .small_button("Clear All") + .on_hover_text("Remove all results") + .clicked() + { + // Mark for clearing + result_to_remove = Some(usize::MAX); + } + }, + ); + }); + + ui.add_space(4.0); + + // Result cards + for (i, result) in results.iter().enumerate() { + if let Some(action) = + Self::render_analysis_result_with_actions(ui, result, i) + { + match action { + ResultAction::AddToChart => result_to_add = Some(i), + ResultAction::Remove => result_to_remove = Some(i), + } + } + } + + ui.add_space(8.0); + ui.separator(); + ui.add_space(8.0); + } + } + } + + // Analyzers section header + let category_label = match &self.analysis_selected_category { + None => "All Tools".to_string(), + Some(cat) => cat.clone(), + }; + + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(format!( + "{} ({})", + category_label, + filtered_infos.len() + )) + .strong() + .size(15.0), + ); + }); + + ui.add_space(4.0); + + if filtered_infos.is_empty() { + ui.label( + egui::RichText::new("No analyzers in this category") + .color(egui::Color32::GRAY), + ); + } else { + // Render analyzer cards + for info in filtered_infos { + if let Some((id, action)) = Self::render_analyzer_card_with_config( + ui, + info, + &channel_names, + &channel_display_names, + ) { + match action { + AnalyzerAction::Run => { + analyzer_to_run = Some(id); + } + AnalyzerAction::RunAndChart => { + analyzer_to_run_and_chart = Some(id); + } + AnalyzerAction::UpdateConfig(config) => { + config_updates.push((id, config)); + } + } + } + } + } + }); + + // Handle deferred actions + + // Apply config updates + for (id, config) in config_updates { + if let Some(analyzer) = self.analyzer_registry.find_by_id_mut(&id) { + analyzer.set_config(&config); + } + } + + // Run analyzer (just run, don't add to chart) + if let Some(id) = analyzer_to_run { + self.run_analyzer(&id); + } + + // Run analyzer AND add to chart immediately + if let Some(id) = analyzer_to_run_and_chart { + self.run_analyzer_and_chart(&id); + } + + // Add result to chart + if let Some(idx) = result_to_add { + self.add_analysis_result_to_chart(idx); + } + + // Remove result(s) + if let Some(idx) = result_to_remove { + if let Some(file_idx) = self.selected_file { + if let Some(results) = self.analysis_results.get_mut(&file_idx) { + if idx == usize::MAX { + // Clear all + results.clear(); + } else if idx < results.len() { + results.remove(idx); + } + } + } + } + } + + /// Render a single analyzer card with configuration options + /// Returns Some((id, action)) if an action was triggered + /// + /// `channel_names` - raw channel names (for config storage and matching) + /// `channel_display_names` - display names (normalized if enabled) + fn render_analyzer_card_with_config( + ui: &mut egui::Ui, + info: &AnalyzerInfo, + channel_names: &[String], + channel_display_names: &[String], + ) -> Option<(String, AnalyzerAction)> { + let mut action: Option = None; + let mut new_config = info.config.clone(); + + // Get parameter definitions for this analyzer + let param_defs = get_analyzer_params(&info.id); + + // Check if required channels are available + let channels_available = check_channels_available( + &new_config, + ¶m_defs, + channel_names, + channel_display_names, + ); + + let card_bg = if channels_available { + egui::Color32::from_rgb(40, 45, 40) + } else { + egui::Color32::from_rgb(45, 45, 45) + }; + + egui::Frame::NONE + .fill(card_bg) + .corner_radius(6) + .inner_margin(10.0) + .outer_margin(egui::Margin::symmetric(0, 2)) + .show(ui, |ui| { + // Header row with name and Run button + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.label(egui::RichText::new(&info.name).strong().size(14.0)); + ui.label( + egui::RichText::new(&info.description) + .color(egui::Color32::GRAY) + .size(12.0), + ); + }); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // "Run & Chart" button (primary action) + let run_chart_btn = egui::Button::new("Run & Chart") + .fill(egui::Color32::from_rgb(60, 100, 60)); + let run_chart_response = ui.add_enabled(channels_available, run_chart_btn); + + if run_chart_response.clicked() { + action = Some(AnalyzerAction::RunAndChart); + } + + if !channels_available { + run_chart_response.on_hover_text("Select valid channels first"); + } else { + run_chart_response + .on_hover_text("Run analysis and add result to chart"); + } + + ui.add_space(4.0); + + // "Run" button (secondary - just adds to results) + let run_btn = egui::Button::new("Run"); + let run_response = ui.add_enabled(channels_available, run_btn); + + if run_response.clicked() { + action = Some(AnalyzerAction::Run); + } + + if !channels_available { + run_response.on_hover_text("Select valid channels first"); + } else { + run_response.on_hover_text("Run analysis (add to chart later)"); + } + }); + }); + + ui.add_space(6.0); + + // Parameter configuration + let mut config_changed = false; + + for param in ¶m_defs { + ui.horizontal(|ui| { + let label_response = ui.label( + egui::RichText::new(¶m.label) + .color(egui::Color32::GRAY) + .small(), + ); + + // Show tooltip if available + if let Some(tooltip) = ¶m.tooltip { + label_response.on_hover_text(tooltip); + } + + ui.add_space(4.0); + + match ¶m.param_type { + ParamType::Channel => { + let config_value = new_config + .parameters + .get(¶m.key) + .cloned() + .unwrap_or_default(); + + // Resolve config value to raw channel name + // Config might have a normalized name (e.g., "AFR") that needs + // to be resolved to the actual raw name (e.g., "Wideband O2 Overall") + let (current_raw, current_display) = if let Some(idx) = + channel_names + .iter() + .position(|n| n.eq_ignore_ascii_case(&config_value)) + { + // Config value matches a raw name directly + ( + channel_names[idx].clone(), + channel_display_names + .get(idx) + .cloned() + .unwrap_or(config_value.clone()), + ) + } else if let Some(idx) = channel_display_names + .iter() + .position(|n| n.eq_ignore_ascii_case(&config_value)) + { + // Config value matches a display/normalized name - resolve to raw + let raw = channel_names + .get(idx) + .cloned() + .unwrap_or(config_value.clone()); + let display = channel_display_names[idx].clone(); + // Auto-update config to use the raw name + new_config.parameters.insert(param.key.clone(), raw.clone()); + config_changed = true; + (raw, display) + } else { + // No match found, keep as-is + (config_value.clone(), config_value) + }; + + let combo_response = egui::ComboBox::from_id_salt(format!( + "{}_{}_combo", + info.id, param.key + )) + .width(180.0) + .selected_text(¤t_display) + .show_ui(ui, |ui| { + // Show display names but store raw names + for (raw_name, display_name) in + channel_names.iter().zip(channel_display_names.iter()) + { + if ui + .selectable_label( + current_raw == *raw_name, + display_name, + ) + .clicked() + { + new_config + .parameters + .insert(param.key.clone(), raw_name.clone()); + config_changed = true; + } + } + }); + + // Show tooltip on combo box too + if let Some(tooltip) = ¶m.tooltip { + combo_response.response.on_hover_text(tooltip); + } + } + ParamType::Integer { min, max } => { + let current: i32 = new_config + .parameters + .get(¶m.key) + .and_then(|s| s.parse().ok()) + .unwrap_or(*min); + + let mut value = current; + if ui + .add( + egui::DragValue::new(&mut value) + .range(*min..=*max) + .speed(1), + ) + .changed() + { + new_config + .parameters + .insert(param.key.clone(), value.to_string()); + config_changed = true; + } + } + ParamType::Float { min, max } => { + let current: f64 = new_config + .parameters + .get(¶m.key) + .and_then(|s| s.parse().ok()) + .unwrap_or(*min); + + let mut value = current; + if ui + .add( + egui::DragValue::new(&mut value) + .range(*min..=*max) + .speed(0.01) + .fixed_decimals(2), + ) + .changed() + { + new_config + .parameters + .insert(param.key.clone(), value.to_string()); + config_changed = true; + } + } + ParamType::Boolean => { + let current: bool = new_config + .parameters + .get(¶m.key) + .and_then(|s| s.parse().ok()) + .unwrap_or(false); + + let mut value = current; + if ui.checkbox(&mut value, "").changed() { + new_config + .parameters + .insert(param.key.clone(), value.to_string()); + config_changed = true; + } + } + } + }); + } + + if config_changed && action.is_none() { + action = Some(AnalyzerAction::UpdateConfig(new_config.clone())); + } + }); + + action.map(|a| (info.id.clone(), a)) + } + + /// Render an analysis result with Add to Chart and Remove buttons + fn render_analysis_result_with_actions( + ui: &mut egui::Ui, + result: &AnalysisResult, + _index: usize, + ) -> Option { + let mut action: Option = None; + + egui::Frame::NONE + .fill(egui::Color32::from_rgb(35, 50, 55)) + .corner_radius(6) + .inner_margin(8.0) + .outer_margin(egui::Margin::symmetric(0, 2)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(&result.name).strong()); + if !result.unit.is_empty() { + ui.label( + egui::RichText::new(format!("({})", result.unit)) + .color(egui::Color32::GRAY) + .small(), + ); + } + }); + + // Show basic stats about the result + if !result.values.is_empty() { + let min = result.values.iter().cloned().fold(f64::INFINITY, f64::min); + let max = result + .values + .iter() + .cloned() + .fold(f64::NEG_INFINITY, f64::max); + let mean: f64 = + result.values.iter().sum::() / result.values.len() as f64; + + ui.label( + egui::RichText::new(format!( + "Min: {:.2} Max: {:.2} Mean: {:.2} ({} pts)", + min, + max, + mean, + result.values.len() + )) + .color(egui::Color32::GRAY) + .small(), + ); + } + }); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // Remove button + if ui + .small_button("x") + .on_hover_text("Remove result") + .clicked() + { + action = Some(ResultAction::Remove); + } + + ui.add_space(4.0); + + // Add to chart button + if ui + .button("+ Chart") + .on_hover_text("Add to chart as a channel") + .clicked() + { + action = Some(ResultAction::AddToChart); + } + }); + }); + }); + + action + } + + /// Add an analysis result to the chart as a computed channel + fn add_analysis_result_to_chart(&mut self, result_index: usize) { + let file_idx = match self.selected_file { + Some(idx) => idx, + None => return, + }; + + let tab_idx = match self.active_tab { + Some(idx) => idx, + None => return, + }; + + // Get the result + let result = match self.analysis_results.get(&file_idx) { + Some(results) => match results.get(result_index) { + Some(r) => r.clone(), + None => return, + }, + None => return, + }; + + // Create a computed channel from the result + let template = ComputedChannelTemplate::new( + result.name.clone(), + format!("_analysis_result_{}", result_index), // Placeholder formula + result.unit.clone(), + format!("Analysis result: {}", result.metadata.algorithm), + ); + + let mut computed = ComputedChannel::from_template(template); + computed.cached_data = Some(result.values.clone()); + + // Add to file's computed channels + let computed_channels = self.file_computed_channels.entry(file_idx).or_default(); + computed_channels.push(computed.clone()); + + // Get the virtual channel index + let file = match self.files.get(file_idx) { + Some(f) => f, + None => return, + }; + let base_channel_count = file.log.channels.len(); + let virtual_channel_index = base_channel_count + computed_channels.len() - 1; + + // Find next available color + let used_colors: std::collections::HashSet = self.tabs[tab_idx] + .selected_channels + .iter() + .map(|c| c.color_index) + .collect(); + let color_index = (0..CHART_COLORS.len()) + .find(|&i| !used_colors.contains(&i)) + .unwrap_or(0); + + // Create the channel enum variant + let channel = Channel::Computed(ComputedChannelInfo { + name: computed.name().to_string(), + formula: computed.formula().to_string(), + unit: computed.unit().to_string(), + }); + + // Add to selected channels + self.tabs[tab_idx].selected_channels.push(SelectedChannel { + file_index: file_idx, + channel_index: virtual_channel_index, + channel, + color_index, + }); + + self.show_toast_success(&format!("Added '{}' to chart", result.name)); + } + + /// Run an analyzer by its ID + fn run_analyzer(&mut self, analyzer_id: &str) { + let file_idx = match self.selected_file { + Some(idx) => idx, + None => { + self.show_toast_error("No file selected"); + return; + } + }; + + // Get the log data + let log = match self.files.get(file_idx) { + Some(file) => &file.log, + None => { + self.show_toast_error("File not found"); + return; + } + }; + + // Find and run the analyzer + // We need to clone the analyzer to avoid borrow issues + let analyzer_clone: Option> = self + .analyzer_registry + .find_by_id(analyzer_id) + .map(|a| a.clone_box()); + + if let Some(analyzer) = analyzer_clone { + match analyzer.analyze(log) { + Ok(result) => { + let result_name = result.name.clone(); + self.analysis_results + .entry(file_idx) + .or_default() + .push(result); + self.show_toast_success(&format!("Analysis complete: {}", result_name)); + } + Err(e) => { + self.show_toast_error(&format!("Analysis failed: {}", e)); + } + } + } else { + self.show_toast_error(&format!("Analyzer not found: {}", analyzer_id)); + } + } + + /// Run an analyzer and immediately add the result to the chart + fn run_analyzer_and_chart(&mut self, analyzer_id: &str) { + let file_idx = match self.selected_file { + Some(idx) => idx, + None => { + self.show_toast_error("No file selected"); + return; + } + }; + + // Get the log data + let log = match self.files.get(file_idx) { + Some(file) => &file.log, + None => { + self.show_toast_error("File not found"); + return; + } + }; + + // Find and run the analyzer + let analyzer_clone: Option> = self + .analyzer_registry + .find_by_id(analyzer_id) + .map(|a| a.clone_box()); + + if let Some(analyzer) = analyzer_clone { + match analyzer.analyze(log) { + Ok(result) => { + let result_name = result.name.clone(); + + // Add to results + self.analysis_results + .entry(file_idx) + .or_default() + .push(result); + + // Get the index of the result we just added + let result_idx = self + .analysis_results + .get(&file_idx) + .map(|r| r.len().saturating_sub(1)) + .unwrap_or(0); + + // Immediately add to chart + self.add_analysis_result_to_chart(result_idx); + + self.show_toast_success(&format!("'{}' added to chart", result_name)); + } + Err(e) => { + self.show_toast_error(&format!("Analysis failed: {}", e)); + } + } + } else { + self.show_toast_error(&format!("Analyzer not found: {}", analyzer_id)); + } + } +} + +/// Actions that can be triggered by analyzer card +enum AnalyzerAction { + Run, + RunAndChart, + UpdateConfig(AnalyzerConfig), +} + +/// Actions for analysis results +enum ResultAction { + AddToChart, + Remove, +} + +/// Get parameter definitions for a specific analyzer +fn get_analyzer_params(analyzer_id: &str) -> Vec { + match analyzer_id { + // ============== Filters ============== + "moving_average" => vec![ + ParamDef { + key: "channel".to_string(), + label: "Channel:".to_string(), + param_type: ParamType::Channel, + tooltip: Some("Any numeric channel to smooth".to_string()), + }, + ParamDef { + key: "window_size".to_string(), + label: "Window:".to_string(), + param_type: ParamType::Integer { min: 2, max: 100 }, + tooltip: Some("Number of samples to average (larger = smoother)".to_string()), + }, + ], + "exponential_moving_average" => vec![ + ParamDef { + key: "channel".to_string(), + label: "Channel:".to_string(), + param_type: ParamType::Channel, + tooltip: Some("Any numeric channel to smooth".to_string()), + }, + ParamDef { + key: "alpha".to_string(), + label: "Alpha:".to_string(), + param_type: ParamType::Float { min: 0.01, max: 1.0 }, + tooltip: Some("Smoothing factor: 0.1 = very smooth, 0.5 = moderate, 0.9 = responsive".to_string()), + }, + ], + "median_filter" => vec![ + ParamDef { + key: "channel".to_string(), + label: "Channel:".to_string(), + param_type: ParamType::Channel, + tooltip: Some("Channel with spike noise to remove".to_string()), + }, + ParamDef { + key: "window_size".to_string(), + label: "Window:".to_string(), + param_type: ParamType::Integer { min: 3, max: 51 }, + tooltip: Some("Must be odd number. Larger = removes wider spikes".to_string()), + }, + ], + "butterworth_lowpass" => vec![ + ParamDef { + key: "channel".to_string(), + label: "Channel:".to_string(), + param_type: ParamType::Channel, + tooltip: Some("Channel to filter. Common: RPM, MAP, TPS, AFR, Knock".to_string()), + }, + ParamDef { + key: "cutoff_normalized".to_string(), + label: "Cutoff:".to_string(), + param_type: ParamType::Float { min: 0.01, max: 0.49 }, + tooltip: Some("Normalized cutoff (0-0.5). 0.1 = 10% of sample rate. Lower = more smoothing".to_string()), + }, + ParamDef { + key: "order".to_string(), + label: "Order:".to_string(), + param_type: ParamType::Integer { min: 1, max: 8 }, + tooltip: Some("Filter order (1-8). Higher = sharper cutoff but more ringing. 2-4 recommended".to_string()), + }, + ], + "butterworth_highpass" => vec![ + ParamDef { + key: "channel".to_string(), + label: "Channel:".to_string(), + param_type: ParamType::Channel, + tooltip: Some("Channel to remove DC/drift from. Common: Knock sensor, vibration data".to_string()), + }, + ParamDef { + key: "cutoff_normalized".to_string(), + label: "Cutoff:".to_string(), + param_type: ParamType::Float { min: 0.01, max: 0.49 }, + tooltip: Some("Normalized cutoff (0-0.5). Frequencies below this are removed".to_string()), + }, + ParamDef { + key: "order".to_string(), + label: "Order:".to_string(), + param_type: ParamType::Integer { min: 1, max: 8 }, + tooltip: Some("Filter order (1-8). Higher = sharper cutoff. 2-4 recommended".to_string()), + }, + ], + + // ============== Statistics ============== + "descriptive_stats" => vec![ParamDef { + key: "channel".to_string(), + label: "Channel:".to_string(), + param_type: ParamType::Channel, + tooltip: Some("Any channel to compute min/max/mean/stdev statistics".to_string()), + }], + "correlation" => vec![ + ParamDef { + key: "channel_x".to_string(), + label: "Channel X:".to_string(), + param_type: ParamType::Channel, + tooltip: Some("First channel (e.g., RPM, TPS, MAP)".to_string()), + }, + ParamDef { + key: "channel_y".to_string(), + label: "Channel Y:".to_string(), + param_type: ParamType::Channel, + tooltip: Some("Second channel to correlate (e.g., AFR, Fuel Trim)".to_string()), + }, + ], + "rate_of_change" => vec![ + ParamDef { + key: "channel".to_string(), + label: "Channel:".to_string(), + param_type: ParamType::Channel, + tooltip: Some("Channel to differentiate. Common: RPM (acceleration), TPS (throttle rate)".to_string()), + }, + ParamDef { + key: "time_based".to_string(), + label: "Per second:".to_string(), + param_type: ParamType::Boolean, + tooltip: Some("If checked, rate is per second. Otherwise, per sample".to_string()), + }, + ], + + // ============== AFR Analysis ============== + "fuel_trim_drift" => vec![ + ParamDef { + key: "channel".to_string(), + label: "Fuel Trim:".to_string(), + param_type: ParamType::Channel, + tooltip: Some("Fuel trim channel. Common names: LTFT, STFT, Long Term FT, Short Term FT, Fuel Trim, FT Bank1".to_string()), + }, + ParamDef { + key: "k".to_string(), + label: "Sensitivity (k):".to_string(), + param_type: ParamType::Float { min: 0.1, max: 10.0 }, + tooltip: Some("CUSUM slack parameter. Lower = more sensitive to small drifts. Default 2.5".to_string()), + }, + ParamDef { + key: "h".to_string(), + label: "Threshold (h):".to_string(), + param_type: ParamType::Float { min: 1.0, max: 100.0 }, + tooltip: Some("Detection threshold. Lower = faster detection, more false alarms. Default 20".to_string()), + }, + ParamDef { + key: "baseline_pct".to_string(), + label: "Baseline %:".to_string(), + param_type: ParamType::Float { min: 1.0, max: 50.0 }, + tooltip: Some("% of data from start to use as baseline. Default 10%".to_string()), + }, + ], + "rich_lean_zone" => vec![ + ParamDef { + key: "channel".to_string(), + label: "AFR/Lambda:".to_string(), + param_type: ParamType::Channel, + tooltip: Some("AFR or Lambda channel. Auto-detects unit type. Common: AFR, Lambda, Wideband O2, O2".to_string()), + }, + ParamDef { + key: "target".to_string(), + label: "Target:".to_string(), + param_type: ParamType::Float { min: 0.0, max: 20.0 }, + tooltip: Some("Target value. Set to 0 for auto-detect (AFR: 14.7, Lambda: 1.0). Or set manually.".to_string()), + }, + ParamDef { + key: "rich_threshold".to_string(), + label: "Rich threshold:".to_string(), + param_type: ParamType::Float { min: 0.0, max: 3.0 }, + tooltip: Some("Set to 0 for auto-detect (AFR: 0.5, Lambda: 0.03). Rich = below (target - threshold)".to_string()), + }, + ParamDef { + key: "lean_threshold".to_string(), + label: "Lean threshold:".to_string(), + param_type: ParamType::Float { min: 0.0, max: 3.0 }, + tooltip: Some("Set to 0 for auto-detect (AFR: 0.5, Lambda: 0.03). Lean = above (target + threshold)".to_string()), + }, + ], + "afr_deviation" => vec![ + ParamDef { + key: "channel".to_string(), + label: "AFR/Lambda:".to_string(), + param_type: ParamType::Channel, + tooltip: Some("AFR or Lambda channel. Auto-detects unit type. Common: AFR, Lambda, Wideband O2, O2".to_string()), + }, + ParamDef { + key: "target".to_string(), + label: "Target:".to_string(), + param_type: ParamType::Float { min: 0.0, max: 20.0 }, + tooltip: Some("Target value. Set to 0 for auto-detect (AFR: 14.7, Lambda: 1.0). Or set manually.".to_string()), + }, + ], + + // ============== Derived Calculations ============== + "volumetric_efficiency" => vec![ + ParamDef { + key: "rpm_channel".to_string(), + label: "RPM:".to_string(), + param_type: ParamType::Channel, + tooltip: Some("Engine RPM channel. Common names: RPM, Engine Speed, Engine RPM".to_string()), + }, + ParamDef { + key: "map_channel".to_string(), + label: "MAP (kPa):".to_string(), + param_type: ParamType::Channel, + tooltip: Some("Manifold Absolute Pressure in kPa. Common names: MAP, Manifold Pressure, Boost".to_string()), + }, + ParamDef { + key: "iat_channel".to_string(), + label: "IAT:".to_string(), + param_type: ParamType::Channel, + tooltip: Some("Intake Air Temp in °C. Common names: IAT, Intake Temp, Air Temp, ACT".to_string()), + }, + ParamDef { + key: "displacement_l".to_string(), + label: "Displacement (L):".to_string(), + param_type: ParamType::Float { min: 0.1, max: 10.0 }, + tooltip: Some("Engine displacement in liters. E.g., 2.0, 3.5, 5.7".to_string()), + }, + ParamDef { + key: "is_iat_kelvin".to_string(), + label: "IAT in Kelvin:".to_string(), + param_type: ParamType::Boolean, + tooltip: Some("Check if your IAT channel is already in Kelvin (rare). Usually °C.".to_string()), + }, + ], + "injector_duty_cycle" => vec![ + ParamDef { + key: "pulse_width_channel".to_string(), + label: "Pulse Width (ms):".to_string(), + param_type: ParamType::Channel, + tooltip: Some("Injector pulse width in milliseconds. Common names: IPW, Inj PW, Pulse Width, Fuel PW, Inj DC".to_string()), + }, + ParamDef { + key: "rpm_channel".to_string(), + label: "RPM:".to_string(), + param_type: ParamType::Channel, + tooltip: Some("Engine RPM channel. Common names: RPM, Engine Speed, Engine RPM".to_string()), + }, + ], + "lambda_calculator" => vec![ + ParamDef { + key: "afr_channel".to_string(), + label: "AFR Channel:".to_string(), + param_type: ParamType::Channel, + tooltip: Some("Air-Fuel Ratio channel. Common names: AFR, O2, Wideband, A/F Ratio".to_string()), + }, + ParamDef { + key: "stoich_afr".to_string(), + label: "Stoich AFR:".to_string(), + param_type: ParamType::Float { min: 5.0, max: 20.0 }, + tooltip: Some("Stoichiometric AFR for your fuel. Gasoline: 14.7, E85: 9.8, E10: 14.1, Methanol: 6.4".to_string()), + }, + ], + + _ => vec![], + } +} + +/// Check if required channels are available in the log +/// +/// Checks both raw channel names and normalized display names to handle +/// cases where analyzer defaults (e.g., "AFR") need to match normalized names +/// (e.g., "Wideband O2 Overall" -> "AFR") +fn check_channels_available( + config: &AnalyzerConfig, + param_defs: &[ParamDef], + channel_names: &[String], + channel_display_names: &[String], +) -> bool { + for param in param_defs { + if matches!(param.param_type, ParamType::Channel) { + if let Some(ch) = config.parameters.get(¶m.key) { + if !ch.is_empty() { + // Check if configured channel matches raw name OR display name + let found_in_raw = channel_names + .iter() + .any(|name| name.eq_ignore_ascii_case(ch)); + let found_in_display = channel_display_names + .iter() + .any(|name| name.eq_ignore_ascii_case(ch)); + + if !found_in_raw && !found_in_display { + return false; + } + } + } + } + } + true +} diff --git a/src/ui/channels.rs b/src/ui/channels.rs index 21c70a8..cfc029c 100644 --- a/src/ui/channels.rs +++ b/src/ui/channels.rs @@ -9,7 +9,12 @@ use crate::state::MAX_CHANNELS; impl UltraLogApp { /// Render channel selection panel - fills available space pub fn render_channel_selection(&mut self, ui: &mut egui::Ui) { - ui.heading("Channels"); + // Pre-compute scaled font sizes + let font_14 = self.scaled_font(14.0); + let font_16 = self.scaled_font(16.0); + let font_18 = self.scaled_font(18.0); + + ui.label(egui::RichText::new("Channels").heading().size(font_18)); ui.separator(); // Get active tab info @@ -31,7 +36,7 @@ impl UltraLogApp { // Computed Channels button if ui - .button("+ Computed Channels") + .button(egui::RichText::new("+ Computed Channels").size(font_14)) .on_hover_text("Create virtual channels from mathematical formulas") .clicked() { @@ -44,7 +49,7 @@ impl UltraLogApp { let mut search_text = current_search; let mut search_changed = false; ui.horizontal(|ui| { - ui.label("Search:"); + ui.label(egui::RichText::new("Search:").size(font_14)); let response = ui .add(egui::TextEdit::singleline(&mut search_text).desired_width(f32::INFINITY)); search_changed = response.changed(); @@ -58,10 +63,13 @@ impl UltraLogApp { ui.add_space(5.0); // Channel count - ui.label(format!( - "Selected: {} / {} | Total: {}", - selected_count, MAX_CHANNELS, channel_count - )); + ui.label( + egui::RichText::new(format!( + "Selected: {} / {} | Total: {}", + selected_count, MAX_CHANNELS, channel_count + )) + .size(font_14), + ); ui.separator(); @@ -173,7 +181,8 @@ impl UltraLogApp { "📊 Channels with Data ({})", channels_with.len() )) - .strong(), + .strong() + .size(font_16), ) .default_open(true) .show(ui, |ui| { @@ -203,7 +212,8 @@ impl UltraLogApp { "📭 Empty Channels ({})", channels_without.len() )) - .color(egui::Color32::GRAY), + .color(egui::Color32::GRAY) + .size(font_16), ) .default_open(false) // Collapsed by default .show(ui, |ui| { @@ -234,7 +244,8 @@ impl UltraLogApp { ui.label( egui::RichText::new("Select a file to view channels") .italics() - .color(egui::Color32::GRAY), + .color(egui::Color32::GRAY) + .size(font_16), ); }); } @@ -242,7 +253,17 @@ impl UltraLogApp { /// Render selected channel cards pub fn render_selected_channels(&mut self, ui: &mut egui::Ui) { - ui.heading("Selected Channels"); + // Pre-compute scaled font sizes + let font_12 = self.scaled_font(12.0); + let font_14 = self.scaled_font(14.0); + let font_16 = self.scaled_font(16.0); + let font_18 = self.scaled_font(18.0); + + ui.label( + egui::RichText::new("Selected Channels") + .heading() + .size(font_18), + ); ui.separator(); let use_normalization = self.field_normalization; @@ -254,6 +275,7 @@ impl UltraLogApp { struct ChannelCardData { color: egui::Color32, display_name: String, + is_computed: bool, min_str: Option, max_str: Option, min_record: Option, @@ -280,43 +302,70 @@ impl UltraLogApp { let (min_str, max_str, min_record, max_record, min_time, max_time) = if selected.file_index < self.files.len() { let file = &self.files[selected.file_index]; - let data = file.log.get_channel_data(selected.channel_index); let times = file.log.get_times_as_f64(); + // Get data from either regular channel or computed channel + let data: Vec = if selected.channel.is_computed() { + // For computed channels, get data from file_computed_channels + let regular_count = file.log.channels.len(); + if selected.channel_index >= regular_count { + let computed_idx = selected.channel_index - regular_count; + self.file_computed_channels + .get(&selected.file_index) + .and_then(|channels| channels.get(computed_idx)) + .and_then(|c| c.cached_data.clone()) + .unwrap_or_default() + } else { + Vec::new() + } + } else { + // Regular channel data + file.log.get_channel_data(selected.channel_index) + }; + if !data.is_empty() { - // Find min and max with their indices - let (min_idx, min_val) = data - .iter() - .enumerate() - .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap()) - .map(|(i, v)| (i, *v)) - .unwrap(); - let (max_idx, max_val) = data + // Find min and max with their indices (filter out NaN values) + let valid_data: Vec<(usize, f64)> = data .iter() .enumerate() - .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap()) + .filter(|(_, v)| v.is_finite()) .map(|(i, v)| (i, *v)) - .unwrap(); - - let source_unit = selected.channel.unit(); - let (conv_min, display_unit) = - self.unit_preferences.convert_value(min_val, source_unit); - let (conv_max, _) = - self.unit_preferences.convert_value(max_val, source_unit); - let unit_str = if display_unit.is_empty() { - String::new() + .collect(); + + if valid_data.is_empty() { + (None, None, None, None, None, None) } else { - format!(" {}", display_unit) - }; + let (min_idx, min_val) = valid_data + .iter() + .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap()) + .map(|(i, v)| (*i, *v)) + .unwrap(); + let (max_idx, max_val) = valid_data + .iter() + .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap()) + .map(|(i, v)| (*i, *v)) + .unwrap(); + + let source_unit = selected.channel.unit(); + let (conv_min, display_unit) = + self.unit_preferences.convert_value(min_val, source_unit); + let (conv_max, _) = + self.unit_preferences.convert_value(max_val, source_unit); + let unit_str = if display_unit.is_empty() { + String::new() + } else { + format!(" {}", display_unit) + }; - ( - Some(format!("{:.1}{}", conv_min, unit_str)), - Some(format!("{:.1}{}", conv_max, unit_str)), - Some(min_idx), - Some(max_idx), - times.get(min_idx).copied(), - times.get(max_idx).copied(), - ) + ( + Some(format!("{:.1}{}", conv_min, unit_str)), + Some(format!("{:.1}{}", conv_max, unit_str)), + Some(min_idx), + Some(max_idx), + times.get(min_idx).copied(), + times.get(max_idx).copied(), + ) + } } else { (None, None, None, None, None, None) } @@ -327,6 +376,7 @@ impl UltraLogApp { channel_cards.push(ChannelCardData { color: color32, display_name, + is_computed: selected.channel.is_computed(), min_str, max_str, min_record, @@ -348,14 +398,105 @@ impl UltraLogApp { .corner_radius(5) .inner_margin(10.0) .show(ui, |ui| { - ui.vertical(|ui| { - ui.horizontal(|ui| { - ui.label( - egui::RichText::new(&card.display_name) - .strong() - .color(card.color), + // Use horizontal layout with content on left, close button on right + // Align to top so cards with different heights don't stair-step + ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| { + // Main content column + ui.vertical(|ui| { + ui.horizontal(|ui| { + // Show computed channel indicator + if card.is_computed { + ui.label( + egui::RichText::new("ƒ") + .color(egui::Color32::from_rgb(150, 200, 255)) + .strong(), + ) + .on_hover_text("Computed channel (formula-based)"); + } + ui.label( + egui::RichText::new(&card.display_name) + .strong() + .color(card.color) + .size(font_14), + ); + }); + + // Show min with jump button + if let Some(min_str) = &card.min_str { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("Min:") + .color(egui::Color32::GRAY) + .size(font_12), + ); + ui.label( + egui::RichText::new(min_str) + .color(egui::Color32::LIGHT_GRAY) + .size(font_14), + ); + if let (Some(record), Some(time)) = + (card.min_record, card.min_time) + { + let btn = ui + .small_button("⏵") + .on_hover_text("Jump to minimum"); + if btn.clicked() { + jump_to = Some((record, time)); + } + if btn.hovered() { + ui.ctx().set_cursor_icon( + egui::CursorIcon::PointingHand, + ); + } + } + }); + } + + // Show max with jump button + if let Some(max_str) = &card.max_str { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("Max:") + .color(egui::Color32::GRAY) + .size(font_12), + ); + ui.label( + egui::RichText::new(max_str) + .color(egui::Color32::LIGHT_GRAY) + .size(font_14), + ); + if let (Some(record), Some(time)) = + (card.max_record, card.max_time) + { + let btn = ui + .small_button("⏵") + .on_hover_text("Jump to maximum"); + if btn.clicked() { + jump_to = Some((record, time)); + } + if btn.hovered() { + ui.ctx().set_cursor_icon( + egui::CursorIcon::PointingHand, + ); + } + } + }); + } + }); + + // Close button in top right + ui.add_space(8.0); + ui.vertical(|ui| { + let close_btn = ui.add( + egui::Button::new( + egui::RichText::new("x") + .size(font_12) + .color(egui::Color32::from_rgb(150, 150, 150)), + ) + .fill(egui::Color32::TRANSPARENT) + .stroke(egui::Stroke::NONE) + .corner_radius(2.0), ); - let close_btn = ui.small_button("x"); if close_btn.clicked() { channel_to_remove = Some(i); } @@ -363,66 +504,6 @@ impl UltraLogApp { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); } }); - - // Show min with jump button - if let Some(min_str) = &card.min_str { - ui.horizontal(|ui| { - ui.label( - egui::RichText::new("Min:") - .color(egui::Color32::GRAY) - .small(), - ); - ui.label( - egui::RichText::new(min_str) - .color(egui::Color32::LIGHT_GRAY), - ); - if let (Some(record), Some(time)) = - (card.min_record, card.min_time) - { - let btn = ui - .small_button("⏵") - .on_hover_text("Jump to minimum"); - if btn.clicked() { - jump_to = Some((record, time)); - } - if btn.hovered() { - ui.ctx().set_cursor_icon( - egui::CursorIcon::PointingHand, - ); - } - } - }); - } - - // Show max with jump button - if let Some(max_str) = &card.max_str { - ui.horizontal(|ui| { - ui.label( - egui::RichText::new("Max:") - .color(egui::Color32::GRAY) - .small(), - ); - ui.label( - egui::RichText::new(max_str) - .color(egui::Color32::LIGHT_GRAY), - ); - if let (Some(record), Some(time)) = - (card.max_record, card.max_time) - { - let btn = ui - .small_button("⏵") - .on_hover_text("Jump to maximum"); - if btn.clicked() { - jump_to = Some((record, time)); - } - if btn.hovered() { - ui.ctx().set_cursor_icon( - egui::CursorIcon::PointingHand, - ); - } - } - }); - } }); }); @@ -450,7 +531,8 @@ impl UltraLogApp { ui.label( egui::RichText::new("Click channels to add them to the chart") .italics() - .color(egui::Color32::GRAY), + .color(egui::Color32::GRAY) + .size(font_16), ); } } diff --git a/src/ui/channels_panel.rs b/src/ui/channels_panel.rs new file mode 100644 index 0000000..18b43b8 --- /dev/null +++ b/src/ui/channels_panel.rs @@ -0,0 +1,295 @@ +//! Channels panel - channel selection and selected channel cards. +//! +//! This panel provides channel selection functionality that works across all tool modes. + +use eframe::egui; + +use crate::app::UltraLogApp; +use crate::normalize::sort_channels_by_priority; +use crate::state::MAX_CHANNELS; + +impl UltraLogApp { + /// Render the channels panel content (called from side_panel.rs) + pub fn render_channels_panel_content(&mut self, ui: &mut egui::Ui) { + // Pre-compute scaled font sizes + let font_12 = self.scaled_font(12.0); + let font_14 = self.scaled_font(14.0); + let _font_16 = self.scaled_font(16.0); + + // Get active tab info + let tab_info = self.active_tab.and_then(|tab_idx| { + let tab = &self.tabs[tab_idx]; + if tab.file_index < self.files.len() { + Some(( + tab.file_index, + tab.channel_search.clone(), + tab.selected_channels.len(), + )) + } else { + None + } + }); + + if let Some((file_index, current_search, selected_count)) = tab_info { + let channel_count = self.files[file_index].log.channels.len(); + + // Computed Channels button + let primary_color = egui::Color32::from_rgb(113, 120, 78); + let computed_btn = egui::Frame::NONE + .fill(primary_color) + .corner_radius(4) + .inner_margin(egui::vec2(10.0, 6.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("ƒ") + .color(egui::Color32::WHITE) + .size(font_14), + ); + ui.label( + egui::RichText::new("Computed Channels") + .color(egui::Color32::WHITE) + .size(font_14), + ); + }); + }); + + if computed_btn + .response + .interact(egui::Sense::click()) + .on_hover_text("Create virtual channels from mathematical formulas") + .clicked() + { + self.show_computed_channels_manager = true; + } + + if computed_btn.response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + + ui.add_space(8.0); + + // Search box + let mut search_text = current_search; + let mut search_changed = false; + egui::Frame::NONE + .fill(egui::Color32::from_rgb(50, 50, 50)) + .corner_radius(4) + .inner_margin(egui::vec2(8.0, 6.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("🔍") + .size(font_14) + .color(egui::Color32::GRAY), + ); + let response = ui.add( + egui::TextEdit::singleline(&mut search_text) + .hint_text("Search channels...") + .desired_width(f32::INFINITY) + .frame(false), + ); + search_changed = response.changed(); + }); + }); + + if search_changed { + self.set_channel_search(search_text.clone()); + } + + ui.add_space(4.0); + + // Channel count + ui.label( + egui::RichText::new(format!( + "Selected: {} / {} | Total: {}", + selected_count, MAX_CHANNELS, channel_count + )) + .size(font_12) + .color(egui::Color32::GRAY), + ); + + ui.add_space(4.0); + ui.separator(); + ui.add_space(4.0); + + // Channel list + self.render_channel_list_compact(ui, file_index, &search_text); + } else { + // No file selected + ui.add_space(40.0); + ui.vertical_centered(|ui| { + ui.label( + egui::RichText::new("📊") + .size(32.0) + .color(egui::Color32::from_rgb(100, 100, 100)), + ); + ui.add_space(8.0); + ui.label( + egui::RichText::new("No file selected") + .size(font_14) + .color(egui::Color32::GRAY), + ); + ui.add_space(4.0); + ui.label( + egui::RichText::new("Load a file to view channels") + .size(font_12) + .color(egui::Color32::from_rgb(100, 100, 100)), + ); + }); + } + } + + /// Render the channel list in compact form + fn render_channel_list_compact(&mut self, ui: &mut egui::Ui, file_index: usize, search: &str) { + let font_14 = self.scaled_font(14.0); + let search_lower = search.to_lowercase(); + + let mut channel_to_add: Option<(usize, usize)> = None; + let mut channel_to_remove: Option = None; + + let file = &self.files[file_index]; + let sorted_channels = sort_channels_by_priority( + file.log.channels.len(), + |idx| file.log.channels[idx].name(), + self.field_normalization, + Some(&self.custom_normalizations), + ); + + let channel_names: Vec = (0..file.log.channels.len()) + .map(|idx| file.log.channels[idx].name()) + .collect(); + + let channels_with_data = &file.channels_with_data; + let (channels_with, channels_without): (Vec<_>, Vec<_>) = sorted_channels + .into_iter() + .partition(|(idx, _, _)| channels_with_data[*idx]); + + let selected_channels = self.get_selected_channels().to_vec(); + + let render_channel = |ui: &mut egui::Ui, + channel_index: usize, + display_name: &str, + is_empty: bool, + channel_to_add: &mut Option<(usize, usize)>, + channel_to_remove: &mut Option| { + let original_name = &channel_names[channel_index]; + + if !search_lower.is_empty() + && !original_name.to_lowercase().contains(&search_lower) + && !display_name.to_lowercase().contains(&search_lower) + { + return; + } + + let selected_idx = selected_channels + .iter() + .position(|c| c.file_index == file_index && c.channel_index == channel_index); + let is_selected = selected_idx.is_some(); + + let text_color = if is_empty { + egui::Color32::from_rgb(100, 100, 100) + } else if is_selected { + egui::Color32::WHITE + } else { + egui::Color32::LIGHT_GRAY + }; + + let bg_color = if is_selected { + egui::Color32::from_rgb(55, 60, 50) + } else { + egui::Color32::TRANSPARENT + }; + + let frame = egui::Frame::NONE + .fill(bg_color) + .corner_radius(3) + .inner_margin(egui::Margin::symmetric(6, 3)); + + let response = frame + .show(ui, |ui| { + ui.horizontal(|ui| { + let check = if is_selected { "☑" } else { "☐" }; + ui.label(egui::RichText::new(check).size(font_14).color(text_color)); + ui.label( + egui::RichText::new(display_name) + .size(font_14) + .color(text_color), + ); + }); + }) + .response + .interact(egui::Sense::click()); + + if response.clicked() { + if let Some(idx) = selected_idx { + *channel_to_remove = Some(idx); + } else { + *channel_to_add = Some((file_index, channel_index)); + } + } + + if response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + }; + + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + // Channels with data + if !channels_with.is_empty() { + egui::CollapsingHeader::new( + egui::RichText::new(format!("📊 With Data ({})", channels_with.len())) + .size(font_14) + .strong(), + ) + .default_open(true) + .show(ui, |ui| { + for (channel_index, display_name, _) in &channels_with { + render_channel( + ui, + *channel_index, + display_name, + false, + &mut channel_to_add, + &mut channel_to_remove, + ); + } + }); + } + + ui.add_space(4.0); + + // Empty channels + if !channels_without.is_empty() { + egui::CollapsingHeader::new( + egui::RichText::new(format!("📭 Empty ({})", channels_without.len())) + .size(font_14) + .color(egui::Color32::GRAY), + ) + .default_open(false) + .show(ui, |ui| { + for (channel_index, display_name, _) in &channels_without { + render_channel( + ui, + *channel_index, + display_name, + true, + &mut channel_to_add, + &mut channel_to_remove, + ); + } + }); + } + }); + + if let Some(idx) = channel_to_remove { + self.remove_channel(idx); + } + + if let Some((file_idx, channel_idx)) = channel_to_add { + self.add_channel(file_idx, channel_idx); + } + } +} diff --git a/src/ui/chart.rs b/src/ui/chart.rs index 10c3527..7d98d20 100644 --- a/src/ui/chart.rs +++ b/src/ui/chart.rs @@ -17,7 +17,7 @@ impl UltraLogApp { ui.centered_and_justified(|ui| { ui.label( egui::RichText::new("Select channels to display chart") - .size(20.0) + .size(self.scaled_font(20.0)) .color(egui::Color32::GRAY), ); }); diff --git a/src/ui/computed_channels_manager.rs b/src/ui/computed_channels_manager.rs index 9092d35..585501c 100644 --- a/src/ui/computed_channels_manager.rs +++ b/src/ui/computed_channels_manager.rs @@ -1,13 +1,16 @@ //! Computed Channels Manager UI. //! -//! Provides a window for users to manage their computed channel library +//! Provides a simplified window for users to manage their computed channel library //! and apply computed channels to the active log file. use eframe::egui; use crate::app::UltraLogApp; use crate::computed::{ComputedChannel, ComputedChannelTemplate}; -use crate::expression::{build_channel_bindings, evaluate_all_records, extract_channel_references}; +use crate::expression::{ + build_channel_bindings, compute_all_channel_statistics, evaluate_all_records, + evaluate_all_records_with_stats, extract_channel_references, +}; use crate::parsers::types::ComputedChannelInfo; use crate::parsers::Channel; use crate::state::{SelectedChannel, CHART_COLORS}; @@ -20,19 +23,31 @@ impl UltraLogApp { } let mut open = true; + let font_12 = self.scaled_font(12.0); + let font_14 = self.scaled_font(14.0); egui::Window::new("Computed Channels") .open(&mut open) .resizable(true) - .default_width(650.0) - .default_height(550.0) + .default_width(500.0) + .default_height(450.0) .order(egui::Order::Foreground) .show(ctx, |ui| { - // Header with Add button + // Header with help button ui.horizontal(|ui| { - ui.heading("Channel Library"); + ui.heading("Computed Channels"); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button("+ New Channel").clicked() { + // Help button + let help_btn = ui.add( + egui::Button::new(egui::RichText::new("?").size(font_14)) + .min_size(egui::vec2(24.0, 24.0)), + ); + if help_btn.clicked() { + self.show_computed_channels_help = !self.show_computed_channels_help; + } + help_btn.on_hover_text("Show formula syntax help"); + + if ui.button("+ New").clicked() { self.formula_editor_state.open_new(); } }); @@ -40,14 +55,78 @@ impl UltraLogApp { ui.add_space(4.0); ui.label( - egui::RichText::new( - "Create reusable computed channels from mathematical formulas.", - ) - .color(egui::Color32::GRAY), + egui::RichText::new("Create virtual channels from mathematical formulas.") + .size(font_12) + .color(egui::Color32::GRAY), ); + ui.add_space(8.0); + // Quick Create section + ui.label(egui::RichText::new("Quick Create:").size(font_12).strong()); + ui.add_space(4.0); + ui.horizontal(|ui| { + if ui + .button("Rate of Change") + .on_hover_text("Create: Channel - Channel[-1]") + .clicked() + { + self.formula_editor_state.open_with_pattern( + "Rate of Change", + "{channel} - {channel}[-1]", + "/sample", + "Rate of change per sample", + ); + } + if ui + .button("Moving Avg") + .on_hover_text("Create: 3-sample moving average") + .clicked() + { + self.formula_editor_state.open_with_pattern( + "Moving Average", + "({channel} + {channel}[-1] + {channel}[-2]) / 3", + "", + "3-sample moving average for smoothing", + ); + } + if ui + .button("% Deviation") + .on_hover_text("Create: Percentage deviation from a target") + .clicked() + { + self.formula_editor_state.open_with_pattern( + "Deviation", + "({channel} - 14.7) / 14.7 * 100", + "%", + "Percentage deviation from target value", + ); + } + }); + + ui.add_space(12.0); ui.separator(); + ui.add_space(8.0); + + // Search filter + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(format!( + "Your Library ({})", + self.computed_library.templates.len() + )) + .size(font_14) + .strong(), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add( + egui::TextEdit::singleline(&mut self.computed_channels_search) + .hint_text("🔍 Search...") + .desired_width(120.0), + ); + }); + }); + ui.add_space(4.0); // Library templates section @@ -58,100 +137,50 @@ impl UltraLogApp { egui::RichText::new("No computed channels yet") .color(egui::Color32::GRAY), ); - ui.add_space(8.0); + ui.add_space(4.0); ui.label( - egui::RichText::new( - "Click '+ New Channel' to create your first computed channel.", - ) - .color(egui::Color32::GRAY) - .small(), + egui::RichText::new("Use Quick Create or click '+ New' to get started") + .color(egui::Color32::GRAY) + .size(font_12), ); ui.add_space(20.0); }); } else { - ui.label( - egui::RichText::new(format!( - "Templates ({})", - self.computed_library.templates.len() - )) - .strong(), - ); - ui.add_space(4.0); - - // Collect actions to perform after rendering (avoid borrow issues) + // Collect actions to perform after rendering let mut template_to_edit: Option = None; let mut template_to_delete: Option = None; let mut template_to_apply: Option = None; + let mut template_to_duplicate: Option = None; + + let search_lower = self.computed_channels_search.to_lowercase(); egui::ScrollArea::vertical() .id_salt("library_templates_scroll") - .max_height(250.0) .show(ui, |ui| { for template in &self.computed_library.templates { - egui::Frame::NONE - .fill(egui::Color32::from_rgb(50, 50, 50)) - .corner_radius(5.0) - .inner_margin(egui::Margin::symmetric(10, 8)) - .show(ui, |ui| { - ui.horizontal(|ui| { - // Template info - ui.vertical(|ui| { - ui.horizontal(|ui| { - ui.label( - egui::RichText::new(&template.name) - .strong() - .color(egui::Color32::LIGHT_BLUE), - ); - if !template.unit.is_empty() { - ui.label( - egui::RichText::new(format!( - "({})", - template.unit - )) - .small() - .color(egui::Color32::GRAY), - ); - } - }); - ui.label( - egui::RichText::new(&template.formula) - .monospace() - .small() - .color(egui::Color32::from_rgb(180, 180, 180)), - ); - if !template.description.is_empty() { - ui.label( - egui::RichText::new(&template.description) - .small() - .color(egui::Color32::GRAY), - ); - } - }); - - // Buttons on the right - ui.with_layout( - egui::Layout::right_to_left(egui::Align::Center), - |ui| { - if ui.small_button("Delete").clicked() { - template_to_delete = Some(template.id.clone()); - } - if ui.small_button("Edit").clicked() { - template_to_edit = Some(template.id.clone()); - } - if self.active_tab.is_some() - && ui - .button( - egui::RichText::new("Apply") - .color(egui::Color32::WHITE), - ) - .clicked() - { - template_to_apply = Some(template.clone()); - } - }, - ); - }); - }); + // Filter by search + if !search_lower.is_empty() { + let matches = template + .name + .to_lowercase() + .contains(&search_lower) + || template.formula.to_lowercase().contains(&search_lower) + || template.category.to_lowercase().contains(&search_lower); + if !matches { + continue; + } + } + + self.render_template_card( + ui, + template, + font_12, + font_14, + &mut template_to_edit, + &mut template_to_delete, + &mut template_to_apply, + &mut template_to_duplicate, + ); ui.add_space(4.0); } }); @@ -168,266 +197,339 @@ impl UltraLogApp { let _ = self.computed_library.save(); } - if let Some(template) = template_to_apply { + if let Some(ref template) = template_to_apply { self.apply_computed_channel_template(template); } + + if let Some(ref template) = template_to_duplicate { + let mut new_template = template.clone(); + new_template.id = uuid::Uuid::new_v4().to_string(); + new_template.name = format!("{} (copy)", template.name); + new_template.is_builtin = false; + self.computed_library.add_template(new_template); + let _ = self.computed_library.save(); + self.show_toast_success("Template duplicated"); + } } - ui.add_space(12.0); + ui.add_space(8.0); ui.separator(); - ui.add_space(4.0); + ui.add_space(8.0); // Applied channels section (for current file) - if let Some(tab_idx) = self.active_tab { - let file_idx = self.tabs[tab_idx].file_index; - let applied_count = self - .file_computed_channels - .get(&file_idx) - .map(|c| c.len()) - .unwrap_or(0); - - ui.horizontal(|ui| { + self.render_applied_channels_section(ui, font_12, font_14); + }); + + // Render help popup if open + if self.show_computed_channels_help { + self.render_computed_channels_help(ctx); + } + + if !open { + self.show_computed_channels_manager = false; + } + } + + /// Render a single template card with cleaner layout + #[allow(clippy::too_many_arguments)] + fn render_template_card( + &self, + ui: &mut egui::Ui, + template: &ComputedChannelTemplate, + font_12: f32, + font_14: f32, + template_to_edit: &mut Option, + template_to_delete: &mut Option, + template_to_apply: &mut Option, + template_to_duplicate: &mut Option, + ) { + egui::Frame::NONE + .fill(egui::Color32::from_rgb(45, 48, 45)) + .corner_radius(6.0) + .inner_margin(egui::Margin::symmetric(12, 8)) + .show(ui, |ui| { + ui.horizontal(|ui| { + // Template info + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("ƒ") + .color(egui::Color32::from_rgb(150, 200, 255)) + .size(font_14), + ); + ui.label( + egui::RichText::new(&template.name) + .strong() + .size(font_14) + .color(egui::Color32::WHITE), + ); + if !template.unit.is_empty() { + ui.label( + egui::RichText::new(format!("({})", template.unit)) + .size(font_12) + .color(egui::Color32::GRAY), + ); + } + if template.is_builtin { + ui.label( + egui::RichText::new("★") + .size(font_12) + .color(egui::Color32::GOLD), + ) + .on_hover_text("Built-in template"); + } + }); ui.label( - egui::RichText::new(format!("Applied to Current File ({})", applied_count)) - .strong(), + egui::RichText::new(&template.formula) + .monospace() + .size(font_12) + .color(egui::Color32::from_rgb(160, 180, 160)), ); }); - ui.add_space(4.0); - if applied_count == 0 { - ui.label( - egui::RichText::new( - "No computed channels applied to this file. Click 'Apply' on a template above.", + // Buttons on the right + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // Overflow menu for Edit/Delete/Duplicate + ui.menu_button("•••", |ui| { + if ui.button("Edit").clicked() { + *template_to_edit = Some(template.id.clone()); + ui.close(); + } + if ui.button("Duplicate").clicked() { + *template_to_duplicate = Some(template.clone()); + ui.close(); + } + ui.separator(); + if ui + .button( + egui::RichText::new("Delete") + .color(egui::Color32::from_rgb(255, 120, 120)), + ) + .clicked() + { + *template_to_delete = Some(template.id.clone()); + ui.close(); + } + }); + + // Apply button (primary action) + if self.active_tab.is_some() { + let apply_btn = egui::Button::new( + egui::RichText::new("Apply").color(egui::Color32::WHITE), ) - .color(egui::Color32::GRAY) - .small(), - ); - } else { - let mut channel_to_remove: Option = None; - let mut channel_to_select: Option = None; - - if let Some(channels) = self.file_computed_channels.get(&file_idx) { - egui::ScrollArea::vertical() - .id_salt("applied_channels_scroll") - .max_height(150.0) - .show(ui, |ui| { - for (idx, channel) in channels.iter().enumerate() { - egui::Frame::NONE - .fill(egui::Color32::from_rgb(40, 50, 40)) - .corner_radius(5.0) - .inner_margin(egui::Margin::symmetric(10, 6)) - .show(ui, |ui| { - ui.horizontal(|ui| { - // Status indicator - if channel.is_valid() { - ui.label( - egui::RichText::new("●") - .color(egui::Color32::GREEN), - ); - } else { - ui.label( - egui::RichText::new("●") - .color(egui::Color32::RED), - ); - } - - ui.label( - egui::RichText::new(channel.name()) - .color(egui::Color32::LIGHT_GREEN), - ); - - if let Some(error) = &channel.error { - ui.label( - egui::RichText::new(format!( - "Error: {}", - error - )) - .small() - .color(egui::Color32::RED), - ); - } - - ui.with_layout( - egui::Layout::right_to_left( - egui::Align::Center, - ), - |ui| { - if ui.small_button("Remove").clicked() { - channel_to_remove = Some(idx); - } - if channel.is_valid() - && ui.small_button("Add to Chart").clicked() - { - channel_to_select = Some(idx); - } - }, - ); - }); - }); - ui.add_space(2.0); - } - }); - } + .fill(egui::Color32::from_rgb(80, 110, 80)); - if let Some(idx) = channel_to_remove { - self.remove_computed_channel(idx); + if ui.add(apply_btn).clicked() { + *template_to_apply = Some(template.clone()); + } } + }); + }); + }); + } - if let Some(idx) = channel_to_select { - self.add_computed_channel_to_chart(idx); - } + /// Render the applied channels section + fn render_applied_channels_section(&mut self, ui: &mut egui::Ui, font_12: f32, font_14: f32) { + if let Some(tab_idx) = self.active_tab { + let file_idx = self.tabs[tab_idx].file_index; + let applied_count = self + .file_computed_channels + .get(&file_idx) + .map(|c| c.len()) + .unwrap_or(0); + + ui.label( + egui::RichText::new(format!("Applied to Current File ({})", applied_count)) + .size(font_14) + .strong(), + ); + ui.add_space(4.0); + + if applied_count == 0 { + ui.label( + egui::RichText::new("Apply templates from the library above") + .color(egui::Color32::GRAY) + .size(font_12), + ); + } else { + let mut channel_to_remove: Option = None; + let mut channel_to_select: Option = None; + + if let Some(channels) = self.file_computed_channels.get(&file_idx) { + for (idx, channel) in channels.iter().enumerate() { + egui::Frame::NONE + .fill(egui::Color32::from_rgb(40, 45, 50)) + .corner_radius(4.0) + .inner_margin(egui::Margin::symmetric(10, 6)) + .show(ui, |ui| { + ui.horizontal(|ui| { + // Status indicator + if channel.is_valid() { + ui.label( + egui::RichText::new("●") + .color(egui::Color32::GREEN) + .size(font_12), + ); + } else { + ui.label( + egui::RichText::new("●") + .color(egui::Color32::RED) + .size(font_12), + ); + } + + ui.label( + egui::RichText::new(channel.name()) + .size(font_14) + .color(egui::Color32::LIGHT_GREEN), + ); + + if let Some(error) = &channel.error { + ui.label( + egui::RichText::new(error) + .size(font_12) + .color(egui::Color32::RED), + ); + } + + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + if ui + .small_button("x") + .on_hover_text("Remove") + .clicked() + { + channel_to_remove = Some(idx); + } + if channel.is_valid() + && ui + .small_button("+ Chart") + .on_hover_text("Add to chart") + .clicked() + { + channel_to_select = Some(idx); + } + }, + ); + }); + }); + ui.add_space(2.0); } - } else { - ui.label( - egui::RichText::new("Load a log file to apply computed channels.") - .color(egui::Color32::GRAY), - ); } - // Examples section - ui.add_space(12.0); - ui.separator(); + if let Some(idx) = channel_to_remove { + self.remove_computed_channel(idx); + } + + if let Some(idx) = channel_to_select { + self.add_computed_channel_to_chart(idx); + } + } + } else { + ui.label( + egui::RichText::new("Load a log file to apply computed channels") + .color(egui::Color32::GRAY) + .size(font_12), + ); + } + } - egui::CollapsingHeader::new("Example Formulas") - .default_open(true) + /// Render the help popup with examples and syntax reference + fn render_computed_channels_help(&mut self, ctx: &egui::Context) { + let mut open = true; + + egui::Window::new("Formula Help") + .open(&mut open) + .resizable(false) + .default_width(400.0) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .order(egui::Order::Foreground) + .show(ctx, |ui| { + egui::ScrollArea::vertical() + .max_height(400.0) .show(ui, |ui| { - ui.label( - egui::RichText::new("Common computed channel examples:") - .color(egui::Color32::GRAY), - ); + // Channel References + ui.label(egui::RichText::new("Channel References").strong()); ui.add_space(4.0); + Self::help_row(ui, "RPM", "Current value of RPM channel"); + Self::help_row( + ui, + "\"Manifold Pressure\"", + "Channels with spaces (use quotes)", + ); + Self::help_row(ui, "RPM[-1]", "Previous sample (index offset)"); + Self::help_row(ui, "RPM[+2]", "2 samples ahead"); + Self::help_row(ui, "RPM@-0.1s", "Value 100ms ago (time offset)"); - // Rate of Change examples - ui.label(egui::RichText::new("Rate of Change:").strong()); - ui.horizontal(|ui| { - ui.label( - egui::RichText::new(" RPM - RPM[-1]") - .monospace() - .color(egui::Color32::LIGHT_GREEN), - ); - ui.label( - egui::RichText::new("— RPM change per sample") - .small() - .color(egui::Color32::GRAY), - ); - }); - ui.horizontal(|ui| { - ui.label( - egui::RichText::new(" TPS - TPS@-0.1s") - .monospace() - .color(egui::Color32::LIGHT_GREEN), - ); - ui.label( - egui::RichText::new("— TPS change over 100ms") - .small() - .color(egui::Color32::GRAY), - ); - }); - - ui.add_space(6.0); - ui.label(egui::RichText::new("Engine Calculations:").strong()); - ui.horizontal(|ui| { - ui.label( - egui::RichText::new(" (AFR - 14.7) / 14.7 * 100") - .monospace() - .color(egui::Color32::LIGHT_GREEN), - ); - ui.label( - egui::RichText::new("— AFR % deviation from stoich") - .small() - .color(egui::Color32::GRAY), - ); - }); - ui.horizontal(|ui| { - ui.label( - egui::RichText::new(" MAP / 101.325 * 100") - .monospace() - .color(egui::Color32::LIGHT_GREEN), - ); - ui.label( - egui::RichText::new("— MAP as % of atmosphere") - .small() - .color(egui::Color32::GRAY), - ); - }); + ui.add_space(12.0); + ui.label(egui::RichText::new("Operators").strong()); + ui.add_space(4.0); + Self::help_row(ui, "+ - * /", "Basic math"); + Self::help_row(ui, "^", "Power (e.g., RPM^2)"); + Self::help_row(ui, "( )", "Grouping"); - ui.add_space(6.0); - ui.label(egui::RichText::new("Averaging / Smoothing:").strong()); - ui.horizontal(|ui| { - ui.label( - egui::RichText::new(" (RPM + RPM[-1] + RPM[-2]) / 3") - .monospace() - .color(egui::Color32::LIGHT_GREEN), - ); - ui.label( - egui::RichText::new("— 3-sample moving average") - .small() - .color(egui::Color32::GRAY), - ); - }); + ui.add_space(12.0); + ui.label(egui::RichText::new("Functions").strong()); + ui.add_space(4.0); + Self::help_row(ui, "sin, cos, tan", "Trigonometry"); + Self::help_row(ui, "sqrt, abs", "Square root, absolute value"); + Self::help_row(ui, "ln, log, exp", "Logarithms, exponential"); + Self::help_row(ui, "min, max", "Minimum, maximum"); + Self::help_row(ui, "floor, ceil", "Rounding"); - ui.add_space(6.0); - ui.label(egui::RichText::new("Unit Conversions:").strong()); - ui.horizontal(|ui| { - ui.label( - egui::RichText::new(" MAP * 0.145038") - .monospace() - .color(egui::Color32::LIGHT_GREEN), - ); - ui.label( - egui::RichText::new("— kPa to PSI") - .small() - .color(egui::Color32::GRAY), - ); - }); - ui.horizontal(|ui| { - ui.label( - egui::RichText::new(" (ECT - 32) * 5 / 9") - .monospace() - .color(egui::Color32::LIGHT_GREEN), - ); - ui.label( - egui::RichText::new("— Fahrenheit to Celsius") - .small() - .color(egui::Color32::GRAY), - ); - }); - }); + ui.add_space(12.0); + ui.label( + egui::RichText::new("Statistics (for anomaly detection)").strong(), + ); + ui.add_space(4.0); + Self::help_row(ui, "_mean_RPM", "Mean of entire RPM channel"); + Self::help_row(ui, "_stdev_RPM", "Standard deviation"); + Self::help_row(ui, "_min_RPM / _max_RPM", "Min/max values"); - // Syntax help section - egui::CollapsingHeader::new("Formula Syntax Reference") - .default_open(false) - .show(ui, |ui| { - ui.label(egui::RichText::new("Channel References:").strong()); - ui.label(" RPM - Current value of RPM channel"); - ui.label(" \"Manifold Pressure\" - Channels with spaces (use quotes)"); - ui.label(" RPM[-1] - Previous sample (index offset)"); - ui.label(" RPM[+2] - 2 samples ahead"); - ui.label(" RPM@-0.1s - Value 100ms ago (time offset)"); - - ui.add_space(8.0); - ui.label(egui::RichText::new("Operators:").strong()); - ui.label(" + - * / - Basic math"); - ui.label(" ^ - Power"); - ui.label(" ( ) - Grouping"); - - ui.add_space(8.0); - ui.label(egui::RichText::new("Functions:").strong()); - ui.label(" sin, cos, tan - Trigonometry"); - ui.label(" sqrt, abs - Square root, absolute value"); - ui.label(" ln, log, exp - Logarithms, exponential"); - ui.label(" min, max - Minimum, maximum"); - ui.label(" floor, ceil - Rounding"); + ui.add_space(12.0); + ui.label(egui::RichText::new("Examples").strong()); + ui.add_space(4.0); + Self::example_row(ui, "RPM - RPM[-1]", "RPM change per sample"); + Self::example_row(ui, "(AFR - 14.7) / 14.7 * 100", "AFR % deviation"); + Self::example_row(ui, "(RPM - _mean_RPM) / _stdev_RPM", "Z-score"); }); }); if !open { - self.show_computed_channels_manager = false; + self.show_computed_channels_help = false; } } + fn help_row(ui: &mut egui::Ui, code: &str, description: &str) { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(code) + .monospace() + .color(egui::Color32::LIGHT_GREEN), + ); + ui.label(egui::RichText::new("—").color(egui::Color32::GRAY)); + ui.label(egui::RichText::new(description).color(egui::Color32::GRAY)); + }); + } + + fn example_row(ui: &mut egui::Ui, formula: &str, description: &str) { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(formula) + .monospace() + .color(egui::Color32::from_rgb(180, 200, 255)), + ); + }); + ui.label( + egui::RichText::new(format!(" {}", description)) + .small() + .color(egui::Color32::GRAY), + ); + } + /// Apply a computed channel template to the current file - fn apply_computed_channel_template(&mut self, template: ComputedChannelTemplate) { + pub fn apply_computed_channel_template(&mut self, template: &ComputedChannelTemplate) { let Some(tab_idx) = self.active_tab else { self.show_toast_warning("No active tab"); return; @@ -449,17 +551,43 @@ impl UltraLogApp { } }; - // Evaluate the formula - let cached_data = match evaluate_all_records( - &template.formula, - &bindings, - &file.log.data, - &file.log.times, - ) { - Ok(data) => Some(data), - Err(e) => { - self.show_toast_error(&format!("Evaluation failed: {}", e)); - return; + // Check if formula uses statistical variables (for z-score anomaly detection) + let needs_statistics = template.formula.contains("_mean_") + || template.formula.contains("_stdev_") + || template.formula.contains("_min_") + || template.formula.contains("_max_") + || template.formula.contains("_range_"); + + // Evaluate the formula (with or without statistics) + let cached_data = if needs_statistics { + // Compute statistics for all channels + let statistics = compute_all_channel_statistics(&available_channels, &file.log.data); + + match evaluate_all_records_with_stats( + &template.formula, + &bindings, + &file.log.data, + &file.log.times, + Some(&statistics), + ) { + Ok(data) => Some(data), + Err(e) => { + self.show_toast_error(&format!("Evaluation failed: {}", e)); + return; + } + } + } else { + match evaluate_all_records( + &template.formula, + &bindings, + &file.log.data, + &file.log.times, + ) { + Ok(data) => Some(data), + Err(e) => { + self.show_toast_error(&format!("Evaluation failed: {}", e)); + return; + } } }; diff --git a/src/ui/export.rs b/src/ui/export.rs index a25df9c..1eaa21d 100644 --- a/src/ui/export.rs +++ b/src/ui/export.rs @@ -1,5 +1,6 @@ //! Chart export functionality (PNG, PDF). +use printpdf::path::{PaintMode, WindingOrder}; use printpdf::*; use std::fs::File; use std::io::BufWriter; @@ -10,6 +11,7 @@ use ::image::{Rgba, RgbaImage}; use crate::analytics; use crate::app::UltraLogApp; use crate::normalize::normalize_channel_name_with_custom; +use crate::state::HistogramMode; impl UltraLogApp { /// Export the current chart view as PNG @@ -352,6 +354,1194 @@ impl UltraLogApp { Ok(()) } + + /// Export the current histogram view as PNG + pub fn export_histogram_png(&mut self) { + // Show save dialog + let Some(path) = rfd::FileDialog::new() + .add_filter("PNG Image", &["png"]) + .set_file_name("ultralog_histogram.png") + .save_file() + else { + return; + }; + + match self.render_histogram_to_png(&path) { + Ok(_) => { + analytics::track_export("histogram_png"); + self.show_toast_success("Histogram exported as PNG"); + } + Err(e) => self.show_toast_error(&format!("Export failed: {}", e)), + } + } + + /// Export the current scatter plot view as PNG + pub fn export_scatter_plot_png(&mut self) { + // Show save dialog + let Some(path) = rfd::FileDialog::new() + .add_filter("PNG Image", &["png"]) + .set_file_name("ultralog_scatter_plot.png") + .save_file() + else { + return; + }; + + match self.render_scatter_plot_to_png(&path) { + Ok(_) => { + analytics::track_export("scatter_plot_png"); + self.show_toast_success("Scatter plot exported as PNG"); + } + Err(e) => self.show_toast_error(&format!("Export failed: {}", e)), + } + } + + /// Export the current scatter plot view as PDF + pub fn export_scatter_plot_pdf(&mut self) { + // Show save dialog + let Some(path) = rfd::FileDialog::new() + .add_filter("PDF Document", &["pdf"]) + .set_file_name("ultralog_scatter_plot.pdf") + .save_file() + else { + return; + }; + + match self.render_scatter_plot_to_pdf(&path) { + Ok(_) => { + analytics::track_export("scatter_plot_pdf"); + self.show_toast_success("Scatter plot exported as PDF"); + } + Err(e) => self.show_toast_error(&format!("Export failed: {}", e)), + } + } + + /// Export the current histogram view as PDF + pub fn export_histogram_pdf(&mut self) { + // Show save dialog + let Some(path) = rfd::FileDialog::new() + .add_filter("PDF Document", &["pdf"]) + .set_file_name("ultralog_histogram.pdf") + .save_file() + else { + return; + }; + + match self.render_histogram_to_pdf(&path) { + Ok(_) => { + analytics::track_export("histogram_pdf"); + self.show_toast_success("Histogram exported as PDF"); + } + Err(e) => self.show_toast_error(&format!("Export failed: {}", e)), + } + } + + /// Render histogram to PDF file + fn render_histogram_to_pdf( + &self, + path: &std::path::Path, + ) -> Result<(), Box> { + // Get tab and file data + let tab_idx = self.active_tab.ok_or("No active tab")?; + let config = &self.tabs[tab_idx].histogram_state.config; + let file_idx = self.tabs[tab_idx].file_index; + + if file_idx >= self.files.len() { + return Err("Invalid file index".into()); + } + + let file = &self.files[file_idx]; + let mode = config.mode; + let grid_size = config.grid_size.size(); + + let x_idx = config.x_channel.ok_or("X axis not selected")?; + let y_idx = config.y_channel.ok_or("Y axis not selected")?; + let z_idx = if mode == HistogramMode::AverageZ { + config + .z_channel + .ok_or("Z axis not selected for Average mode")? + } else { + 0 // unused + }; + + // Get channel data + let x_data = file.log.get_channel_data(x_idx); + let y_data = file.log.get_channel_data(y_idx); + let z_data = if mode == HistogramMode::AverageZ { + Some(file.log.get_channel_data(z_idx)) + } else { + None + }; + + if x_data.is_empty() || y_data.is_empty() { + return Err("No data available".into()); + } + + // Calculate data bounds + let x_min = x_data.iter().cloned().fold(f64::MAX, f64::min); + let x_max = x_data.iter().cloned().fold(f64::MIN, f64::max); + let y_min = y_data.iter().cloned().fold(f64::MAX, f64::min); + let y_max = y_data.iter().cloned().fold(f64::MIN, f64::max); + + let x_range = if (x_max - x_min).abs() < f64::EPSILON { + 1.0 + } else { + x_max - x_min + }; + let y_range = if (y_max - y_min).abs() < f64::EPSILON { + 1.0 + } else { + y_max - y_min + }; + + // Build histogram grid + let mut hit_counts = vec![vec![0u32; grid_size]; grid_size]; + let mut z_sums = vec![vec![0.0f64; grid_size]; grid_size]; + + for i in 0..x_data.len() { + let x_bin = (((x_data[i] - x_min) / x_range) * (grid_size - 1) as f64).round() as usize; + let y_bin = (((y_data[i] - y_min) / y_range) * (grid_size - 1) as f64).round() as usize; + let x_bin = x_bin.min(grid_size - 1); + let y_bin = y_bin.min(grid_size - 1); + + hit_counts[y_bin][x_bin] += 1; + if let Some(ref z) = z_data { + z_sums[y_bin][x_bin] += z[i]; + } + } + + // Calculate cell values and find min/max for color scaling + let mut cell_values = vec![vec![None::; grid_size]; grid_size]; + let mut min_value: f64 = f64::MAX; + let mut max_value: f64 = f64::MIN; + + for y_bin in 0..grid_size { + for x_bin in 0..grid_size { + let hits = hit_counts[y_bin][x_bin]; + if hits > 0 { + let value = match mode { + HistogramMode::HitCount => hits as f64, + HistogramMode::AverageZ => z_sums[y_bin][x_bin] / hits as f64, + }; + cell_values[y_bin][x_bin] = Some(value); + min_value = min_value.min(value); + max_value = max_value.max(value); + } + } + } + + let value_range = if (max_value - min_value).abs() < f64::EPSILON { + 1.0 + } else { + max_value - min_value + }; + + // Get channel names + let x_name = file.log.channels[x_idx].name(); + let y_name = file.log.channels[y_idx].name(); + let z_name = if mode == HistogramMode::AverageZ { + file.log.channels[z_idx].name() + } else { + "Hit Count".to_string() + }; + + // Create PDF document (A4 landscape) + let (doc, page1, layer1) = PdfDocument::new( + "UltraLog Histogram Export", + Mm(297.0), + Mm(210.0), + "Histogram", + ); + + let current_layer = doc.get_page(page1).get_layer(layer1); + + // Chart dimensions in mm (A4 landscape with margins) + let margin: f64 = 20.0; + let axis_margin: f64 = 25.0; + let chart_left: f64 = margin + axis_margin; + let chart_right: f64 = 250.0; // Leave room for legend + let chart_bottom: f64 = margin + axis_margin; + let chart_top: f64 = 210.0 - margin - 30.0; + + let chart_width: f64 = chart_right - chart_left; + let chart_height: f64 = chart_top - chart_bottom; + + let cell_width = chart_width / grid_size as f64; + let cell_height = chart_height / grid_size as f64; + + // Draw title + let font = doc.add_builtin_font(BuiltinFont::HelveticaBold)?; + current_layer.use_text( + "UltraLog Histogram Export", + 16.0, + Mm(margin as f32), + Mm(200.0), + &font, + ); + + // Draw subtitle + let font_regular = doc.add_builtin_font(BuiltinFont::Helvetica)?; + let subtitle = format!( + "{} | Grid: {}x{} | Mode: {}", + file.name, + grid_size, + grid_size, + if mode == HistogramMode::HitCount { + "Hit Count" + } else { + "Average Z" + } + ); + current_layer.use_text(&subtitle, 10.0, Mm(margin as f32), Mm(192.0), &font_regular); + + // Draw axis labels + let axis_subtitle = format!("X: {} | Y: {} | Z: {}", x_name, y_name, z_name); + current_layer.use_text( + &axis_subtitle, + 9.0, + Mm(margin as f32), + Mm(186.0), + &font_regular, + ); + + // Draw histogram cells + for y_bin in 0..grid_size { + for x_bin in 0..grid_size { + let cell_x = chart_left + x_bin as f64 * cell_width; + let cell_y = chart_bottom + y_bin as f64 * cell_height; + + if let Some(value) = cell_values[y_bin][x_bin] { + // Calculate color + let normalized = if mode == HistogramMode::HitCount && max_value > 1.0 { + (value.ln() / max_value.ln()).clamp(0.0, 1.0) + } else { + ((value - min_value) / value_range).clamp(0.0, 1.0) + }; + let color = Self::get_pdf_heat_color(normalized); + + current_layer.set_fill_color(color); + + // Draw filled rectangle + let rect = printpdf::Polygon { + rings: vec![vec![ + (Point::new(Mm(cell_x as f32), Mm(cell_y as f32)), false), + ( + Point::new(Mm((cell_x + cell_width) as f32), Mm(cell_y as f32)), + false, + ), + ( + Point::new( + Mm((cell_x + cell_width) as f32), + Mm((cell_y + cell_height) as f32), + ), + false, + ), + ( + Point::new(Mm(cell_x as f32), Mm((cell_y + cell_height) as f32)), + false, + ), + ]], + mode: PaintMode::Fill, + winding_order: WindingOrder::NonZero, + }; + current_layer.add_polygon(rect); + + // Draw cell value text (only for smaller grids) + if grid_size <= 32 { + let text = if mode == HistogramMode::HitCount { + format!("{}", hit_counts[y_bin][x_bin]) + } else { + format!("{:.1}", value) + }; + + // Calculate text color based on brightness + let brightness = normalized; + let text_color = if brightness > 0.5 { + Color::Rgb(Rgb::new(0.0, 0.0, 0.0, None)) // Black + } else { + Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)) // White + }; + + current_layer.set_fill_color(text_color); + let font_size = if grid_size <= 16 { 6.0 } else { 4.0 }; + current_layer.use_text( + &text, + font_size, + Mm((cell_x + cell_width / 2.0 - 2.0) as f32), + Mm((cell_y + cell_height / 2.0 - 1.0) as f32), + &font_regular, + ); + } + } + } + } + + // Draw grid lines + let grid_color = Color::Rgb(Rgb::new(0.4, 0.4, 0.4, None)); + current_layer.set_outline_color(grid_color); + current_layer.set_outline_thickness(0.25); + + for i in 0..=grid_size { + let x = chart_left + i as f64 * cell_width; + let y = chart_bottom + i as f64 * cell_height; + + // Vertical line + let vline = Line { + points: vec![ + (Point::new(Mm(x as f32), Mm(chart_bottom as f32)), false), + (Point::new(Mm(x as f32), Mm(chart_top as f32)), false), + ], + is_closed: false, + }; + current_layer.add_line(vline); + + // Horizontal line + let hline = Line { + points: vec![ + (Point::new(Mm(chart_left as f32), Mm(y as f32)), false), + (Point::new(Mm(chart_right as f32), Mm(y as f32)), false), + ], + is_closed: false, + }; + current_layer.add_line(hline); + } + + // Draw axis value labels + let label_color = Color::Rgb(Rgb::new(0.0, 0.0, 0.0, None)); + current_layer.set_fill_color(label_color); + + // Y axis labels + for i in 0..=4 { + let t = i as f64 / 4.0; + let value = y_min + t * y_range; + let y_pos = chart_bottom + t * chart_height; + current_layer.use_text( + format!("{:.0}", value), + 7.0, + Mm((chart_left - 12.0) as f32), + Mm((y_pos - 1.0) as f32), + &font_regular, + ); + } + + // X axis labels + for i in 0..=4 { + let t = i as f64 / 4.0; + let value = x_min + t * x_range; + let x_pos = chart_left + t * chart_width; + current_layer.use_text( + format!("{:.0}", value), + 7.0, + Mm((x_pos - 4.0) as f32), + Mm((chart_bottom - 8.0) as f32), + &font_regular, + ); + } + + // Draw legend (color scale) + let legend_left: f64 = 260.0; + let legend_width: f64 = 15.0; + let legend_bottom: f64 = chart_bottom; + let legend_height: f64 = chart_height; + + // Draw legend title + current_layer.set_fill_color(Color::Rgb(Rgb::new(0.0, 0.0, 0.0, None))); + let legend_title = if mode == HistogramMode::HitCount { + "Hits" + } else { + "Value" + }; + current_layer.use_text( + legend_title, + 9.0, + Mm(legend_left as f32), + Mm((legend_bottom + legend_height + 5.0) as f32), + &font, + ); + + // Draw color gradient bar + let gradient_steps = 30; + let step_height = legend_height / gradient_steps as f64; + + for i in 0..gradient_steps { + let t = i as f64 / gradient_steps as f64; + let color = Self::get_pdf_heat_color(t); + current_layer.set_fill_color(color); + + let y = legend_bottom + i as f64 * step_height; + let rect = printpdf::Polygon { + rings: vec![vec![ + (Point::new(Mm(legend_left as f32), Mm(y as f32)), false), + ( + Point::new(Mm((legend_left + legend_width) as f32), Mm(y as f32)), + false, + ), + ( + Point::new( + Mm((legend_left + legend_width) as f32), + Mm((y + step_height + 0.5) as f32), + ), + false, + ), + ( + Point::new(Mm(legend_left as f32), Mm((y + step_height + 0.5) as f32)), + false, + ), + ]], + mode: PaintMode::Fill, + winding_order: WindingOrder::NonZero, + }; + current_layer.add_polygon(rect); + } + + // Draw legend min/max labels + current_layer.set_fill_color(Color::Rgb(Rgb::new(0.0, 0.0, 0.0, None))); + let min_label = if mode == HistogramMode::HitCount { + "0".to_string() + } else { + format!("{:.1}", min_value) + }; + let max_label = if mode == HistogramMode::HitCount { + format!("{:.0}", max_value) + } else { + format!("{:.1}", max_value) + }; + + current_layer.use_text( + &min_label, + 7.0, + Mm((legend_left + legend_width + 3.0) as f32), + Mm(legend_bottom as f32), + &font_regular, + ); + current_layer.use_text( + &max_label, + 7.0, + Mm((legend_left + legend_width + 3.0) as f32), + Mm((legend_bottom + legend_height - 3.0) as f32), + &font_regular, + ); + + // Draw statistics + let stats_y = legend_bottom - 15.0; + current_layer.use_text( + format!("Total Points: {}", x_data.len()), + 8.0, + Mm(legend_left as f32), + Mm(stats_y as f32), + &font_regular, + ); + + // Save PDF + let file = File::create(path)?; + let mut writer = BufWriter::new(file); + doc.save(&mut writer)?; + + Ok(()) + } + + /// Get a PDF color from the heat map gradient based on normalized value (0-1) + fn get_pdf_heat_color(normalized: f64) -> Color { + const HEAT_COLORS: &[[u8; 3]] = &[ + [0, 0, 80], // Dark blue (0.0) + [0, 0, 180], // Blue + [0, 100, 255], // Light blue + [0, 200, 255], // Cyan + [0, 255, 200], // Cyan-green + [0, 255, 100], // Green + [100, 255, 0], // Yellow-green + [200, 255, 0], // Yellow + [255, 200, 0], // Orange + [255, 100, 0], // Red-orange + [255, 0, 0], // Red (1.0) + ]; + + let t = normalized.clamp(0.0, 1.0); + let scaled = t * (HEAT_COLORS.len() - 1) as f64; + let idx = scaled.floor() as usize; + let frac = scaled - idx as f64; + + if idx >= HEAT_COLORS.len() - 1 { + let c = HEAT_COLORS[HEAT_COLORS.len() - 1]; + return Color::Rgb(Rgb::new( + c[0] as f32 / 255.0, + c[1] as f32 / 255.0, + c[2] as f32 / 255.0, + None, + )); + } + + let c1 = HEAT_COLORS[idx]; + let c2 = HEAT_COLORS[idx + 1]; + + let r = (c1[0] as f64 + (c2[0] as f64 - c1[0] as f64) * frac) / 255.0; + let g = (c1[1] as f64 + (c2[1] as f64 - c1[1] as f64) * frac) / 255.0; + let b = (c1[2] as f64 + (c2[2] as f64 - c1[2] as f64) * frac) / 255.0; + + Color::Rgb(Rgb::new(r as f32, g as f32, b as f32, None)) + } + + /// Get a PNG color from the heat map gradient based on normalized value (0-1) + fn get_png_heat_color(normalized: f64) -> Rgba { + const HEAT_COLORS: &[[u8; 3]] = &[ + [0, 0, 80], // Dark blue (0.0) + [0, 0, 180], // Blue + [0, 100, 255], // Light blue + [0, 200, 255], // Cyan + [0, 255, 200], // Cyan-green + [0, 255, 100], // Green + [100, 255, 0], // Yellow-green + [200, 255, 0], // Yellow + [255, 200, 0], // Orange + [255, 100, 0], // Red-orange + [255, 0, 0], // Red (1.0) + ]; + + let t = normalized.clamp(0.0, 1.0); + let scaled = t * (HEAT_COLORS.len() - 1) as f64; + let idx = scaled.floor() as usize; + let frac = scaled - idx as f64; + + if idx >= HEAT_COLORS.len() - 1 { + let c = HEAT_COLORS[HEAT_COLORS.len() - 1]; + return Rgba([c[0], c[1], c[2], 255]); + } + + let c1 = HEAT_COLORS[idx]; + let c2 = HEAT_COLORS[idx + 1]; + + let r = (c1[0] as f64 + (c2[0] as f64 - c1[0] as f64) * frac) as u8; + let g = (c1[1] as f64 + (c2[1] as f64 - c1[1] as f64) * frac) as u8; + let b = (c1[2] as f64 + (c2[2] as f64 - c1[2] as f64) * frac) as u8; + + Rgba([r, g, b, 255]) + } + + /// Render histogram to PNG file + fn render_histogram_to_png( + &self, + path: &std::path::Path, + ) -> Result<(), Box> { + // Get tab and file data + let tab_idx = self.active_tab.ok_or("No active tab")?; + let config = &self.tabs[tab_idx].histogram_state.config; + let file_idx = self.tabs[tab_idx].file_index; + + if file_idx >= self.files.len() { + return Err("Invalid file index".into()); + } + + let file = &self.files[file_idx]; + let mode = config.mode; + let grid_size = config.grid_size.size(); + + let x_idx = config.x_channel.ok_or("X axis not selected")?; + let y_idx = config.y_channel.ok_or("Y axis not selected")?; + let z_idx = if mode == HistogramMode::AverageZ { + config + .z_channel + .ok_or("Z axis not selected for Average mode")? + } else { + 0 // unused + }; + + // Get channel data + let x_data = file.log.get_channel_data(x_idx); + let y_data = file.log.get_channel_data(y_idx); + let z_data = if mode == HistogramMode::AverageZ { + Some(file.log.get_channel_data(z_idx)) + } else { + None + }; + + if x_data.is_empty() || y_data.is_empty() { + return Err("No data available".into()); + } + + // Calculate data bounds + let x_min = x_data.iter().cloned().fold(f64::MAX, f64::min); + let x_max = x_data.iter().cloned().fold(f64::MIN, f64::max); + let y_min = y_data.iter().cloned().fold(f64::MAX, f64::min); + let y_max = y_data.iter().cloned().fold(f64::MIN, f64::max); + + let x_range = if (x_max - x_min).abs() < f64::EPSILON { + 1.0 + } else { + x_max - x_min + }; + let y_range = if (y_max - y_min).abs() < f64::EPSILON { + 1.0 + } else { + y_max - y_min + }; + + // Build histogram grid + let mut hit_counts = vec![vec![0u32; grid_size]; grid_size]; + let mut z_sums = vec![vec![0.0f64; grid_size]; grid_size]; + + for i in 0..x_data.len() { + let x_bin = (((x_data[i] - x_min) / x_range) * (grid_size - 1) as f64).round() as usize; + let y_bin = (((y_data[i] - y_min) / y_range) * (grid_size - 1) as f64).round() as usize; + let x_bin = x_bin.min(grid_size - 1); + let y_bin = y_bin.min(grid_size - 1); + + hit_counts[y_bin][x_bin] += 1; + if let Some(ref z) = z_data { + z_sums[y_bin][x_bin] += z[i]; + } + } + + // Calculate cell values and find min/max for color scaling + let mut cell_values = vec![vec![None::; grid_size]; grid_size]; + let mut min_value: f64 = f64::MAX; + let mut max_value: f64 = f64::MIN; + + for y_bin in 0..grid_size { + for x_bin in 0..grid_size { + let hits = hit_counts[y_bin][x_bin]; + if hits > 0 { + let value = match mode { + HistogramMode::HitCount => hits as f64, + HistogramMode::AverageZ => z_sums[y_bin][x_bin] / hits as f64, + }; + cell_values[y_bin][x_bin] = Some(value); + min_value = min_value.min(value); + max_value = max_value.max(value); + } + } + } + + let value_range = if (max_value - min_value).abs() < f64::EPSILON { + 1.0 + } else { + max_value - min_value + }; + + // Image dimensions + let width = 1920u32; + let height = 1080u32; + let margin = 80u32; + let legend_width = 100u32; + + let chart_left = margin; + let chart_right = width - margin - legend_width; + let chart_top = margin; + let chart_bottom = height - margin; + + let chart_width = chart_right - chart_left; + let chart_height = chart_bottom - chart_top; + let cell_width = chart_width as f64 / grid_size as f64; + let cell_height = chart_height as f64 / grid_size as f64; + + // Create image buffer + let mut imgbuf = RgbaImage::new(width, height); + + // Fill with dark background + for pixel in imgbuf.pixels_mut() { + *pixel = Rgba([30, 30, 30, 255]); + } + + // Draw histogram cells + #[allow(clippy::needless_range_loop)] + for y_bin in 0..grid_size { + for x_bin in 0..grid_size { + if let Some(value) = cell_values[y_bin][x_bin] { + // Calculate color + let normalized = if mode == HistogramMode::HitCount && max_value > 1.0 { + (value.ln() / max_value.ln()).clamp(0.0, 1.0) + } else { + ((value - min_value) / value_range).clamp(0.0, 1.0) + }; + let color = Self::get_png_heat_color(normalized); + + // Calculate cell position (Y inverted - higher values at top) + let cell_x = chart_left as f64 + x_bin as f64 * cell_width; + let cell_y = chart_bottom as f64 - (y_bin + 1) as f64 * cell_height; + + // Fill cell + for py in 0..(cell_height.ceil() as u32) { + for px in 0..(cell_width.ceil() as u32) { + let x = (cell_x as u32 + px).min(width - 1); + let y = (cell_y as u32 + py).min(height - 1); + imgbuf.put_pixel(x, y, color); + } + } + } + } + } + + // Draw grid lines + let grid_color = Rgba([80, 80, 80, 255]); + for i in 0..=grid_size { + let x = chart_left + (i as f64 * cell_width) as u32; + let y = chart_top + (i as f64 * cell_height) as u32; + + // Vertical line + for py in chart_top..chart_bottom { + if x < width { + imgbuf.put_pixel(x, py, grid_color); + } + } + + // Horizontal line + for px in chart_left..chart_right { + if y < height { + imgbuf.put_pixel(px, y, grid_color); + } + } + } + + // Draw color scale legend + let legend_left = width - margin - legend_width + 20; + let legend_bar_width = 20u32; + let legend_height = chart_height; + + for i in 0..legend_height { + let t = i as f64 / legend_height as f64; + let color = Self::get_png_heat_color(t); + + for px in 0..legend_bar_width { + let x = legend_left + px; + let y = chart_bottom - i; + if x < width && y < height { + imgbuf.put_pixel(x, y, color); + } + } + } + + // Save the image + imgbuf.save(path)?; + + Ok(()) + } + + /// Render scatter plot to PNG file (exports both left and right plots) + fn render_scatter_plot_to_png( + &self, + path: &std::path::Path, + ) -> Result<(), Box> { + let tab_idx = self.active_tab.ok_or("No active tab")?; + + // Image dimensions (wider to fit two plots) + let width = 2560u32; + let height = 1080u32; + let margin = 60u32; + let gap = 40u32; + + let plot_width = (width - 2 * margin - gap) / 2; + let plot_height = height - 2 * margin; + + // Create image buffer + let mut imgbuf = RgbaImage::new(width, height); + + // Fill with dark background + for pixel in imgbuf.pixels_mut() { + *pixel = Rgba([30, 30, 30, 255]); + } + + // Render left plot + let left_config = &self.tabs[tab_idx].scatter_plot_state.left; + let left_rect = (margin, margin, plot_width, plot_height); + self.render_scatter_plot_to_image(&mut imgbuf, left_config, left_rect, tab_idx)?; + + // Render right plot + let right_config = &self.tabs[tab_idx].scatter_plot_state.right; + let right_rect = (margin + plot_width + gap, margin, plot_width, plot_height); + self.render_scatter_plot_to_image(&mut imgbuf, right_config, right_rect, tab_idx)?; + + // Save the image + imgbuf.save(path)?; + + Ok(()) + } + + /// Helper to render a single scatter plot to an image region + fn render_scatter_plot_to_image( + &self, + imgbuf: &mut RgbaImage, + config: &crate::state::ScatterPlotConfig, + rect: (u32, u32, u32, u32), + tab_idx: usize, + ) -> Result<(), Box> { + let (left, top, width, height) = rect; + let right = left + width; + let bottom = top + height; + + let file_idx = config.file_index.unwrap_or(self.tabs[tab_idx].file_index); + + // Check if we have valid axis selections + let (x_idx, y_idx) = match (config.x_channel, config.y_channel) { + (Some(x), Some(y)) => (x, y), + _ => { + // Draw placeholder + let placeholder_color = Rgba([80, 80, 80, 255]); + for y in top..bottom { + for x in left..right { + imgbuf.put_pixel(x, y, placeholder_color); + } + } + return Ok(()); + } + }; + + if file_idx >= self.files.len() { + return Err("Invalid file index".into()); + } + + let file = &self.files[file_idx]; + let x_data = file.log.get_channel_data(x_idx); + let y_data = file.log.get_channel_data(y_idx); + + if x_data.is_empty() || y_data.is_empty() || x_data.len() != y_data.len() { + return Err("No data available".into()); + } + + // Calculate data bounds + let x_min = x_data.iter().cloned().fold(f64::MAX, f64::min); + let x_max = x_data.iter().cloned().fold(f64::MIN, f64::max); + let y_min = y_data.iter().cloned().fold(f64::MAX, f64::min); + let y_max = y_data.iter().cloned().fold(f64::MIN, f64::max); + + let x_range = if (x_max - x_min).abs() < f64::EPSILON { + 1.0 + } else { + x_max - x_min + }; + let y_range = if (y_max - y_min).abs() < f64::EPSILON { + 1.0 + } else { + y_max - y_min + }; + + // Build 2D histogram (512 bins like the UI) + const HEATMAP_BINS: usize = 512; + let mut histogram = vec![vec![0u32; HEATMAP_BINS]; HEATMAP_BINS]; + let mut max_hits: u32 = 0; + + for (&x, &y) in x_data.iter().zip(y_data.iter()) { + let x_bin = (((x - x_min) / x_range) * (HEATMAP_BINS - 1) as f64).round() as usize; + let y_bin = (((y - y_min) / y_range) * (HEATMAP_BINS - 1) as f64).round() as usize; + + let x_bin = x_bin.min(HEATMAP_BINS - 1); + let y_bin = y_bin.min(HEATMAP_BINS - 1); + + histogram[y_bin][x_bin] += 1; + max_hits = max_hits.max(histogram[y_bin][x_bin]); + } + + // Fill background with black + let bg_color = Rgba([0, 0, 0, 255]); + for y in top..bottom { + for x in left..right { + imgbuf.put_pixel(x, y, bg_color); + } + } + + let cell_width = width as f64 / HEATMAP_BINS as f64; + let cell_height = height as f64 / HEATMAP_BINS as f64; + + // Draw heatmap cells + #[allow(clippy::needless_range_loop)] + for y_bin in 0..HEATMAP_BINS { + for x_bin in 0..HEATMAP_BINS { + let hits = histogram[y_bin][x_bin]; + if hits > 0 { + // Normalize using log scale + let normalized = if max_hits > 1 { + (hits as f64).ln() / (max_hits as f64).ln() + } else { + 1.0 + }; + let color = Self::get_png_heat_color(normalized); + + // Calculate cell position (Y inverted) + let cell_x = left as f64 + x_bin as f64 * cell_width; + let cell_y = bottom as f64 - (y_bin + 1) as f64 * cell_height; + + // Fill cell + for py in 0..(cell_height.ceil() as u32 + 1) { + for px in 0..(cell_width.ceil() as u32 + 1) { + let x = (cell_x as u32 + px).min(right - 1); + let y = (cell_y as u32 + py).min(bottom - 1); + if x >= left && y >= top { + imgbuf.put_pixel(x, y, color); + } + } + } + } + } + } + + Ok(()) + } + + /// Render scatter plot to PDF file + fn render_scatter_plot_to_pdf( + &self, + path: &std::path::Path, + ) -> Result<(), Box> { + let tab_idx = self.active_tab.ok_or("No active tab")?; + + // Create PDF document (A4 landscape) + let (doc, page1, layer1) = PdfDocument::new( + "UltraLog Scatter Plot Export", + Mm(297.0), + Mm(210.0), + "Scatter Plot", + ); + + let current_layer = doc.get_page(page1).get_layer(layer1); + + // Draw title + let font = doc.add_builtin_font(BuiltinFont::HelveticaBold)?; + let font_regular = doc.add_builtin_font(BuiltinFont::Helvetica)?; + + current_layer.use_text( + "UltraLog Scatter Plot Export", + 16.0, + Mm(20.0), + Mm(200.0), + &font, + ); + + // Subtitle with file info + if let Some(file_idx) = self.selected_file { + if file_idx < self.files.len() { + let file = &self.files[file_idx]; + current_layer.use_text(&file.name, 10.0, Mm(20.0), Mm(192.0), &font_regular); + } + } + + // Layout: two plots side by side + let margin: f64 = 20.0; + let gap: f64 = 15.0; + let plot_width: f64 = (297.0 - 2.0 * margin - gap) / 2.0; + let plot_height: f64 = 150.0; + let plot_top: f64 = 180.0; + let plot_bottom: f64 = plot_top - plot_height; + + // Render left plot + let left_config = &self.tabs[tab_idx].scatter_plot_state.left; + let left_rect = (margin, plot_bottom, plot_width, plot_height); + self.render_scatter_plot_to_pdf_region( + ¤t_layer, + &font_regular, + left_config, + left_rect, + tab_idx, + )?; + + // Render right plot + let right_config = &self.tabs[tab_idx].scatter_plot_state.right; + let right_rect = ( + margin + plot_width + gap, + plot_bottom, + plot_width, + plot_height, + ); + self.render_scatter_plot_to_pdf_region( + ¤t_layer, + &font_regular, + right_config, + right_rect, + tab_idx, + )?; + + // Save PDF + let file = File::create(path)?; + let mut writer = BufWriter::new(file); + doc.save(&mut writer)?; + + Ok(()) + } + + /// Helper to render a single scatter plot to a PDF region + fn render_scatter_plot_to_pdf_region( + &self, + layer: &printpdf::PdfLayerReference, + font: &printpdf::IndirectFontRef, + config: &crate::state::ScatterPlotConfig, + rect: (f64, f64, f64, f64), + tab_idx: usize, + ) -> Result<(), Box> { + let (left, bottom, width, height) = rect; + let right = left + width; + let top = bottom + height; + + let file_idx = config.file_index.unwrap_or(self.tabs[tab_idx].file_index); + + // Check if we have valid axis selections + let (x_idx, y_idx) = match (config.x_channel, config.y_channel) { + (Some(x), Some(y)) => (x, y), + _ => { + // Draw placeholder text + layer.use_text( + "No axes selected", + 10.0, + Mm((left + width / 2.0 - 15.0) as f32), + Mm((bottom + height / 2.0) as f32), + font, + ); + return Ok(()); + } + }; + + if file_idx >= self.files.len() { + return Err("Invalid file index".into()); + } + + let file = &self.files[file_idx]; + let x_data = file.log.get_channel_data(x_idx); + let y_data = file.log.get_channel_data(y_idx); + + if x_data.is_empty() || y_data.is_empty() || x_data.len() != y_data.len() { + return Err("No data available".into()); + } + + // Get channel names for labels + let x_name = file.log.channels[x_idx].name(); + let y_name = file.log.channels[y_idx].name(); + + // Draw axis labels + layer.use_text( + format!("{} vs {}", y_name, x_name), + 9.0, + Mm(left as f32), + Mm((top + 3.0) as f32), + font, + ); + + // Calculate data bounds + let x_min = x_data.iter().cloned().fold(f64::MAX, f64::min); + let x_max = x_data.iter().cloned().fold(f64::MIN, f64::max); + let y_min = y_data.iter().cloned().fold(f64::MAX, f64::min); + let y_max = y_data.iter().cloned().fold(f64::MIN, f64::max); + + let x_range = if (x_max - x_min).abs() < f64::EPSILON { + 1.0 + } else { + x_max - x_min + }; + let y_range = if (y_max - y_min).abs() < f64::EPSILON { + 1.0 + } else { + y_max - y_min + }; + + // Build 2D histogram (use smaller bins for PDF) + const PDF_BINS: usize = 64; + let mut histogram = vec![vec![0u32; PDF_BINS]; PDF_BINS]; + let mut max_hits: u32 = 0; + + for (&x, &y) in x_data.iter().zip(y_data.iter()) { + let x_bin = (((x - x_min) / x_range) * (PDF_BINS - 1) as f64).round() as usize; + let y_bin = (((y - y_min) / y_range) * (PDF_BINS - 1) as f64).round() as usize; + + let x_bin = x_bin.min(PDF_BINS - 1); + let y_bin = y_bin.min(PDF_BINS - 1); + + histogram[y_bin][x_bin] += 1; + max_hits = max_hits.max(histogram[y_bin][x_bin]); + } + + let cell_width = width / PDF_BINS as f64; + let cell_height = height / PDF_BINS as f64; + + // Draw heatmap cells + #[allow(clippy::needless_range_loop)] + for y_bin in 0..PDF_BINS { + for x_bin in 0..PDF_BINS { + let hits = histogram[y_bin][x_bin]; + if hits > 0 { + // Normalize using log scale + let normalized = if max_hits > 1 { + (hits as f64).ln() / (max_hits as f64).ln() + } else { + 1.0 + }; + let color = Self::get_pdf_heat_color(normalized); + + layer.set_fill_color(color); + + let cell_x = left + x_bin as f64 * cell_width; + let cell_y = bottom + y_bin as f64 * cell_height; + + let rect = printpdf::Polygon { + rings: vec![vec![ + (Point::new(Mm(cell_x as f32), Mm(cell_y as f32)), false), + ( + Point::new(Mm((cell_x + cell_width) as f32), Mm(cell_y as f32)), + false, + ), + ( + Point::new( + Mm((cell_x + cell_width) as f32), + Mm((cell_y + cell_height) as f32), + ), + false, + ), + ( + Point::new(Mm(cell_x as f32), Mm((cell_y + cell_height) as f32)), + false, + ), + ]], + mode: PaintMode::Fill, + winding_order: WindingOrder::NonZero, + }; + layer.add_polygon(rect); + } + } + } + + // Draw border + let border_color = Color::Rgb(Rgb::new(0.5, 0.5, 0.5, None)); + layer.set_outline_color(border_color); + layer.set_outline_thickness(0.5); + + let border = Line { + points: vec![ + (Point::new(Mm(left as f32), Mm(bottom as f32)), false), + (Point::new(Mm(right as f32), Mm(bottom as f32)), false), + (Point::new(Mm(right as f32), Mm(top as f32)), false), + (Point::new(Mm(left as f32), Mm(top as f32)), false), + ], + is_closed: true, + }; + layer.add_line(border); + + // Draw axis labels + let label_color = Color::Rgb(Rgb::new(0.0, 0.0, 0.0, None)); + layer.set_fill_color(label_color); + + // X axis labels + for i in 0..=4 { + let t = i as f64 / 4.0; + let value = x_min + t * x_range; + let x_pos = left + t * width; + layer.use_text( + format!("{:.0}", value), + 6.0, + Mm((x_pos - 3.0) as f32), + Mm((bottom - 5.0) as f32), + font, + ); + } + + // Y axis labels + for i in 0..=4 { + let t = i as f64 / 4.0; + let value = y_min + t * y_range; + let y_pos = bottom + t * height; + layer.use_text( + format!("{:.0}", value), + 6.0, + Mm((left - 10.0) as f32), + Mm((y_pos - 1.0) as f32), + font, + ); + } + + Ok(()) + } } /// Draw a line between two points using Bresenham's algorithm diff --git a/src/ui/files_panel.rs b/src/ui/files_panel.rs new file mode 100644 index 0000000..5d3a67e --- /dev/null +++ b/src/ui/files_panel.rs @@ -0,0 +1,287 @@ +//! Files panel - file management, loading, and file list. + +use eframe::egui; + +use crate::app::UltraLogApp; +use crate::state::{LoadingState, SUPPORTED_EXTENSIONS}; +use crate::ui::icons::draw_upload_icon; + +impl UltraLogApp { + /// Render the files panel content (called from side_panel.rs) + pub fn render_files_panel_content(&mut self, ui: &mut egui::Ui) { + // Show loading indicator + if let LoadingState::Loading(filename) = &self.loading_state { + ui.horizontal(|ui| { + ui.spinner(); + ui.label(format!("Loading {}...", filename)); + }); + ui.add_space(8.0); + ui.separator(); + ui.add_space(8.0); + } + + let is_loading = matches!(self.loading_state, LoadingState::Loading(_)); + + // File list (if any files loaded) + if !self.files.is_empty() { + self.render_file_list(ui); + + ui.add_space(10.0); + + // Add more files button + ui.add_enabled_ui(!is_loading, |ui| { + self.render_add_file_button(ui); + }); + } else if !is_loading { + // Nice drop zone when no files loaded + self.render_drop_zone_card(ui); + } + } + + /// Render the list of loaded files + fn render_file_list(&mut self, ui: &mut egui::Ui) { + let mut file_to_remove: Option = None; + let mut file_to_switch: Option = None; + + // Collect file info upfront to avoid borrow issues + let file_info: Vec<(String, bool, String, usize, usize)> = self + .files + .iter() + .enumerate() + .map(|(i, file)| { + ( + file.name.clone(), + self.selected_file == Some(i), + file.ecu_type.name().to_string(), + file.log.channels.len(), + file.log.data.len(), + ) + }) + .collect(); + + ui.label( + egui::RichText::new(format!("Loaded Files ({})", file_info.len())) + .size(self.scaled_font(13.0)) + .color(egui::Color32::GRAY), + ); + ui.add_space(4.0); + + for (i, (file_name, is_selected, ecu_name, channel_count, data_count)) in + file_info.iter().enumerate() + { + // File card with selection highlight + let card_bg = if *is_selected { + egui::Color32::from_rgb(50, 55, 45) // Subtle olive tint for selected + } else { + egui::Color32::from_rgb(40, 40, 40) + }; + + let card_border = if *is_selected { + egui::Color32::from_rgb(113, 120, 78) // Olive green for selected + } else { + egui::Color32::from_rgb(55, 55, 55) + }; + + egui::Frame::NONE + .fill(card_bg) + .stroke(egui::Stroke::new(1.0, card_border)) + .corner_radius(6) + .inner_margin(egui::Margin::symmetric(10, 8)) + .show(ui, |ui| { + ui.horizontal(|ui| { + // File name (clickable) + let response = ui.add( + egui::Label::new( + egui::RichText::new(file_name) + .size(self.scaled_font(14.0)) + .color(if *is_selected { + egui::Color32::WHITE + } else { + egui::Color32::LIGHT_GRAY + }), + ) + .sense(egui::Sense::click()), + ); + + if response.clicked() { + file_to_switch = Some(i); + } + + if response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + + // Spacer + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // Delete button + let close_btn = ui.add( + egui::Label::new( + egui::RichText::new("x") + .size(self.scaled_font(16.0)) + .color(egui::Color32::from_rgb(150, 150, 150)), + ) + .sense(egui::Sense::click()), + ); + + if close_btn.clicked() { + file_to_remove = Some(i); + } + + if close_btn.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + }); + }); + + // ECU type and data info + ui.label( + egui::RichText::new(format!( + "{} • {} ch • {} pts", + ecu_name, channel_count, data_count + )) + .size(self.scaled_font(11.0)) + .color(egui::Color32::GRAY), + ); + }); + + ui.add_space(4.0); + } + + // Handle deferred file switching + if let Some(index) = file_to_switch { + self.switch_to_file_tab(index); + } + + if let Some(index) = file_to_remove { + self.remove_file(index); + } + } + + /// Render the "Add File" button + fn render_add_file_button(&mut self, ui: &mut egui::Ui) { + let primary_color = egui::Color32::from_rgb(113, 120, 78); // Olive green + + let button_response = egui::Frame::NONE + .fill(primary_color) + .corner_radius(6) + .inner_margin(egui::vec2(16.0, 8.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("+") + .color(egui::Color32::WHITE) + .size(self.scaled_font(16.0)), + ); + ui.label( + egui::RichText::new("Add File") + .color(egui::Color32::WHITE) + .size(self.scaled_font(14.0)), + ); + }); + }); + + if button_response + .response + .interact(egui::Sense::click()) + .clicked() + { + if let Some(path) = rfd::FileDialog::new() + .add_filter("Log Files", crate::state::SUPPORTED_EXTENSIONS) + .pick_file() + { + self.start_loading_file(path); + } + } + + if button_response.response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + } + + /// Render the drop zone card for when no files are loaded + fn render_drop_zone_card(&mut self, ui: &mut egui::Ui) { + let primary_color = egui::Color32::from_rgb(113, 120, 78); // Olive green + let card_bg = egui::Color32::from_rgb(45, 45, 45); + let text_gray = egui::Color32::from_rgb(150, 150, 150); + + ui.add_space(20.0); + + // Drop zone card + egui::Frame::NONE + .fill(card_bg) + .corner_radius(12) + .inner_margin(20.0) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + // Upload icon + let icon_size = 32.0; + let (icon_rect, _) = ui.allocate_exact_size( + egui::vec2(icon_size, icon_size), + egui::Sense::hover(), + ); + draw_upload_icon(ui, icon_rect.center(), icon_size, primary_color); + + ui.add_space(12.0); + + // Select file button + let button_response = egui::Frame::NONE + .fill(primary_color) + .corner_radius(6) + .inner_margin(egui::vec2(16.0, 8.0)) + .show(ui, |ui| { + ui.label( + egui::RichText::new("Select a file") + .color(egui::Color32::WHITE) + .size(self.scaled_font(14.0)), + ); + }); + + if button_response + .response + .interact(egui::Sense::click()) + .clicked() + { + if let Some(path) = rfd::FileDialog::new() + .add_filter("Log Files", crate::state::SUPPORTED_EXTENSIONS) + .pick_file() + { + self.start_loading_file(path); + } + } + + if button_response.response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + + ui.add_space(12.0); + + ui.label( + egui::RichText::new("or") + .color(text_gray) + .size(self.scaled_font(12.0)), + ); + + ui.add_space(8.0); + + ui.label( + egui::RichText::new("Drop file here") + .color(egui::Color32::LIGHT_GRAY) + .size(self.scaled_font(13.0)), + ); + + ui.add_space(12.0); + + let extensions_text = SUPPORTED_EXTENSIONS + .iter() + .map(|ext| ext.to_uppercase()) + .collect::>() + .join(" • "); + ui.label( + egui::RichText::new(extensions_text) + .color(text_gray) + .size(self.scaled_font(11.0)), + ); + }); + }); + } +} diff --git a/src/ui/formula_editor.rs b/src/ui/formula_editor.rs index 54f53b2..7cf69b7 100644 --- a/src/ui/formula_editor.rs +++ b/src/ui/formula_editor.rs @@ -1,6 +1,7 @@ //! Formula Editor UI. //! //! Provides a modal window for creating and editing computed channel formulas. +//! Features an expanded channel browser, quick pattern buttons, and rich preview with statistics. use eframe::egui; @@ -29,7 +30,7 @@ impl UltraLogApp { egui::Window::new(title) .open(&mut open) .resizable(true) - .default_width(500.0) + .default_width(550.0) .collapsible(false) .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) .order(egui::Order::Foreground) @@ -63,6 +64,50 @@ impl UltraLogApp { self.validate_current_formula(); } + // Quick pattern buttons + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Insert:").small().weak()); + ui.add_space(4.0); + + // Math operators + for (label, insert) in [ + ("+", " + "), + ("-", " - "), + ("*", " * "), + ("/", " / "), + ("()", "("), + ("abs", "abs("), + ("sqrt", "sqrt("), + ] { + if ui + .small_button(egui::RichText::new(label).monospace()) + .on_hover_text(format!("Insert {}", insert.trim())) + .clicked() + { + self.formula_editor_state.formula.push_str(insert); + self.validate_current_formula(); + } + } + + ui.separator(); + + // Time-shift operators + for (label, insert, tooltip) in [ + ("[-1]", "[-1]", "Previous sample (index offset)"), + ("@-0.1s", "@-0.1s", "Value 0.1 seconds ago"), + ] { + if ui + .small_button(egui::RichText::new(label).monospace()) + .on_hover_text(tooltip) + .clicked() + { + self.formula_editor_state.formula.push_str(insert); + self.validate_current_formula(); + } + } + }); + // Show validation status ui.add_space(4.0); if let Some(error) = &self.formula_editor_state.validation_error { @@ -76,41 +121,46 @@ impl UltraLogApp { ui.add_space(8.0); - // Unit field + // Unit and description in a row ui.horizontal(|ui| { ui.label("Unit:"); ui.add( egui::TextEdit::singleline(&mut self.formula_editor_state.unit) .hint_text("e.g., RPM/s") - .desired_width(150.0), + .desired_width(100.0), ); - }); - ui.add_space(8.0); + ui.add_space(16.0); - // Description field - ui.label("Description (optional):"); - ui.add( - egui::TextEdit::multiline(&mut self.formula_editor_state.description) - .hint_text("What does this computed channel calculate?") - .desired_width(ui.available_width()) - .desired_rows(2), - ); + ui.label("Description:"); + ui.add( + egui::TextEdit::singleline(&mut self.formula_editor_state.description) + .hint_text("Optional description") + .desired_width(ui.available_width() - 20.0), + ); + }); ui.add_space(8.0); - // Channel browser + // Channel browser - expanded by default for discoverability if self.active_tab.is_some() { egui::CollapsingHeader::new("Available Channels") - .default_open(false) + .default_open(true) // Expanded by default .show(ui, |ui| { let channels = self.get_available_channel_names(); if channels.is_empty() { ui.label( - egui::RichText::new("No channels available") - .color(egui::Color32::GRAY), + egui::RichText::new( + "No channels available - load a log file first", + ) + .color(egui::Color32::GRAY), ); } else { + ui.label( + egui::RichText::new("Click to insert into formula:") + .small() + .weak(), + ); egui::ScrollArea::vertical() .id_salt("channel_browser") .max_height(120.0) @@ -136,23 +186,76 @@ impl UltraLogApp { }); } - // Preview section + // Preview section with statistics if let Some(preview_values) = &self.formula_editor_state.preview_values { - ui.add_space(8.0); - ui.separator(); - ui.label(egui::RichText::new("Preview (first 5 values):").strong()); - ui.horizontal(|ui| { - for (i, val) in preview_values.iter().take(5).enumerate() { - if i > 0 { - ui.label(","); - } - ui.label( - egui::RichText::new(format!("{:.2}", val)) - .monospace() - .color(egui::Color32::LIGHT_GREEN), - ); + if !preview_values.is_empty() { + ui.add_space(8.0); + ui.separator(); + + // Calculate stats + let valid_values: Vec = preview_values + .iter() + .copied() + .filter(|v| v.is_finite()) + .collect(); + + if !valid_values.is_empty() { + let min = valid_values.iter().copied().fold(f64::INFINITY, f64::min); + let max = valid_values + .iter() + .copied() + .fold(f64::NEG_INFINITY, f64::max); + let sum: f64 = valid_values.iter().sum(); + let avg = sum / valid_values.len() as f64; + + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Preview").strong()); + ui.add_space(8.0); + + // Stats in a compact row + ui.label(egui::RichText::new("Min:").small().weak()); + ui.label( + egui::RichText::new(format!("{:.2}", min)) + .monospace() + .color(egui::Color32::LIGHT_BLUE), + ); + ui.add_space(8.0); + + ui.label(egui::RichText::new("Avg:").small().weak()); + ui.label( + egui::RichText::new(format!("{:.2}", avg)) + .monospace() + .color(egui::Color32::LIGHT_GREEN), + ); + ui.add_space(8.0); + + ui.label(egui::RichText::new("Max:").small().weak()); + ui.label( + egui::RichText::new(format!("{:.2}", max)) + .monospace() + .color(egui::Color32::from_rgb(255, 180, 100)), + ); + }); + + // Sample values + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Sample:").small().weak()); + for (i, val) in preview_values.iter().take(5).enumerate() { + if i > 0 { + ui.label(egui::RichText::new(",").weak()); + } + ui.label( + egui::RichText::new(format!("{:.2}", val)) + .monospace() + .color(egui::Color32::GRAY), + ); + } + if preview_values.len() > 5 { + ui.label(egui::RichText::new("...").weak()); + } + }); } - }); + } } ui.add_space(16.0); @@ -212,19 +315,20 @@ impl UltraLogApp { Ok(()) => { self.formula_editor_state.validation_error = None; - // Generate preview if we have data + // Generate preview if we have data - get more samples for better stats if let Some(tab_idx) = self.active_tab { let file_idx = self.tabs[tab_idx].file_index; if file_idx < self.files.len() { let file = &self.files[file_idx]; let refs = extract_channel_references(&formula); if let Ok(bindings) = build_channel_bindings(&refs, &available_channels) { + // Get 100 samples for meaningful statistics if let Ok(preview) = generate_preview( &formula, &bindings, &file.log.data, &file.log.times, - 5, + 100, ) { self.formula_editor_state.preview_values = Some(preview); } diff --git a/src/ui/histogram.rs b/src/ui/histogram.rs new file mode 100644 index 0000000..c93ffd0 --- /dev/null +++ b/src/ui/histogram.rs @@ -0,0 +1,1120 @@ +//! Histogram / 2D heatmap view for analyzing channel distributions. +//! +//! This module provides a histogram view where users can visualize +//! relationships between channels as a 2D grid with configurable +//! cell coloring based on average Z-value or hit count. + +use eframe::egui; + +use crate::app::UltraLogApp; +use crate::normalize::sort_channels_by_priority; +use crate::state::{HistogramGridSize, HistogramMode, SelectedHistogramCell}; + +/// Heat map color gradient from blue (low) to red (high) +const HEAT_COLORS: &[[u8; 3]] = &[ + [0, 0, 80], // Dark blue (0.0) + [0, 0, 180], // Blue + [0, 100, 255], // Light blue + [0, 200, 255], // Cyan + [0, 255, 200], // Cyan-green + [0, 255, 100], // Green + [100, 255, 0], // Yellow-green + [200, 255, 0], // Yellow + [255, 200, 0], // Orange + [255, 100, 0], // Red-orange + [255, 0, 0], // Red (1.0) +]; + +/// Margin for axis labels and titles +const AXIS_LABEL_MARGIN_LEFT: f32 = 75.0; +const AXIS_LABEL_MARGIN_BOTTOM: f32 = 45.0; + +/// Height reserved for legend at bottom +const LEGEND_HEIGHT: f32 = 55.0; + +/// Current position indicator color (cyan, matches chart cursor) +const CURSOR_COLOR: egui::Color32 = egui::Color32::from_rgb(0, 255, 255); + +/// Cell highlight color for current position +const CELL_HIGHLIGHT_COLOR: egui::Color32 = egui::Color32::WHITE; + +/// Selected cell highlight color +const SELECTED_CELL_COLOR: egui::Color32 = egui::Color32::from_rgb(255, 165, 0); // Orange + +/// Crosshair color for cursor tracking during playback +const CURSOR_CROSSHAIR_COLOR: egui::Color32 = egui::Color32::from_rgb(128, 128, 128); // Grey + +/// Calculate relative luminance for WCAG contrast ratio +/// Uses the sRGB colorspace formula from WCAG 2.1 +fn calculate_luminance(color: egui::Color32) -> f64 { + let r = linearize_channel(color.r()); + let g = linearize_channel(color.g()); + let b = linearize_channel(color.b()); + 0.2126 * r + 0.7152 * g + 0.0722 * b +} + +/// Linearize an sRGB channel value (0-255) to linear RGB +fn linearize_channel(value: u8) -> f64 { + let v = value as f64 / 255.0; + if v <= 0.03928 { + v / 12.92 + } else { + ((v + 0.055) / 1.055).powf(2.4) + } +} + +/// Calculate WCAG contrast ratio between two colors +fn contrast_ratio(color1: egui::Color32, color2: egui::Color32) -> f64 { + let l1 = calculate_luminance(color1); + let l2 = calculate_luminance(color2); + let (lighter, darker) = if l1 > l2 { (l1, l2) } else { (l2, l1) }; + (lighter + 0.05) / (darker + 0.05) +} + +/// Get the best text color (black or white) for AAA compliance on given background +/// Returns the color that provides the highest contrast ratio +fn get_aaa_text_color(background: egui::Color32) -> egui::Color32 { + let white_contrast = contrast_ratio(egui::Color32::WHITE, background); + let black_contrast = contrast_ratio(egui::Color32::BLACK, background); + + // Choose whichever provides better contrast + // AAA requires 7:1 for normal text, but we pick the better option regardless + if white_contrast >= black_contrast { + egui::Color32::WHITE + } else { + egui::Color32::BLACK + } +} + +impl UltraLogApp { + /// Main entry point: render the histogram view + pub fn render_histogram_view(&mut self, ui: &mut egui::Ui) { + if self.active_tab.is_none() || self.files.is_empty() { + ui.centered_and_justified(|ui| { + ui.label( + egui::RichText::new("Load a log file to use histogram") + .size(self.scaled_font(20.0)) + .color(egui::Color32::GRAY), + ); + }); + return; + } + + // Render tab bar + self.render_tab_bar(ui); + ui.add_space(10.0); + + // Render axis selectors and mode toggle + self.render_histogram_controls(ui); + ui.add_space(8.0); + + // Render the histogram grid + self.render_histogram_grid(ui); + } + + /// Render the control panel with axis selectors, mode toggle, and grid size + fn render_histogram_controls(&mut self, ui: &mut egui::Ui) { + let Some(tab_idx) = self.active_tab else { + return; + }; + let file_idx = self.tabs[tab_idx].file_index; + + if file_idx >= self.files.len() { + return; + } + + let file = &self.files[file_idx]; + + // Sort channels for dropdown + let sorted_channels = sort_channels_by_priority( + file.log.channels.len(), + |idx| file.log.channels[idx].name(), + self.field_normalization, + Some(&self.custom_normalizations), + ); + + // Get current selections + let config = &self.tabs[tab_idx].histogram_state.config; + let current_x = config.x_channel; + let current_y = config.y_channel; + let current_z = config.z_channel; + let current_mode = config.mode; + let current_grid_size = config.grid_size; + + // Build channel name lookup + let channel_names: std::collections::HashMap = sorted_channels + .iter() + .map(|(idx, name, _)| (*idx, name.clone())) + .collect(); + + // Track selections for deferred updates + let mut new_x: Option = None; + let mut new_y: Option = None; + let mut new_z: Option = None; + let mut new_mode: Option = None; + let mut new_grid_size: Option = None; + + // Pre-compute scaled font sizes + let font_14 = self.scaled_font(14.0); + let font_15 = self.scaled_font(15.0); + + ui.horizontal(|ui| { + // X Axis selector + ui.label(egui::RichText::new("X Axis:").size(font_15)); + egui::ComboBox::from_id_salt("histogram_x") + .selected_text( + egui::RichText::new( + current_x + .and_then(|i| channel_names.get(&i).map(|n| n.as_str())) + .unwrap_or("Select..."), + ) + .size(font_14), + ) + .width(160.0) + .show_ui(ui, |ui| { + for (idx, name, _) in &sorted_channels { + if ui + .selectable_label( + current_x == Some(*idx), + egui::RichText::new(name).size(font_14), + ) + .clicked() + { + new_x = Some(*idx); + } + } + }); + + ui.add_space(16.0); + + // Y Axis selector + ui.label(egui::RichText::new("Y Axis:").size(font_15)); + egui::ComboBox::from_id_salt("histogram_y") + .selected_text( + egui::RichText::new( + current_y + .and_then(|i| channel_names.get(&i).map(|n| n.as_str())) + .unwrap_or("Select..."), + ) + .size(font_14), + ) + .width(160.0) + .show_ui(ui, |ui| { + for (idx, name, _) in &sorted_channels { + if ui + .selectable_label( + current_y == Some(*idx), + egui::RichText::new(name).size(font_14), + ) + .clicked() + { + new_y = Some(*idx); + } + } + }); + + ui.add_space(16.0); + + // Z Axis selector (only enabled in AverageZ mode) + let z_enabled = current_mode == HistogramMode::AverageZ; + ui.add_enabled_ui(z_enabled, |ui| { + ui.label(egui::RichText::new("Z Axis:").size(font_15)); + egui::ComboBox::from_id_salt("histogram_z") + .selected_text( + egui::RichText::new( + current_z + .and_then(|i| channel_names.get(&i).map(|n| n.as_str())) + .unwrap_or("Select..."), + ) + .size(font_14), + ) + .width(160.0) + .show_ui(ui, |ui| { + for (idx, name, _) in &sorted_channels { + if ui + .selectable_label( + current_z == Some(*idx), + egui::RichText::new(name).size(font_14), + ) + .clicked() + { + new_z = Some(*idx); + } + } + }); + }); + + ui.add_space(20.0); + + // Grid size selector + ui.label(egui::RichText::new("Grid:").size(font_15)); + egui::ComboBox::from_id_salt("histogram_grid_size") + .selected_text(egui::RichText::new(current_grid_size.name()).size(font_14)) + .width(80.0) + .show_ui(ui, |ui| { + let sizes = [ + HistogramGridSize::Size16, + HistogramGridSize::Size32, + HistogramGridSize::Size64, + ]; + for size in sizes { + if ui + .selectable_label( + current_grid_size == size, + egui::RichText::new(size.name()).size(font_14), + ) + .clicked() + { + new_grid_size = Some(size); + } + } + }); + + ui.add_space(20.0); + + // Mode toggle + ui.label(egui::RichText::new("Mode:").size(font_15)); + if ui + .selectable_label( + current_mode == HistogramMode::AverageZ, + egui::RichText::new("Average Z").size(font_14), + ) + .clicked() + { + new_mode = Some(HistogramMode::AverageZ); + } + if ui + .selectable_label( + current_mode == HistogramMode::HitCount, + egui::RichText::new("Hit Count").size(font_14), + ) + .clicked() + { + new_mode = Some(HistogramMode::HitCount); + } + }); + + // Apply deferred updates + let config = &mut self.tabs[tab_idx].histogram_state.config; + if let Some(x) = new_x { + config.x_channel = Some(x); + config.selected_cell = None; // Clear selection on axis change + } + if let Some(y) = new_y { + config.y_channel = Some(y); + config.selected_cell = None; + } + if let Some(z) = new_z { + config.z_channel = Some(z); + config.selected_cell = None; + } + if let Some(mode) = new_mode { + config.mode = mode; + config.selected_cell = None; + } + if let Some(size) = new_grid_size { + config.grid_size = size; + config.selected_cell = None; + } + } + + /// Render the histogram grid with current position indicator + fn render_histogram_grid(&mut self, ui: &mut egui::Ui) { + let Some(tab_idx) = self.active_tab else { + return; + }; + + let config = &self.tabs[tab_idx].histogram_state.config; + let file_idx = self.tabs[tab_idx].file_index; + let mode = config.mode; + let grid_size = config.grid_size.size(); + + // Pre-compute scaled font sizes for use in closures + let font_10 = self.scaled_font(10.0); + let font_12 = self.scaled_font(12.0); + let font_13 = self.scaled_font(13.0); + let font_16 = self.scaled_font(16.0); + + // Check valid axis selections + let (x_idx, y_idx) = match (config.x_channel, config.y_channel) { + (Some(x), Some(y)) => (x, y), + _ => { + let available = ui.available_size(); + let (rect, _) = ui.allocate_exact_size(available, egui::Sense::hover()); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + "Select X and Y axes", + egui::FontId::proportional(font_16), + egui::Color32::GRAY, + ); + return; + } + }; + + // Check Z axis for AverageZ mode + let z_idx = if mode == HistogramMode::AverageZ { + match config.z_channel { + Some(z) => Some(z), + None => { + let available = ui.available_size(); + let (rect, _) = ui.allocate_exact_size(available, egui::Sense::hover()); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + "Select Z axis for Average mode", + egui::FontId::proportional(font_16), + egui::Color32::GRAY, + ); + return; + } + } + } else { + None + }; + + if file_idx >= self.files.len() { + return; + } + + let file = &self.files[file_idx]; + let x_data = file.log.get_channel_data(x_idx); + let y_data = file.log.get_channel_data(y_idx); + let z_data = z_idx.map(|z| file.log.get_channel_data(z)); + + if x_data.is_empty() || y_data.is_empty() || x_data.len() != y_data.len() { + return; + } + + // Calculate data bounds + let x_min = x_data.iter().cloned().fold(f64::MAX, f64::min); + let x_max = x_data.iter().cloned().fold(f64::MIN, f64::max); + let y_min = y_data.iter().cloned().fold(f64::MAX, f64::min); + let y_max = y_data.iter().cloned().fold(f64::MIN, f64::max); + + let x_range = if (x_max - x_min).abs() < f64::EPSILON { + 1.0 + } else { + x_max - x_min + }; + let y_range = if (y_max - y_min).abs() < f64::EPSILON { + 1.0 + } else { + y_max - y_min + }; + + // Build histogram grid with full statistics + let mut hit_counts = vec![vec![0u32; grid_size]; grid_size]; + let mut z_sums = vec![vec![0.0f64; grid_size]; grid_size]; + let mut z_sum_sq = vec![vec![0.0f64; grid_size]; grid_size]; + let mut z_mins = vec![vec![f64::MAX; grid_size]; grid_size]; + let mut z_maxs = vec![vec![f64::MIN; grid_size]; grid_size]; + + for i in 0..x_data.len() { + let x_bin = (((x_data[i] - x_min) / x_range) * (grid_size - 1) as f64).round() as usize; + let y_bin = (((y_data[i] - y_min) / y_range) * (grid_size - 1) as f64).round() as usize; + let x_bin = x_bin.min(grid_size - 1); + let y_bin = y_bin.min(grid_size - 1); + + hit_counts[y_bin][x_bin] += 1; + if let Some(ref z) = z_data { + let z_val = z[i]; + z_sums[y_bin][x_bin] += z_val; + z_sum_sq[y_bin][x_bin] += z_val * z_val; + z_mins[y_bin][x_bin] = z_mins[y_bin][x_bin].min(z_val); + z_maxs[y_bin][x_bin] = z_maxs[y_bin][x_bin].max(z_val); + } + } + + // Calculate cell values and find min/max for color scaling + let mut cell_values = vec![vec![None::; grid_size]; grid_size]; + let mut min_value: f64 = f64::MAX; + let mut max_value: f64 = f64::MIN; + + for y_bin in 0..grid_size { + for x_bin in 0..grid_size { + let hits = hit_counts[y_bin][x_bin]; + if hits > 0 { + let value = match mode { + HistogramMode::HitCount => hits as f64, + HistogramMode::AverageZ => z_sums[y_bin][x_bin] / hits as f64, + }; + cell_values[y_bin][x_bin] = Some(value); + min_value = min_value.min(value); + max_value = max_value.max(value); + } + } + } + + // Handle case where all values are the same + let value_range = if (max_value - min_value).abs() < f64::EPSILON { + 1.0 + } else { + max_value - min_value + }; + + // Allocate space for the grid + let available = ui.available_size(); + let chart_size = egui::vec2(available.x, (available.y - LEGEND_HEIGHT).max(200.0)); + let (full_rect, response) = ui.allocate_exact_size(chart_size, egui::Sense::click()); + + // Create inner plot rect with margins + let plot_rect = egui::Rect::from_min_max( + egui::pos2(full_rect.left() + AXIS_LABEL_MARGIN_LEFT, full_rect.top()), + egui::pos2( + full_rect.right(), + full_rect.bottom() - AXIS_LABEL_MARGIN_BOTTOM, + ), + ); + + let painter = ui.painter_at(full_rect); + painter.rect_filled(plot_rect, 0.0, egui::Color32::BLACK); + + let cell_width = plot_rect.width() / grid_size as f32; + let cell_height = plot_rect.height() / grid_size as f32; + + // Get selected cell for highlighting + let selected_cell = self.tabs[tab_idx] + .histogram_state + .config + .selected_cell + .clone(); + + // Draw grid cells with values + for y_bin in 0..grid_size { + for x_bin in 0..grid_size { + let cell_x = plot_rect.left() + x_bin as f32 * cell_width; + let cell_y = plot_rect.bottom() - (y_bin + 1) as f32 * cell_height; + let cell_rect = egui::Rect::from_min_size( + egui::pos2(cell_x, cell_y), + egui::vec2(cell_width, cell_height), + ); + + if let Some(value) = cell_values[y_bin][x_bin] { + // Normalize to 0-1 for color scaling + let normalized = if mode == HistogramMode::HitCount && max_value > 1.0 { + (value.ln() / max_value.ln()).clamp(0.0, 1.0) + } else { + ((value - min_value) / value_range).clamp(0.0, 1.0) + }; + let color = Self::get_histogram_color(normalized); + + painter.rect_filled(cell_rect, 0.0, color); + + // Draw value text in center of cell (only if cell is large enough) + if cell_width > 25.0 && cell_height > 18.0 { + let text = if mode == HistogramMode::HitCount { + format!("{}", hit_counts[y_bin][x_bin]) + } else { + format!("{:.1}", value) + }; + + // Choose text color for AAA contrast compliance + let text_color = get_aaa_text_color(color); + + let base_font_size = if grid_size <= 16 { + 11.0 + } else if grid_size <= 32 { + 9.0 + } else { + 7.0 + }; + let cell_font_size = self.scaled_font(base_font_size); + + painter.text( + cell_rect.center(), + egui::Align2::CENTER_CENTER, + text, + egui::FontId::proportional(cell_font_size), + text_color, + ); + } + } + } + } + + // Draw grid lines + let grid_color = egui::Color32::from_rgb(60, 60, 60); + for i in 0..=grid_size { + let x = plot_rect.left() + i as f32 * cell_width; + let y = plot_rect.top() + i as f32 * cell_height; + painter.line_segment( + [ + egui::pos2(x, plot_rect.top()), + egui::pos2(x, plot_rect.bottom()), + ], + egui::Stroke::new(0.5, grid_color), + ); + painter.line_segment( + [ + egui::pos2(plot_rect.left(), y), + egui::pos2(plot_rect.right(), y), + ], + egui::Stroke::new(0.5, grid_color), + ); + } + + // Get channel names for axis labels + let x_channel_name = file.log.channels[x_idx].name(); + let y_channel_name = file.log.channels[y_idx].name(); + + // Draw axis labels + let text_color = egui::Color32::from_rgb(200, 200, 200); + let axis_title_color = egui::Color32::from_rgb(255, 255, 255); + + // Y axis value labels + for i in 0..=4 { + let t = i as f64 / 4.0; + let value = y_min + t * y_range; + let y_pos = plot_rect.bottom() - t as f32 * plot_rect.height(); + painter.text( + egui::pos2(plot_rect.left() - 8.0, y_pos), + egui::Align2::RIGHT_CENTER, + format!("{:.1}", value), + egui::FontId::proportional(font_10), + text_color, + ); + } + + // Y axis title (rotated text simulation - draw vertically) + let y_title_x = full_rect.left() + 12.0; + let y_title_y = plot_rect.center().y; + painter.text( + egui::pos2(y_title_x, y_title_y), + egui::Align2::CENTER_CENTER, + &y_channel_name, + egui::FontId::proportional(font_13), + axis_title_color, + ); + + // X axis value labels + for i in 0..=4 { + let t = i as f64 / 4.0; + let value = x_min + t * x_range; + let x_pos = plot_rect.left() + t as f32 * plot_rect.width(); + painter.text( + egui::pos2(x_pos, plot_rect.bottom() + 5.0), + egui::Align2::CENTER_TOP, + format!("{:.0}", value), + egui::FontId::proportional(font_10), + text_color, + ); + } + + // X axis title + let x_title_x = plot_rect.center().x; + let x_title_y = full_rect.bottom() - 8.0; + painter.text( + egui::pos2(x_title_x, x_title_y), + egui::Align2::CENTER_CENTER, + &x_channel_name, + egui::FontId::proportional(font_13), + axis_title_color, + ); + + // Draw selected cell highlight + if let Some(ref sel) = selected_cell { + if sel.x_bin < grid_size && sel.y_bin < grid_size { + let sel_x = plot_rect.left() + sel.x_bin as f32 * cell_width; + let sel_y = plot_rect.bottom() - (sel.y_bin + 1) as f32 * cell_height; + let sel_rect = egui::Rect::from_min_size( + egui::pos2(sel_x, sel_y), + egui::vec2(cell_width, cell_height), + ); + let stroke = egui::Stroke::new(3.0, SELECTED_CELL_COLOR); + painter.line_segment([sel_rect.left_top(), sel_rect.right_top()], stroke); + painter.line_segment([sel_rect.left_bottom(), sel_rect.right_bottom()], stroke); + painter.line_segment([sel_rect.left_top(), sel_rect.left_bottom()], stroke); + painter.line_segment([sel_rect.right_top(), sel_rect.right_bottom()], stroke); + } + } + + // Draw current position indicator (cursor time) + if let Some(cursor_record) = self.get_cursor_record() { + if cursor_record < x_data.len() { + let cursor_x = x_data[cursor_record]; + let cursor_y = y_data[cursor_record]; + + let rel_x = ((cursor_x - x_min) / x_range) as f32; + let rel_y = ((cursor_y - y_min) / y_range) as f32; + + if (0.0..=1.0).contains(&rel_x) && (0.0..=1.0).contains(&rel_y) { + let pos_x = plot_rect.left() + rel_x * plot_rect.width(); + let pos_y = plot_rect.bottom() - rel_y * plot_rect.height(); + + // Draw grey crosshairs tracking the cursor position + painter.line_segment( + [ + egui::pos2(pos_x, plot_rect.top()), + egui::pos2(pos_x, plot_rect.bottom()), + ], + egui::Stroke::new(1.0, CURSOR_CROSSHAIR_COLOR), + ); + painter.line_segment( + [ + egui::pos2(plot_rect.left(), pos_y), + egui::pos2(plot_rect.right(), pos_y), + ], + egui::Stroke::new(1.0, CURSOR_CROSSHAIR_COLOR), + ); + + // Highlight the cell containing the cursor + let cell_x_bin = (rel_x * (grid_size - 1) as f32).round() as usize; + let cell_y_bin = (rel_y * (grid_size - 1) as f32).round() as usize; + let cell_x_bin = cell_x_bin.min(grid_size - 1); + let cell_y_bin = cell_y_bin.min(grid_size - 1); + + let highlight_x = plot_rect.left() + cell_x_bin as f32 * cell_width; + let highlight_y = plot_rect.bottom() - (cell_y_bin + 1) as f32 * cell_height; + + let highlight_rect = egui::Rect::from_min_size( + egui::pos2(highlight_x, highlight_y), + egui::vec2(cell_width, cell_height), + ); + let stroke = egui::Stroke::new(2.0, CELL_HIGHLIGHT_COLOR); + painter.line_segment( + [highlight_rect.left_top(), highlight_rect.right_top()], + stroke, + ); + painter.line_segment( + [highlight_rect.left_bottom(), highlight_rect.right_bottom()], + stroke, + ); + painter.line_segment( + [highlight_rect.left_top(), highlight_rect.left_bottom()], + stroke, + ); + painter.line_segment( + [highlight_rect.right_top(), highlight_rect.right_bottom()], + stroke, + ); + + // Draw circle at exact position + painter.circle_filled(egui::pos2(pos_x, pos_y), 6.0, CURSOR_COLOR); + painter.circle_stroke( + egui::pos2(pos_x, pos_y), + 6.0, + egui::Stroke::new(2.0, egui::Color32::WHITE), + ); + } + } + } + + // Handle hover tooltip + if let Some(pos) = response.hover_pos() { + if plot_rect.contains(pos) { + let rel_x = (pos.x - plot_rect.left()) / plot_rect.width(); + let rel_y = 1.0 - (pos.y - plot_rect.top()) / plot_rect.height(); + + if (0.0..=1.0).contains(&rel_x) && (0.0..=1.0).contains(&rel_y) { + let x_val = x_min + rel_x as f64 * x_range; + let y_val = y_min + rel_y as f64 * y_range; + + let x_bin = (rel_x * (grid_size - 1) as f32).round() as usize; + let y_bin = (rel_y * (grid_size - 1) as f32).round() as usize; + let x_bin = x_bin.min(grid_size - 1); + let y_bin = y_bin.min(grid_size - 1); + + let hits = hit_counts[y_bin][x_bin]; + let cell_value = cell_values[y_bin][x_bin]; + + let tooltip_text = match mode { + HistogramMode::HitCount => { + format!("X: {:.1}\nY: {:.1}\nHits: {}", x_val, y_val, hits) + } + HistogramMode::AverageZ => { + let avg = cell_value + .map(|v| format!("{:.2}", v)) + .unwrap_or("-".to_string()); + format!( + "X: {:.1}\nY: {:.1}\nAvg Z: {}\nHits: {}", + x_val, y_val, avg, hits + ) + } + }; + + // Draw hover crosshairs + let hover_x = plot_rect.left() + rel_x * plot_rect.width(); + let hover_y = plot_rect.top() + (1.0 - rel_y) * plot_rect.height(); + let crosshair_color = egui::Color32::from_rgb(255, 255, 0); + + painter.line_segment( + [ + egui::pos2(hover_x, plot_rect.top()), + egui::pos2(hover_x, plot_rect.bottom()), + ], + egui::Stroke::new(1.0, crosshair_color), + ); + painter.line_segment( + [ + egui::pos2(plot_rect.left(), hover_y), + egui::pos2(plot_rect.right(), hover_y), + ], + egui::Stroke::new(1.0, crosshair_color), + ); + + painter.text( + egui::pos2(plot_rect.right() - 10.0, plot_rect.top() + 15.0), + egui::Align2::RIGHT_TOP, + tooltip_text, + egui::FontId::proportional(font_12), + egui::Color32::WHITE, + ); + } + } + } + + // Handle click to select cell + if response.clicked() { + if let Some(pos) = response.interact_pointer_pos() { + if plot_rect.contains(pos) { + let rel_x = (pos.x - plot_rect.left()) / plot_rect.width(); + let rel_y = 1.0 - (pos.y - plot_rect.top()) / plot_rect.height(); + + if (0.0..=1.0).contains(&rel_x) && (0.0..=1.0).contains(&rel_y) { + let x_bin = (rel_x * (grid_size - 1) as f32).round() as usize; + let y_bin = (rel_y * (grid_size - 1) as f32).round() as usize; + let x_bin = x_bin.min(grid_size - 1); + let y_bin = y_bin.min(grid_size - 1); + + let hits = hit_counts[y_bin][x_bin]; + + // Calculate cell value ranges + let bin_width_x = x_range / grid_size as f64; + let bin_width_y = y_range / grid_size as f64; + let cell_x_min = x_min + x_bin as f64 * bin_width_x; + let cell_x_max = cell_x_min + bin_width_x; + let cell_y_min = y_min + y_bin as f64 * bin_width_y; + let cell_y_max = cell_y_min + bin_width_y; + + // Calculate statistics + let mean = if hits > 0 { + z_sums[y_bin][x_bin] / hits as f64 + } else { + 0.0 + }; + + let variance = if hits > 1 { + let n = hits as f64; + (z_sum_sq[y_bin][x_bin] - (z_sums[y_bin][x_bin].powi(2) / n)) + / (n - 1.0) + } else { + 0.0 + }; + + let std_dev = variance.sqrt(); + let cell_weight = z_sums[y_bin][x_bin]; + + let minimum = if hits > 0 && z_mins[y_bin][x_bin] != f64::MAX { + z_mins[y_bin][x_bin] + } else { + 0.0 + }; + + let maximum = if hits > 0 && z_maxs[y_bin][x_bin] != f64::MIN { + z_maxs[y_bin][x_bin] + } else { + 0.0 + }; + + let selected = SelectedHistogramCell { + x_bin, + y_bin, + x_range: (cell_x_min, cell_x_max), + y_range: (cell_y_min, cell_y_max), + hit_count: hits, + cell_weight, + variance, + std_dev, + minimum, + mean, + maximum, + }; + + self.tabs[tab_idx].histogram_state.config.selected_cell = Some(selected); + } + } + } + } + + // Render legend with selected cell info + ui.add_space(8.0); + let selected_cell = self.tabs[tab_idx] + .histogram_state + .config + .selected_cell + .as_ref(); + self.render_histogram_legend(ui, min_value, max_value, mode, selected_cell); + } + + /// Get a color from the heat map gradient based on normalized value (0-1) + fn get_histogram_color(normalized: f64) -> egui::Color32 { + let t = normalized.clamp(0.0, 1.0); + let scaled = t * (HEAT_COLORS.len() - 1) as f64; + let idx = scaled.floor() as usize; + let frac = scaled - idx as f64; + + if idx >= HEAT_COLORS.len() - 1 { + let c = HEAT_COLORS[HEAT_COLORS.len() - 1]; + return egui::Color32::from_rgb(c[0], c[1], c[2]); + } + + let c1 = HEAT_COLORS[idx]; + let c2 = HEAT_COLORS[idx + 1]; + + let r = (c1[0] as f64 + (c2[0] as f64 - c1[0] as f64) * frac) as u8; + let g = (c1[1] as f64 + (c2[1] as f64 - c1[1] as f64) * frac) as u8; + let b = (c1[2] as f64 + (c2[2] as f64 - c1[2] as f64) * frac) as u8; + + egui::Color32::from_rgb(r, g, b) + } + + /// Render the legend with color scale and cell statistics + fn render_histogram_legend( + &self, + ui: &mut egui::Ui, + min_value: f64, + max_value: f64, + mode: HistogramMode, + selected_cell: Option<&SelectedHistogramCell>, + ) { + let font_12 = self.scaled_font(12.0); + let font_13 = self.scaled_font(13.0); + + ui.horizontal(|ui| { + ui.add_space(4.0); + + // Color scale legend + egui::Frame::NONE + .fill(egui::Color32::from_rgba_unmultiplied(30, 30, 30, 220)) + .corner_radius(4) + .inner_margin(8.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + let label = match mode { + HistogramMode::HitCount => "Hits:", + HistogramMode::AverageZ => "Value:", + }; + ui.label( + egui::RichText::new(label) + .size(font_13) + .color(egui::Color32::WHITE), + ); + + // Color gradient bar + let (rect, _) = + ui.allocate_exact_size(egui::vec2(120.0, 18.0), egui::Sense::hover()); + + let painter = ui.painter(); + let steps = 30; + let step_width = rect.width() / steps as f32; + + for i in 0..steps { + let t = i as f64 / steps as f64; + let color = Self::get_histogram_color(t); + let x = rect.left() + i as f32 * step_width; + painter.rect_filled( + egui::Rect::from_min_size( + egui::pos2(x, rect.top()), + egui::vec2(step_width + 1.0, rect.height()), + ), + 0.0, + color, + ); + } + + ui.add_space(6.0); + let range_text = if mode == HistogramMode::HitCount { + format!("0-{:.0}", max_value) + } else { + format!("{:.1}-{:.1}", min_value, max_value) + }; + ui.label( + egui::RichText::new(range_text) + .size(font_13) + .color(egui::Color32::WHITE), + ); + }); + }); + + ui.add_space(16.0); + + // Cell statistics panel (only shown when a cell is selected) + if let Some(cell) = selected_cell { + egui::Frame::NONE + .fill(egui::Color32::from_rgba_unmultiplied(30, 30, 30, 220)) + .corner_radius(4) + .inner_margin(8.0) + .stroke(egui::Stroke::new(1.0, SELECTED_CELL_COLOR)) + .show(ui, |ui| { + ui.horizontal(|ui| { + // Cell identifier + ui.label( + egui::RichText::new(format!( + "Cell [{}, {}]", + cell.x_bin, cell.y_bin + )) + .size(font_13) + .color(SELECTED_CELL_COLOR), + ); + ui.add_space(12.0); + + // Key statistics inline + let stat_color = egui::Color32::from_rgb(180, 180, 180); + let value_color = egui::Color32::WHITE; + + ui.label(egui::RichText::new("Hits:").size(font_12).color(stat_color)); + ui.label( + egui::RichText::new(format!("{}", cell.hit_count)) + .size(font_12) + .color(value_color), + ); + ui.add_space(8.0); + + ui.label(egui::RichText::new("Mean:").size(font_12).color(stat_color)); + ui.label( + egui::RichText::new(format!("{:.2}", cell.mean)) + .size(font_12) + .color(value_color), + ); + ui.add_space(8.0); + + ui.label(egui::RichText::new("Min:").size(font_12).color(stat_color)); + ui.label( + egui::RichText::new(format!("{:.2}", cell.minimum)) + .size(font_12) + .color(value_color), + ); + ui.add_space(8.0); + + ui.label(egui::RichText::new("Max:").size(font_12).color(stat_color)); + ui.label( + egui::RichText::new(format!("{:.2}", cell.maximum)) + .size(font_12) + .color(value_color), + ); + ui.add_space(8.0); + + ui.label(egui::RichText::new("σ:").size(font_12).color(stat_color)); + ui.label( + egui::RichText::new(format!("{:.2}", cell.std_dev)) + .size(font_12) + .color(value_color), + ); + }); + }); + } else { + // Hint when no cell is selected + egui::Frame::NONE + .fill(egui::Color32::from_rgba_unmultiplied(30, 30, 30, 220)) + .corner_radius(4) + .inner_margin(8.0) + .show(ui, |ui| { + ui.label( + egui::RichText::new("Click a cell to view statistics") + .size(font_12) + .italics() + .color(egui::Color32::GRAY), + ); + }); + } + }); + } + + /// Render histogram cell statistics in sidebar + pub fn render_histogram_stats(&self, ui: &mut egui::Ui) { + let Some(tab_idx) = self.active_tab else { + return; + }; + + let selected = &self.tabs[tab_idx].histogram_state.config.selected_cell; + + // Pre-compute scaled font sizes + let font_12 = self.scaled_font(12.0); + let font_13 = self.scaled_font(13.0); + let font_14 = self.scaled_font(14.0); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(5.0); + + ui.label( + egui::RichText::new("Cell Statistics") + .size(font_14) + .strong() + .color(egui::Color32::WHITE), + ); + + ui.add_space(5.0); + + if let Some(cell) = selected { + egui::Frame::NONE + .fill(egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200)) + .corner_radius(4) + .inner_margin(8.0) + .stroke(egui::Stroke::new(1.0, SELECTED_CELL_COLOR)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(format!("Cell [{}, {}]", cell.x_bin, cell.y_bin)) + .size(font_13) + .color(SELECTED_CELL_COLOR), + ); + }); + + ui.add_space(4.0); + + let stats = [ + ( + "X Range", + format!("{:.2} - {:.2}", cell.x_range.0, cell.x_range.1), + ), + ( + "Y Range", + format!("{:.2} - {:.2}", cell.y_range.0, cell.y_range.1), + ), + ("Hit Count", format!("{}", cell.hit_count)), + ("Cell Weight", format!("{:.4}", cell.cell_weight)), + ("Mean", format!("{:.4}", cell.mean)), + ("Minimum", format!("{:.4}", cell.minimum)), + ("Maximum", format!("{:.4}", cell.maximum)), + ("Variance", format!("{:.4}", cell.variance)), + ("Std Dev", format!("{:.4}", cell.std_dev)), + ]; + + for (label, value) in stats { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(format!("{}:", label)) + .size(font_12) + .color(egui::Color32::from_rgb(180, 180, 180)), + ); + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + ui.label( + egui::RichText::new(&value) + .size(font_12) + .color(egui::Color32::WHITE), + ); + }, + ); + }); + } + + ui.add_space(4.0); + + if ui.small_button("Clear Selection").clicked() { + // We can't mutate here, set a flag instead + } + }); + } else { + ui.label( + egui::RichText::new("Click a cell to view statistics") + .size(font_12) + .italics() + .color(egui::Color32::GRAY), + ); + } + } +} diff --git a/src/ui/menu.rs b/src/ui/menu.rs index 30a6b41..1bf10b7 100644 --- a/src/ui/menu.rs +++ b/src/ui/menu.rs @@ -1,23 +1,24 @@ -//! Menu bar UI components (File, View, Units, Help menus). +//! Menu bar UI components (File, View, Help menus). +//! +//! Simplified menu structure - settings moved to Settings panel. use eframe::egui; -use crate::analytics; use crate::app::UltraLogApp; -use crate::state::LoadingState; -use crate::units::{ - AccelerationUnit, DistanceUnit, FlowUnit, FuelEconomyUnit, PressureUnit, SpeedUnit, - TemperatureUnit, VolumeUnit, -}; +use crate::state::{ActivePanel, ActiveTool, LoadingState}; impl UltraLogApp { /// Render the application menu bar pub fn render_menu_bar(&mut self, ui: &mut egui::Ui) { + // Pre-compute scaled font sizes for use in closures + let font_14 = self.scaled_font(14.0); + let font_15 = self.scaled_font(15.0); + egui::MenuBar::new().ui(ui, |ui| { // Increase font size for menu items ui.style_mut() .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(15.0)); + .insert(egui::TextStyle::Button, egui::FontId::proportional(font_15)); // File menu ui.menu_button("File", |ui| { @@ -26,16 +27,17 @@ impl UltraLogApp { // Increase font size for dropdown items ui.style_mut() .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(14.0)); + .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); ui.style_mut() .text_styles - .insert(egui::TextStyle::Body, egui::FontId::proportional(14.0)); + .insert(egui::TextStyle::Body, egui::FontId::proportional(font_14)); let is_loading = matches!(self.loading_state, LoadingState::Loading(_)); // Open file option if ui - .add_enabled(!is_loading, egui::Button::new("📂 Open Log File...")) + .add_enabled(!is_loading, egui::Button::new("Open Log File...")) + .on_hover_text("⌘O") .clicked() { if let Some(path) = rfd::FileDialog::new() @@ -49,400 +51,167 @@ impl UltraLogApp { ui.separator(); - // Export submenu + // Close current tab + let has_tabs = !self.tabs.is_empty(); + if ui + .add_enabled(has_tabs, egui::Button::new("Close Tab")) + .on_hover_text("⌘W") + .clicked() + { + if let Some(tab_idx) = self.active_tab { + self.close_tab(tab_idx); + } + ui.close(); + } + + ui.separator(); + + // Export submenu - context-aware based on active tool let has_chart_data = !self.files.is_empty() && !self.get_selected_channels().is_empty(); - ui.add_enabled_ui(has_chart_data, |ui| { - ui.menu_button("📤 Export", |ui| { - // Increase font size for submenu items + let has_histogram_data = !self.files.is_empty() + && self.active_tool == ActiveTool::Histogram + && self.active_tab.is_some() + && { + let tab_idx = self.active_tab.unwrap(); + let config = &self.tabs[tab_idx].histogram_state.config; + config.x_channel.is_some() && config.y_channel.is_some() + }; + + let can_export = has_chart_data || has_histogram_data; + + ui.add_enabled_ui(can_export, |ui| { + ui.menu_button("Export", |ui| { ui.style_mut() .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(14.0)); - if ui.button("Export as PNG...").clicked() { - self.export_chart_png(); - ui.close(); - } - if ui.button("Export as PDF...").clicked() { - self.export_chart_pdf(); - ui.close(); + .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); + + if self.active_tool == ActiveTool::Histogram && has_histogram_data { + if ui.button("Export Histogram as PDF...").clicked() { + self.export_histogram_pdf(); + ui.close(); + } + } else if has_chart_data { + if ui.button("Export as PNG...").clicked() { + self.export_chart_png(); + ui.close(); + } + if ui.button("Export as PDF...").clicked() { + self.export_chart_pdf(); + ui.close(); + } } }); }); }); - // View menu + // View menu - tool modes and panels ui.menu_button("View", |ui| { - ui.set_min_width(180.0); + ui.set_min_width(200.0); - // Increase font size for dropdown items ui.style_mut() .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(14.0)); + .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); ui.style_mut() .text_styles - .insert(egui::TextStyle::Body, egui::FontId::proportional(14.0)); + .insert(egui::TextStyle::Body, egui::FontId::proportional(font_14)); + + // Tool modes + ui.label( + egui::RichText::new("Tool Mode") + .size(font_14) + .color(egui::Color32::GRAY), + ); - // Cursor Tracking toggle if ui - .checkbox(&mut self.cursor_tracking, "🎯 Cursor Tracking") + .radio_value(&mut self.active_tool, ActiveTool::LogViewer, "Log Viewer") + .on_hover_text("⌘1") .clicked() { ui.close(); } - - // Color Blind Mode toggle - let old_color_blind_mode = self.color_blind_mode; if ui - .checkbox(&mut self.color_blind_mode, "👁 Color Blind Mode") + .radio_value( + &mut self.active_tool, + ActiveTool::ScatterPlot, + "Scatter Plots", + ) + .on_hover_text("⌘2") + .clicked() + { + ui.close(); + } + if ui + .radio_value(&mut self.active_tool, ActiveTool::Histogram, "Histogram") + .on_hover_text("⌘3") .clicked() { - if self.color_blind_mode != old_color_blind_mode { - analytics::track_colorblind_mode_toggled(self.color_blind_mode); - } ui.close(); } ui.separator(); - // Field Normalization toggle + // Panel navigation + ui.label( + egui::RichText::new("Side Panel") + .size(font_14) + .color(egui::Color32::GRAY), + ); + if ui - .checkbox(&mut self.field_normalization, "📝 Field Normalization") - .on_hover_text("Standardize channel names across different ECU types") + .radio_value(&mut self.active_panel, ActivePanel::Files, "Files") + .on_hover_text("⌘⇧F") .clicked() { ui.close(); } - - // Edit mappings button - if ui.button(" Edit Mappings...").clicked() { - self.show_normalization_editor = true; + if ui + .radio_value(&mut self.active_panel, ActivePanel::Channels, "Channels") + .on_hover_text("⌘⇧C") + .clicked() + { ui.close(); } - - ui.separator(); - - // Auto-update preference if ui - .checkbox( - &mut self.auto_check_updates, - "🔄 Check for Updates on Startup", - ) - .on_hover_text("Automatically check for new versions when the app starts") + .radio_value(&mut self.active_panel, ActivePanel::Tools, "Tools") + .on_hover_text("⌘⇧T") .clicked() { ui.close(); } - }); - - // Units menu - ui.menu_button("Units", |ui| { - ui.set_min_width(180.0); - - // Increase font size for dropdown items - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(14.0)); - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Body, egui::FontId::proportional(14.0)); - - // Temperature submenu - ui.menu_button("°C Temperature", |ui| { - // Increase font size for submenu items - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(14.0)); - if ui - .radio_value( - &mut self.unit_preferences.temperature, - TemperatureUnit::Celsius, - "Celsius (°C)", - ) - .clicked() - { - ui.close(); - } - if ui - .radio_value( - &mut self.unit_preferences.temperature, - TemperatureUnit::Fahrenheit, - "Fahrenheit (°F)", - ) - .clicked() - { - ui.close(); - } - if ui - .radio_value( - &mut self.unit_preferences.temperature, - TemperatureUnit::Kelvin, - "Kelvin (K)", - ) - .clicked() - { - ui.close(); - } - }); - - // Pressure submenu - ui.menu_button("💨 Pressure", |ui| { - // Increase font size for submenu items - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(14.0)); - if ui - .radio_value( - &mut self.unit_preferences.pressure, - PressureUnit::KPa, - "Kilopascal (kPa)", - ) - .clicked() - { - ui.close(); - } - if ui - .radio_value( - &mut self.unit_preferences.pressure, - PressureUnit::PSI, - "PSI", - ) - .clicked() - { - ui.close(); - } - if ui - .radio_value( - &mut self.unit_preferences.pressure, - PressureUnit::Bar, - "Bar", - ) - .clicked() - { - ui.close(); - } - }); - - // Speed submenu - ui.menu_button("🚗 Speed", |ui| { - // Increase font size for submenu items - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(14.0)); - if ui - .radio_value( - &mut self.unit_preferences.speed, - SpeedUnit::KmH, - "Kilometers/hour (km/h)", - ) - .clicked() - { - ui.close(); - } - if ui - .radio_value( - &mut self.unit_preferences.speed, - SpeedUnit::Mph, - "Miles/hour (mph)", - ) - .clicked() - { - ui.close(); - } - }); - - // Distance submenu - ui.menu_button("📏 Distance", |ui| { - // Increase font size for submenu items - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(14.0)); - if ui - .radio_value( - &mut self.unit_preferences.distance, - DistanceUnit::Kilometers, - "Kilometers (km)", - ) - .clicked() - { - ui.close(); - } - if ui - .radio_value( - &mut self.unit_preferences.distance, - DistanceUnit::Miles, - "Miles (mi)", - ) - .clicked() - { - ui.close(); - } - }); - - ui.separator(); - - // Fuel Economy submenu - ui.menu_button("⛽ Fuel Economy", |ui| { - // Increase font size for submenu items - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(14.0)); - if ui - .radio_value( - &mut self.unit_preferences.fuel_economy, - FuelEconomyUnit::LPer100Km, - "Liters/100km (L/100km)", - ) - .clicked() - { - ui.close(); - } - if ui - .radio_value( - &mut self.unit_preferences.fuel_economy, - FuelEconomyUnit::Mpg, - "Miles/gallon (mpg)", - ) - .clicked() - { - ui.close(); - } - if ui - .radio_value( - &mut self.unit_preferences.fuel_economy, - FuelEconomyUnit::KmPerL, - "Kilometers/liter (km/L)", - ) - .clicked() - { - ui.close(); - } - }); - - // Volume submenu - ui.menu_button("📊 Volume", |ui| { - // Increase font size for submenu items - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(14.0)); - if ui - .radio_value( - &mut self.unit_preferences.volume, - VolumeUnit::Liters, - "Liters (L)", - ) - .clicked() - { - ui.close(); - } - if ui - .radio_value( - &mut self.unit_preferences.volume, - VolumeUnit::Gallons, - "Gallons (gal)", - ) - .clicked() - { - ui.close(); - } - }); - - // Flow submenu - ui.menu_button("💧 Flow Rate", |ui| { - // Increase font size for submenu items - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(14.0)); - if ui - .radio_value( - &mut self.unit_preferences.flow, - FlowUnit::CcPerMin, - "cc/min", - ) - .clicked() - { - ui.close(); - } - if ui - .radio_value(&mut self.unit_preferences.flow, FlowUnit::LbPerHr, "lb/hr") - .clicked() - { - ui.close(); - } - }); - - ui.separator(); - - // Acceleration submenu - ui.menu_button("📈 Acceleration", |ui| { - // Increase font size for submenu items - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(14.0)); - if ui - .radio_value( - &mut self.unit_preferences.acceleration, - AccelerationUnit::MPerS2, - "m/s²", - ) - .clicked() - { - ui.close(); - } - if ui - .radio_value( - &mut self.unit_preferences.acceleration, - AccelerationUnit::G, - "g-force (g)", - ) - .clicked() - { - ui.close(); - } - }); - }); - - // Channels menu - ui.menu_button("Channels", |ui| { - ui.set_min_width(200.0); - - // Increase font size for dropdown items - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(14.0)); - ui.style_mut() - .text_styles - .insert(egui::TextStyle::Body, egui::FontId::proportional(14.0)); - - if ui.button("ƒ(x) Computed Channels...").clicked() { - self.show_computed_channels_manager = true; + if ui + .radio_value(&mut self.active_panel, ActivePanel::Settings, "Settings") + .on_hover_text("⌘,") + .clicked() + { ui.close(); } }); + // Help menu ui.menu_button("Help", |ui| { ui.set_min_width(200.0); - // Increase font size for dropdown items ui.style_mut() .text_styles - .insert(egui::TextStyle::Button, egui::FontId::proportional(14.0)); + .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); ui.style_mut() .text_styles - .insert(egui::TextStyle::Body, egui::FontId::proportional(14.0)); + .insert(egui::TextStyle::Body, egui::FontId::proportional(font_14)); - if ui.button("📖 Documentation").clicked() { + if ui.button("Documentation").clicked() { let _ = open::that("https://github.com/SomethingNew71/UltraLog/wiki"); ui.close(); } - if ui.button("🐛 Report Issue").clicked() { + if ui.button("Report Issue").clicked() { let _ = open::that("https://github.com/SomethingNew71/UltraLog/issues"); ui.close(); } ui.separator(); - if ui.button("💝 Support Development").clicked() { + if ui.button("Support Development").clicked() { let _ = open::that("https://github.com/sponsors/SomethingNew71"); ui.close(); } @@ -456,9 +225,9 @@ impl UltraLogApp { | crate::updater::UpdateState::Downloading ); let button_text = if is_checking { - "🔄 Checking for Updates..." + "Checking for Updates..." } else { - "🔄 Check for Updates" + "Check for Updates" }; if ui diff --git a/src/ui/mod.rs b/src/ui/mod.rs index bf02d77..84ba92a 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,25 +1,50 @@ //! UI rendering modules for the UltraLog application. //! //! This module organizes the various UI components into logical submodules: -//! - `sidebar` - Files panel and view options -//! - `channels` - Channel selection and display +//! +//! ## New Activity Bar Architecture +//! - `activity_bar` - VS Code-style vertical icon strip for panel navigation +//! - `side_panel` - Container that routes to the appropriate panel +//! - `files_panel` - File management, loading, and file list +//! - `channels_panel` - Channel selection (works in all modes) +//! - `tools_panel` - Analysis tools, computed channels, export +//! - `settings_panel` - Consolidated settings (display, units, normalization, updates) +//! +//! ## Core UI Components +//! - `sidebar` - Legacy files panel (being replaced by files_panel) +//! - `channels` - Legacy channel selection (being replaced by channels_panel) //! - `chart` - Main chart rendering and legends //! - `timeline` - Timeline scrubber and playback controls -//! - `menu` - Menu bar (File, Units, Help) +//! - `menu` - Menu bar (File, Edit, View, Help) //! - `toast` - Toast notification system //! - `icons` - Custom icon drawing utilities //! - `export` - Chart export functionality (PNG, PDF) //! - `normalization_editor` - Field normalization customization window -//! - `tool_switcher` - Pill-style tab navigation between tools +//! - `tool_switcher` - Tool mode selection (Log Viewer, Scatter Plot, Histogram) //! - `scatter_plot` - Scatter plot visualization view +//! - `histogram` - Histogram visualization view //! - `tab_bar` - Chrome-style tabs for managing multiple log files //! - `update_dialog` - Auto-update dialog window +//! - `analysis_panel` - Signal analysis tools window +//! - `computed_channels_manager` - Computed channels library manager +//! - `formula_editor` - Formula creation and editing + +// New activity bar architecture +pub mod activity_bar; +pub mod channels_panel; +pub mod files_panel; +pub mod settings_panel; +pub mod side_panel; +pub mod tools_panel; +// Core UI components +pub mod analysis_panel; pub mod channels; pub mod chart; pub mod computed_channels_manager; pub mod export; pub mod formula_editor; +pub mod histogram; pub mod icons; pub mod menu; pub mod normalization_editor; diff --git a/src/ui/scatter_plot.rs b/src/ui/scatter_plot.rs index f06f3c4..0e425c2 100644 --- a/src/ui/scatter_plot.rs +++ b/src/ui/scatter_plot.rs @@ -45,7 +45,7 @@ impl UltraLogApp { ui.centered_and_justified(|ui| { ui.label( egui::RichText::new("Load a log file to use scatter plots") - .size(20.0) + .size(self.scaled_font(20.0)) .color(egui::Color32::GRAY), ); }); @@ -95,7 +95,7 @@ impl UltraLogApp { ui.horizontal(|ui| { ui.label( egui::RichText::new(&title) - .size(16.0) + .size(self.scaled_font(16.0)) .strong() .color(egui::Color32::WHITE), ); @@ -266,6 +266,11 @@ impl UltraLogApp { let file_idx = config.file_index.unwrap_or(self.tabs[tab_idx].file_index); + // Pre-compute scaled font sizes + let font_10 = self.scaled_font(10.0); + let font_11 = self.scaled_font(11.0); + let font_16 = self.scaled_font(16.0); + // Check if we have valid axis selections let (x_idx, y_idx) = match (config.x_channel, config.y_channel) { (Some(x), Some(y)) => (x, y), @@ -277,7 +282,7 @@ impl UltraLogApp { rect.center(), egui::Align2::CENTER_CENTER, "Select X and Y axes", - egui::FontId::proportional(16.0), + egui::FontId::proportional(font_16), egui::Color32::GRAY, ); return; @@ -396,7 +401,7 @@ impl UltraLogApp { egui::pos2(plot_rect.left() - 5.0, y_pos), egui::Align2::RIGHT_CENTER, format!("{:.1}", value), - egui::FontId::proportional(10.0), + egui::FontId::proportional(font_10), text_color, ); @@ -421,7 +426,7 @@ impl UltraLogApp { egui::pos2(x_pos, plot_rect.bottom() + 5.0), egui::Align2::CENTER_TOP, format!("{:.0}", value), - egui::FontId::proportional(10.0), + egui::FontId::proportional(font_10), text_color, ); @@ -510,7 +515,7 @@ impl UltraLogApp { egui::pos2(plot_rect.right() - 10.0, plot_rect.top() + 15.0), egui::Align2::RIGHT_TOP, tooltip_text, - egui::FontId::proportional(11.0), + egui::FontId::proportional(font_11), egui::Color32::WHITE, ); @@ -571,6 +576,10 @@ impl UltraLogApp { return; }; + // Pre-compute scaled font sizes + let font_10 = self.scaled_font(10.0); + let font_11 = self.scaled_font(11.0); + // First, gather the data we need (immutable borrow) let config = if is_left { &self.tabs[tab_idx].scatter_plot_state.left @@ -631,7 +640,7 @@ impl UltraLogApp { ui.horizontal(|ui| { ui.label( egui::RichText::new("Hits:") - .size(11.0) + .size(font_11) .color(egui::Color32::WHITE), ); @@ -660,7 +669,7 @@ impl UltraLogApp { ui.add_space(4.0); ui.label( egui::RichText::new(format!("0-{}", max_hits)) - .size(10.0) + .size(font_10) .color(egui::Color32::WHITE), ); }); @@ -697,7 +706,7 @@ impl UltraLogApp { selected.y_value, selected.hits )) - .size(11.0) + .size(font_11) .color(egui::Color32::WHITE), ); diff --git a/src/ui/settings_panel.rs b/src/ui/settings_panel.rs new file mode 100644 index 0000000..8d8fbc5 --- /dev/null +++ b/src/ui/settings_panel.rs @@ -0,0 +1,460 @@ +//! Settings panel - consolidated settings for display, units, normalization, and updates. +//! +//! This panel provides a single location for all user preferences. + +use eframe::egui; + +use crate::analytics; +use crate::app::UltraLogApp; +use crate::state::FontScale; +use crate::units::{ + AccelerationUnit, DistanceUnit, FlowUnit, FuelEconomyUnit, PressureUnit, SpeedUnit, + TemperatureUnit, VolumeUnit, +}; +use crate::updater::UpdateState; + +impl UltraLogApp { + /// Render the settings panel content (called from side_panel.rs) + pub fn render_settings_panel_content(&mut self, ui: &mut egui::Ui) { + // Display settings section + self.render_display_settings(ui); + + ui.add_space(8.0); + ui.separator(); + ui.add_space(8.0); + + // Field normalization settings + self.render_normalization_settings(ui); + + ui.add_space(8.0); + ui.separator(); + ui.add_space(8.0); + + // Unit preferences + self.render_unit_settings(ui); + + ui.add_space(8.0); + ui.separator(); + ui.add_space(8.0); + + // Update settings + self.render_update_settings(ui); + } + + /// Render display settings section + fn render_display_settings(&mut self, ui: &mut egui::Ui) { + let font_12 = self.scaled_font(12.0); + let font_14 = self.scaled_font(14.0); + + egui::CollapsingHeader::new(egui::RichText::new("🖥 Display").size(font_14).strong()) + .default_open(true) + .show(ui, |ui| { + // Colorblind mode + let old_color_blind_mode = self.color_blind_mode; + ui.checkbox( + &mut self.color_blind_mode, + egui::RichText::new("Color Blind Mode").size(font_14), + ); + if self.color_blind_mode != old_color_blind_mode { + analytics::track_colorblind_mode_toggled(self.color_blind_mode); + } + ui.label( + egui::RichText::new("Use accessible color palette (Wong's palette)") + .size(font_12) + .color(egui::Color32::GRAY), + ); + + ui.add_space(8.0); + + // Font size + ui.label(egui::RichText::new("Font Size:").size(font_14)); + ui.horizontal(|ui| { + ui.selectable_value(&mut self.font_scale, FontScale::Small, "S"); + ui.selectable_value(&mut self.font_scale, FontScale::Medium, "M"); + ui.selectable_value(&mut self.font_scale, FontScale::Large, "L"); + ui.selectable_value(&mut self.font_scale, FontScale::ExtraLarge, "XL"); + }); + + ui.add_space(8.0); + + // Cursor tracking + ui.checkbox( + &mut self.cursor_tracking, + egui::RichText::new("Cursor Tracking").size(font_14), + ); + ui.label( + egui::RichText::new("Keep cursor centered while scrubbing") + .size(font_12) + .color(egui::Color32::GRAY), + ); + + if self.cursor_tracking { + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Window:").size(font_12)); + ui.add( + egui::Slider::new(&mut self.view_window_seconds, 5.0..=120.0) + .suffix("s") + .logarithmic(true) + .text(""), + ); + }); + } + }); + } + + /// Render field normalization settings + fn render_normalization_settings(&mut self, ui: &mut egui::Ui) { + let font_12 = self.scaled_font(12.0); + let font_14 = self.scaled_font(14.0); + + egui::CollapsingHeader::new(egui::RichText::new("📝 Field Names").size(font_14).strong()) + .default_open(true) + .show(ui, |ui| { + ui.checkbox( + &mut self.field_normalization, + egui::RichText::new("Field Normalization").size(font_14), + ); + ui.label( + egui::RichText::new("Standardize channel names across ECU types") + .size(font_12) + .color(egui::Color32::GRAY), + ); + + ui.add_space(8.0); + + // Custom mappings count + let custom_count = self.custom_normalizations.len(); + if custom_count > 0 { + ui.label( + egui::RichText::new(format!("{} custom mappings", custom_count)) + .size(font_12) + .color(egui::Color32::GRAY), + ); + } + + // Edit mappings button + let btn = egui::Frame::NONE + .fill(egui::Color32::from_rgb(60, 60, 60)) + .corner_radius(4) + .inner_margin(egui::vec2(12.0, 6.0)) + .show(ui, |ui| { + ui.label( + egui::RichText::new("Edit Custom Mappings") + .color(egui::Color32::LIGHT_GRAY) + .size(font_14), + ); + }); + + if btn.response.interact(egui::Sense::click()).clicked() { + self.show_normalization_editor = true; + } + + if btn.response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + }); + } + + /// Render unit preferences + fn render_unit_settings(&mut self, ui: &mut egui::Ui) { + let font_12 = self.scaled_font(12.0); + let font_14 = self.scaled_font(14.0); + + egui::CollapsingHeader::new(egui::RichText::new("📐 Units").size(font_14).strong()) + .default_open(true) + .show(ui, |ui| { + ui.label( + egui::RichText::new("Select preferred units for display") + .size(font_12) + .color(egui::Color32::GRAY), + ); + ui.add_space(8.0); + + // Create a grid for unit selections + egui::Grid::new("unit_settings_grid") + .num_columns(2) + .spacing([8.0, 6.0]) + .show(ui, |ui| { + // Temperature + ui.label(egui::RichText::new("Temperature:").size(font_12)); + egui::ComboBox::from_id_salt("temp_unit") + .selected_text(self.unit_preferences.temperature.symbol()) + .width(80.0) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.unit_preferences.temperature, + TemperatureUnit::Celsius, + "°C", + ); + ui.selectable_value( + &mut self.unit_preferences.temperature, + TemperatureUnit::Fahrenheit, + "°F", + ); + ui.selectable_value( + &mut self.unit_preferences.temperature, + TemperatureUnit::Kelvin, + "K", + ); + }); + ui.end_row(); + + // Pressure + ui.label(egui::RichText::new("Pressure:").size(font_12)); + egui::ComboBox::from_id_salt("pressure_unit") + .selected_text(self.unit_preferences.pressure.symbol()) + .width(80.0) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.unit_preferences.pressure, + PressureUnit::KPa, + "kPa", + ); + ui.selectable_value( + &mut self.unit_preferences.pressure, + PressureUnit::PSI, + "psi", + ); + ui.selectable_value( + &mut self.unit_preferences.pressure, + PressureUnit::Bar, + "bar", + ); + }); + ui.end_row(); + + // Speed + ui.label(egui::RichText::new("Speed:").size(font_12)); + egui::ComboBox::from_id_salt("speed_unit") + .selected_text(self.unit_preferences.speed.symbol()) + .width(80.0) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.unit_preferences.speed, + SpeedUnit::KmH, + "km/h", + ); + ui.selectable_value( + &mut self.unit_preferences.speed, + SpeedUnit::Mph, + "mph", + ); + }); + ui.end_row(); + + // Distance + ui.label(egui::RichText::new("Distance:").size(font_12)); + egui::ComboBox::from_id_salt("distance_unit") + .selected_text(self.unit_preferences.distance.symbol()) + .width(80.0) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.unit_preferences.distance, + DistanceUnit::Kilometers, + "km", + ); + ui.selectable_value( + &mut self.unit_preferences.distance, + DistanceUnit::Miles, + "mi", + ); + }); + ui.end_row(); + + // Fuel Economy + ui.label(egui::RichText::new("Fuel Economy:").size(font_12)); + egui::ComboBox::from_id_salt("fuel_unit") + .selected_text(self.unit_preferences.fuel_economy.symbol()) + .width(80.0) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.unit_preferences.fuel_economy, + FuelEconomyUnit::LPer100Km, + "L/100km", + ); + ui.selectable_value( + &mut self.unit_preferences.fuel_economy, + FuelEconomyUnit::Mpg, + "mpg", + ); + ui.selectable_value( + &mut self.unit_preferences.fuel_economy, + FuelEconomyUnit::KmPerL, + "km/L", + ); + }); + ui.end_row(); + + // Volume + ui.label(egui::RichText::new("Volume:").size(font_12)); + egui::ComboBox::from_id_salt("volume_unit") + .selected_text(self.unit_preferences.volume.symbol()) + .width(80.0) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.unit_preferences.volume, + VolumeUnit::Liters, + "L", + ); + ui.selectable_value( + &mut self.unit_preferences.volume, + VolumeUnit::Gallons, + "gal", + ); + }); + ui.end_row(); + + // Flow Rate + ui.label(egui::RichText::new("Flow Rate:").size(font_12)); + egui::ComboBox::from_id_salt("flow_unit") + .selected_text(self.unit_preferences.flow.symbol()) + .width(80.0) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.unit_preferences.flow, + FlowUnit::CcPerMin, + "cc/min", + ); + ui.selectable_value( + &mut self.unit_preferences.flow, + FlowUnit::LbPerHr, + "lb/hr", + ); + }); + ui.end_row(); + + // Acceleration + ui.label(egui::RichText::new("Acceleration:").size(font_12)); + egui::ComboBox::from_id_salt("accel_unit") + .selected_text(self.unit_preferences.acceleration.symbol()) + .width(80.0) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.unit_preferences.acceleration, + AccelerationUnit::MPerS2, + "m/s²", + ); + ui.selectable_value( + &mut self.unit_preferences.acceleration, + AccelerationUnit::G, + "g", + ); + }); + ui.end_row(); + }); + }); + } + + /// Render update settings + fn render_update_settings(&mut self, ui: &mut egui::Ui) { + let font_12 = self.scaled_font(12.0); + let font_14 = self.scaled_font(14.0); + + egui::CollapsingHeader::new(egui::RichText::new("🔄 Updates").size(font_14).strong()) + .default_open(true) + .show(ui, |ui| { + // Auto-check preference + ui.checkbox( + &mut self.auto_check_updates, + egui::RichText::new("Check on startup").size(font_14), + ); + ui.label( + egui::RichText::new("Automatically check for new versions") + .size(font_12) + .color(egui::Color32::GRAY), + ); + + ui.add_space(8.0); + + // Check now button + let is_checking = matches!(self.update_state, UpdateState::Checking); + ui.add_enabled_ui(!is_checking, |ui| { + let btn = egui::Frame::NONE + .fill(egui::Color32::from_rgb(60, 60, 60)) + .corner_radius(4) + .inner_margin(egui::vec2(12.0, 6.0)) + .show(ui, |ui| { + let text = if is_checking { + "Checking..." + } else { + "Check for Updates" + }; + ui.label( + egui::RichText::new(text) + .color(egui::Color32::LIGHT_GRAY) + .size(font_14), + ); + }); + + if !is_checking && btn.response.interact(egui::Sense::click()).clicked() { + self.start_update_check(); + } + + if btn.response.hovered() && !is_checking { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + }); + + ui.add_space(8.0); + + // Version info + let version = env!("CARGO_PKG_VERSION"); + ui.label( + egui::RichText::new(format!("Current version: {}", version)) + .size(font_12) + .color(egui::Color32::GRAY), + ); + + // Show update status + match &self.update_state { + UpdateState::Checking => { + ui.horizontal(|ui| { + ui.spinner(); + ui.label( + egui::RichText::new("Checking...") + .size(font_12) + .color(egui::Color32::GRAY), + ); + }); + } + UpdateState::UpdateAvailable(info) => { + ui.add_space(4.0); + ui.label( + egui::RichText::new(format!( + "✓ Update available: v{}", + info.new_version + )) + .size(font_12) + .color(egui::Color32::from_rgb(150, 200, 150)), + ); + + let view_btn = ui.add( + egui::Label::new( + egui::RichText::new("View Details →") + .size(font_12) + .color(egui::Color32::from_rgb(150, 180, 220)), + ) + .sense(egui::Sense::click()), + ); + + if view_btn.clicked() { + self.show_update_dialog = true; + } + + if view_btn.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + } + UpdateState::Error(msg) => { + ui.label( + egui::RichText::new(format!("⚠ {}", msg)) + .size(font_12) + .color(egui::Color32::from_rgb(200, 150, 100)), + ); + } + _ => {} + } + }); + } +} diff --git a/src/ui/side_panel.rs b/src/ui/side_panel.rs new file mode 100644 index 0000000..671e60a --- /dev/null +++ b/src/ui/side_panel.rs @@ -0,0 +1,35 @@ +//! Side panel container - routes to the appropriate panel based on activity bar selection. + +use eframe::egui; + +use crate::app::UltraLogApp; +use crate::state::ActivePanel; + +/// Default width of the side panel in pixels +pub const SIDE_PANEL_WIDTH: f32 = 280.0; + +/// Minimum width of the side panel +pub const SIDE_PANEL_MIN_WIDTH: f32 = 200.0; + +impl UltraLogApp { + /// Render the side panel content based on the active panel selection + pub fn render_side_panel(&mut self, ui: &mut egui::Ui) { + // Panel header with title + ui.horizontal(|ui| { + ui.heading(self.active_panel.name()); + }); + ui.add_space(8.0); + ui.separator(); + ui.add_space(8.0); + + // Route to the appropriate panel content + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| match self.active_panel { + ActivePanel::Files => self.render_files_panel_content(ui), + ActivePanel::Channels => self.render_channels_panel_content(ui), + ActivePanel::Tools => self.render_tools_panel_content(ui), + ActivePanel::Settings => self.render_settings_panel_content(ui), + }); + } +} diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index efca203..9453cf1 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -66,7 +66,7 @@ impl UltraLogApp { "{} | {} channels | {} points", ecu_name, channel_count, data_count )) - .size(12.0) + .size(self.scaled_font(12.0)) .color(egui::Color32::GRAY), ); }); @@ -98,7 +98,7 @@ impl UltraLogApp { ui.label( egui::RichText::new("+ Add File") .color(egui::Color32::WHITE) - .size(14.0), + .size(self.scaled_font(14.0)), ); }); @@ -163,7 +163,7 @@ impl UltraLogApp { ui.label( egui::RichText::new("Select a file") .color(egui::Color32::WHITE) - .size(14.0), + .size(self.scaled_font(14.0)), ); }); @@ -186,22 +186,26 @@ impl UltraLogApp { ui.add_space(12.0); - ui.label(egui::RichText::new("or").color(text_gray).size(12.0)); + ui.label( + egui::RichText::new("or") + .color(text_gray) + .size(self.scaled_font(12.0)), + ); ui.add_space(8.0); ui.label( egui::RichText::new("Drop file here") .color(egui::Color32::LIGHT_GRAY) - .size(13.0), + .size(self.scaled_font(13.0)), ); ui.add_space(12.0); ui.label( - egui::RichText::new("CSV • LOG • TXT • MLG") + egui::RichText::new("CSV • LOG • TXT • MLG • LLG • XRK • DRK") .color(text_gray) - .size(11.0), + .size(self.scaled_font(11.0)), ); }); }); @@ -209,10 +213,23 @@ impl UltraLogApp { /// Render view options at the bottom of the sidebar fn render_view_options(&mut self, ui: &mut egui::Ui) { + // Pre-compute scaled font sizes + let font_12 = self.scaled_font(12.0); + let font_14 = self.scaled_font(14.0); + let font_18 = self.scaled_font(18.0); + ui.with_layout(egui::Layout::bottom_up(egui::Align::LEFT), |ui| { // Reverse order since we're bottom-up ui.add_space(10.0); + // Show histogram stats when in Histogram mode + if !self.files.is_empty() && self.active_tool == ActiveTool::Histogram { + self.render_histogram_stats(ui); + + ui.add_space(5.0); + ui.separator(); + } + // Only show options when we have data to view and are in Log Viewer mode if !self.files.is_empty() && !self.get_selected_channels().is_empty() @@ -224,16 +241,20 @@ impl UltraLogApp { .inner_margin(10.0) .show(ui, |ui| { // Cursor tracking checkbox - ui.checkbox(&mut self.cursor_tracking, "🎯 Cursor Tracking"); + ui.checkbox( + &mut self.cursor_tracking, + egui::RichText::new("🎯 Cursor Tracking").size(font_14), + ); ui.label( egui::RichText::new("Keep cursor centered while scrubbing") - .color(egui::Color32::GRAY), + .color(egui::Color32::GRAY) + .size(font_12), ); // Window size slider (only show when cursor tracking is enabled) if self.cursor_tracking { ui.add_space(8.0); - ui.label("View Window:"); + ui.label(egui::RichText::new("View Window:").size(font_14)); ui.add( egui::Slider::new(&mut self.view_window_seconds, 5.0..=120.0) .suffix("s") @@ -246,10 +267,14 @@ impl UltraLogApp { ui.add_space(4.0); // Color blind mode checkbox - ui.checkbox(&mut self.color_blind_mode, "👁 Color Blind Mode"); + ui.checkbox( + &mut self.color_blind_mode, + egui::RichText::new("👁 Color Blind Mode").size(font_14), + ); ui.label( egui::RichText::new("Use accessible color palette") - .color(egui::Color32::GRAY), + .color(egui::Color32::GRAY) + .size(font_12), ); ui.add_space(8.0); @@ -258,7 +283,10 @@ impl UltraLogApp { // Field normalization checkbox with right-aligned Edit button ui.horizontal(|ui| { - ui.checkbox(&mut self.field_normalization, "📝 Field Normalization"); + ui.checkbox( + &mut self.field_normalization, + egui::RichText::new("📝 Field Normalization").size(font_14), + ); ui.with_layout( egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -270,13 +298,14 @@ impl UltraLogApp { }); ui.label( egui::RichText::new("Standardize channel names across ECU types") - .color(egui::Color32::GRAY), + .color(egui::Color32::GRAY) + .size(font_12), ); }); ui.add_space(5.0); ui.separator(); - ui.heading("View Options"); + ui.label(egui::RichText::new("View Options").heading().size(font_18)); } }); } diff --git a/src/ui/tab_bar.rs b/src/ui/tab_bar.rs index a507880..95f5bcf 100644 --- a/src/ui/tab_bar.rs +++ b/src/ui/tab_bar.rs @@ -63,9 +63,10 @@ impl UltraLogApp { .show(ui, |ui| { ui.horizontal(|ui| { // Tab name (clickable) + let font_13 = self.scaled_font(13.0); let label_response = ui.add( egui::Label::new( - egui::RichText::new(name).color(text_color).size(13.0), + egui::RichText::new(name).color(text_color).size(font_13), ) .sense(egui::Sense::click()), ); @@ -80,11 +81,12 @@ impl UltraLogApp { ui.add_space(4.0); // Close button + let font_14 = self.scaled_font(14.0); let close_btn = ui.add( egui::Label::new( - egui::RichText::new("×") + egui::RichText::new("x") .color(egui::Color32::from_rgb(150, 150, 150)) - .size(14.0), + .size(font_14), ) .sense(egui::Sense::click()), ); diff --git a/src/ui/timeline.rs b/src/ui/timeline.rs index 03051ba..dae566e 100644 --- a/src/ui/timeline.rs +++ b/src/ui/timeline.rs @@ -8,6 +8,9 @@ use crate::app::UltraLogApp; impl UltraLogApp { /// Render the timeline scrubber bar pub fn render_timeline_scrubber(&mut self, ui: &mut egui::Ui) { + // Pre-compute scaled font size + let font_12 = self.scaled_font(12.0); + let Some((min_time, max_time)) = self.get_time_range() else { return; }; @@ -20,12 +23,15 @@ impl UltraLogApp { // Time labels row ui.horizontal(|ui| { ui.label( - egui::RichText::new(Self::format_time(min_time)).color(egui::Color32::LIGHT_GRAY), + egui::RichText::new(Self::format_time(min_time)) + .color(egui::Color32::LIGHT_GRAY) + .size(font_12), ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.label( egui::RichText::new(Self::format_time(max_time)) - .color(egui::Color32::LIGHT_GRAY), + .color(egui::Color32::LIGHT_GRAY) + .size(font_12), ); }); }); @@ -63,19 +69,24 @@ impl UltraLogApp { /// Render the record/time indicator bar with playback controls pub fn render_record_indicator(&mut self, ui: &mut egui::Ui) { + // Pre-compute scaled font size + let font_14 = self.scaled_font(14.0); + ui.horizontal(|ui| { // Playback controls let button_size = egui::vec2(28.0, 28.0); // Play/Pause button let play_text = if self.is_playing { "⏸" } else { "▶" }; - let play_button = egui::Button::new(egui::RichText::new(play_text).size(16.0).color( - if self.is_playing { - egui::Color32::from_rgb(253, 193, 73) // Amber when playing - } else { - egui::Color32::from_rgb(144, 238, 144) // Light green when paused - }, - )) + let play_button = egui::Button::new( + egui::RichText::new(play_text) + .size(self.scaled_font(16.0)) + .color(if self.is_playing { + egui::Color32::from_rgb(253, 193, 73) // Amber when playing + } else { + egui::Color32::from_rgb(144, 238, 144) // Light green when paused + }), + ) .min_size(button_size); if ui.add(play_button).clicked() { @@ -99,7 +110,7 @@ impl UltraLogApp { // Stop button (resets to beginning) let stop_button = egui::Button::new( egui::RichText::new("⏹") - .size(16.0) + .size(self.scaled_font(16.0)) .color(egui::Color32::from_rgb(191, 78, 48)), // Rust orange ) .min_size(button_size); @@ -118,7 +129,11 @@ impl UltraLogApp { ui.separator(); // Playback speed selector - ui.label(egui::RichText::new("Speed:").color(egui::Color32::GRAY)); + ui.label( + egui::RichText::new("Speed:") + .color(egui::Color32::GRAY) + .size(font_14), + ); let speed_options = [0.25, 0.5, 1.0, 2.0, 4.0, 8.0]; egui::ComboBox::from_id_salt("playback_speed") @@ -137,7 +152,8 @@ impl UltraLogApp { ui.label( egui::RichText::new(format!("Time: {}", Self::format_time(time))) .strong() - .color(egui::Color32::from_rgb(0, 255, 255)), // Cyan to match cursor + .color(egui::Color32::from_rgb(0, 255, 255)) // Cyan to match cursor + .size(font_14), ); } @@ -155,7 +171,8 @@ impl UltraLogApp { record + 1, total_records )) - .color(egui::Color32::LIGHT_GRAY), + .color(egui::Color32::LIGHT_GRAY) + .size(font_14), ); } } diff --git a/src/ui/toast.rs b/src/ui/toast.rs index 53dd4ea..c0472d9 100644 --- a/src/ui/toast.rs +++ b/src/ui/toast.rs @@ -34,6 +34,9 @@ impl UltraLogApp { color: egui::Color32::from_black_alpha(60), }) .show(ui, |ui| { + // Set min/max width for proper text wrapping + ui.set_min_width(200.0); + ui.set_max_width(400.0); ui.label( egui::RichText::new(message) .color(egui::Color32::from_rgb( @@ -41,7 +44,7 @@ impl UltraLogApp { text_color[1], text_color[2], )) - .size(14.0), + .size(self.scaled_font(14.0)), ); }); }); diff --git a/src/ui/tool_switcher.rs b/src/ui/tool_switcher.rs index 140723b..b036b55 100644 --- a/src/ui/tool_switcher.rs +++ b/src/ui/tool_switcher.rs @@ -16,7 +16,11 @@ impl UltraLogApp { ui.add_space(10.0); // Define available tools - let tools = [ActiveTool::LogViewer, ActiveTool::ScatterPlot]; + let tools = [ + ActiveTool::LogViewer, + ActiveTool::ScatterPlot, + ActiveTool::Histogram, + ]; for tool in tools { let is_selected = self.active_tool == tool; @@ -44,7 +48,7 @@ impl UltraLogApp { let response = ui.add( egui::Button::new( egui::RichText::new(tool.name()) - .size(14.0) + .size(self.scaled_font(14.0)) .color(text_color), ) .fill(button_fill) diff --git a/src/ui/tools_panel.rs b/src/ui/tools_panel.rs new file mode 100644 index 0000000..1ecf904 --- /dev/null +++ b/src/ui/tools_panel.rs @@ -0,0 +1,374 @@ +//! Tools panel - analysis tools, computed channels library, and export options. +//! +//! Provides quick access to analysis and export functionality inline in the side panel. + +use eframe::egui; + +use crate::app::UltraLogApp; +use crate::state::ActiveTool; + +impl UltraLogApp { + /// Render the tools panel content (called from side_panel.rs) + pub fn render_tools_panel_content(&mut self, ui: &mut egui::Ui) { + let _font_12 = self.scaled_font(12.0); + let _font_14 = self.scaled_font(14.0); + + // Analysis Tools Section + self.render_tools_analysis_section(ui); + + ui.add_space(12.0); + ui.separator(); + ui.add_space(8.0); + + // Computed Channels Section + self.render_tools_computed_section(ui); + + ui.add_space(12.0); + ui.separator(); + ui.add_space(8.0); + + // Export Section + self.render_tools_export_section(ui); + } + + /// Render the analysis tools section + fn render_tools_analysis_section(&mut self, ui: &mut egui::Ui) { + let font_12 = self.scaled_font(12.0); + let font_14 = self.scaled_font(14.0); + + egui::CollapsingHeader::new( + egui::RichText::new("📈 Analysis Tools") + .size(font_14) + .strong(), + ) + .default_open(true) + .show(ui, |ui| { + ui.label( + egui::RichText::new("Run signal processing and statistical analysis on log data.") + .size(font_12) + .color(egui::Color32::GRAY), + ); + ui.add_space(8.0); + + let has_file = self.selected_file.is_some() && !self.files.is_empty(); + + if has_file { + // Show available analyzers count + let analyzer_count = self.analyzer_registry.all().len(); + ui.label( + egui::RichText::new(format!("{} analyzers available", analyzer_count)) + .size(font_12) + .color(egui::Color32::GRAY), + ); + ui.add_space(4.0); + + // Open full analysis panel button + let primary_color = egui::Color32::from_rgb(113, 120, 78); + let btn = egui::Frame::NONE + .fill(primary_color) + .corner_radius(4) + .inner_margin(egui::vec2(12.0, 6.0)) + .show(ui, |ui| { + ui.label( + egui::RichText::new("Open Analysis Panel") + .color(egui::Color32::WHITE) + .size(font_14), + ); + }); + + if btn.response.interact(egui::Sense::click()).clicked() { + self.show_analysis_panel = true; + } + + if btn.response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + + // Show results count if any + if let Some(file_idx) = self.selected_file { + if let Some(results) = self.analysis_results.get(&file_idx) { + if !results.is_empty() { + ui.add_space(8.0); + ui.label( + egui::RichText::new(format!( + "✓ {} analysis results for current file", + results.len() + )) + .size(font_12) + .color(egui::Color32::from_rgb(150, 200, 150)), + ); + } + } + } + } else { + ui.label( + egui::RichText::new("Load a file to access analysis tools") + .size(font_12) + .color(egui::Color32::from_rgb(100, 100, 100)) + .italics(), + ); + } + }); + } + + /// Render the computed channels section + fn render_tools_computed_section(&mut self, ui: &mut egui::Ui) { + let font_12 = self.scaled_font(12.0); + let font_14 = self.scaled_font(14.0); + + egui::CollapsingHeader::new( + egui::RichText::new("ƒ Computed Channels") + .size(font_14) + .strong(), + ) + .default_open(true) + .show(ui, |ui| { + ui.label( + egui::RichText::new("Create virtual channels from mathematical formulas.") + .size(font_12) + .color(egui::Color32::GRAY), + ); + ui.add_space(8.0); + + // Library count + let template_count = self.computed_library.templates.len(); + ui.label( + egui::RichText::new(format!("{} templates in library", template_count)) + .size(font_12) + .color(egui::Color32::GRAY), + ); + ui.add_space(4.0); + + // Buttons row + ui.horizontal(|ui| { + // New Channel button + let accent_color = egui::Color32::from_rgb(113, 120, 78); + let new_btn = egui::Frame::NONE + .fill(accent_color) + .corner_radius(4) + .inner_margin(egui::vec2(10.0, 5.0)) + .show(ui, |ui| { + ui.label( + egui::RichText::new("+ New") + .color(egui::Color32::WHITE) + .size(font_12), + ); + }); + + if new_btn.response.interact(egui::Sense::click()).clicked() { + self.formula_editor_state.open_new(); + } + + if new_btn.response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + + // Manage Library button + let manage_btn = egui::Frame::NONE + .fill(egui::Color32::from_rgb(60, 60, 60)) + .corner_radius(4) + .inner_margin(egui::vec2(10.0, 5.0)) + .show(ui, |ui| { + ui.label( + egui::RichText::new("Manage Library") + .color(egui::Color32::LIGHT_GRAY) + .size(font_12), + ); + }); + + if manage_btn.response.interact(egui::Sense::click()).clicked() { + self.show_computed_channels_manager = true; + } + + if manage_btn.response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + }); + + // Show applied channels for current file + if let Some(file_idx) = self.selected_file { + if let Some(channels) = self.file_computed_channels.get(&file_idx) { + if !channels.is_empty() { + ui.add_space(8.0); + ui.label( + egui::RichText::new(format!( + "✓ {} computed channels on current file", + channels.len() + )) + .size(font_12) + .color(egui::Color32::from_rgb(150, 200, 150)), + ); + } + } + } + + // Quick apply section + if !self.computed_library.templates.is_empty() && self.selected_file.is_some() { + ui.add_space(8.0); + ui.label( + egui::RichText::new("Quick Apply:") + .size(font_12) + .color(egui::Color32::GRAY), + ); + + // Show first few templates as quick apply buttons + let templates: Vec<_> = self + .computed_library + .templates + .iter() + .take(5) + .map(|t| (t.id.clone(), t.name.clone())) + .collect(); + + for (id, name) in templates { + let response = ui.add( + egui::Label::new( + egui::RichText::new(format!(" • {}", name)) + .size(font_12) + .color(egui::Color32::from_rgb(150, 180, 220)), + ) + .sense(egui::Sense::click()), + ); + + if response.clicked() { + // Find and apply the template + if let Some(template) = + self.computed_library.templates.iter().find(|t| t.id == id) + { + let template_clone = template.clone(); + self.apply_computed_channel_template(&template_clone); + } + } + + if response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + } + } + }); + } + + /// Render the export section + fn render_tools_export_section(&mut self, ui: &mut egui::Ui) { + let font_12 = self.scaled_font(12.0); + let font_14 = self.scaled_font(14.0); + + egui::CollapsingHeader::new(egui::RichText::new("📤 Export").size(font_14).strong()) + .default_open(true) + .show(ui, |ui| { + ui.label( + egui::RichText::new("Export visualizations as images or documents.") + .size(font_12) + .color(egui::Color32::GRAY), + ); + ui.add_space(8.0); + + let has_file = self.selected_file.is_some() && !self.files.is_empty(); + let has_channels = !self.get_selected_channels().is_empty(); + + // Determine what can be exported based on active tool + let can_export_png = match self.active_tool { + ActiveTool::LogViewer => has_file && has_channels, + ActiveTool::ScatterPlot => has_file, + ActiveTool::Histogram => has_file, + }; + + let can_export_pdf = match self.active_tool { + ActiveTool::LogViewer => has_file && has_channels, + ActiveTool::ScatterPlot => has_file, + ActiveTool::Histogram => has_file, + }; + + ui.horizontal(|ui| { + // PNG Export + ui.add_enabled_ui(can_export_png, |ui| { + let btn = egui::Frame::NONE + .fill(if can_export_png { + egui::Color32::from_rgb(71, 108, 155) + } else { + egui::Color32::from_rgb(50, 50, 50) + }) + .corner_radius(4) + .inner_margin(egui::vec2(12.0, 6.0)) + .show(ui, |ui| { + ui.label( + egui::RichText::new("PNG") + .color(if can_export_png { + egui::Color32::WHITE + } else { + egui::Color32::GRAY + }) + .size(font_14), + ); + }); + + if can_export_png && btn.response.interact(egui::Sense::click()).clicked() { + match self.active_tool { + ActiveTool::LogViewer => self.export_chart_png(), + ActiveTool::ScatterPlot => self.export_scatter_plot_png(), + ActiveTool::Histogram => self.export_histogram_png(), + } + } + + if btn.response.hovered() && can_export_png { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + }); + + // PDF Export + ui.add_enabled_ui(can_export_pdf, |ui| { + let btn = egui::Frame::NONE + .fill(if can_export_pdf { + egui::Color32::from_rgb(155, 71, 71) + } else { + egui::Color32::from_rgb(50, 50, 50) + }) + .corner_radius(4) + .inner_margin(egui::vec2(12.0, 6.0)) + .show(ui, |ui| { + ui.label( + egui::RichText::new("PDF") + .color(if can_export_pdf { + egui::Color32::WHITE + } else { + egui::Color32::GRAY + }) + .size(font_14), + ); + }); + + if can_export_pdf && btn.response.interact(egui::Sense::click()).clicked() { + match self.active_tool { + ActiveTool::LogViewer => self.export_chart_pdf(), + ActiveTool::ScatterPlot => self.export_scatter_plot_pdf(), + ActiveTool::Histogram => self.export_histogram_pdf(), + } + } + + if btn.response.hovered() && can_export_pdf { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + }); + }); + + // Show appropriate help text based on tool and state + if !has_file { + ui.add_space(4.0); + ui.label( + egui::RichText::new("Load a file to enable export") + .size(font_12) + .color(egui::Color32::from_rgb(100, 100, 100)) + .italics(), + ); + } else if self.active_tool == ActiveTool::LogViewer && !has_channels { + ui.add_space(4.0); + ui.label( + egui::RichText::new("Select channels to enable chart export") + .size(font_12) + .color(egui::Color32::from_rgb(100, 100, 100)) + .italics(), + ); + } + }); + } +} diff --git a/src/ui/update_dialog.rs b/src/ui/update_dialog.rs index 23ee10d..4d14faa 100644 --- a/src/ui/update_dialog.rs +++ b/src/ui/update_dialog.rs @@ -63,7 +63,7 @@ impl UltraLogApp { ui.label( egui::RichText::new("A new version is available!") - .size(18.0) + .size(self.scaled_font(18.0)) .strong(), ); @@ -133,7 +133,7 @@ impl UltraLogApp { ui.vertical_centered(|ui| { ui.add_space(20.0); - ui.label(egui::RichText::new("Downloading update...").size(16.0)); + ui.label(egui::RichText::new("Downloading update...").size(self.scaled_font(16.0))); ui.add_space(15.0); @@ -158,7 +158,7 @@ impl UltraLogApp { ui.label( egui::RichText::new("Download complete!") - .size(16.0) + .size(self.scaled_font(16.0)) .color(egui::Color32::LIGHT_GREEN), ); @@ -224,7 +224,7 @@ impl UltraLogApp { ui.label( egui::RichText::new("Update Error") - .size(16.0) + .size(self.scaled_font(16.0)) .color(egui::Color32::from_rgb(191, 78, 48)), ); diff --git a/tests/common/mod.rs b/tests/common/mod.rs index fb2de1e..528fba6 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -67,6 +67,12 @@ pub mod example_files { // RomRaider example files pub const ROMRAIDER_EUROPEAN: &str = "exampleLogs/romraider/romraiderlog_20251031_170713.csv"; + + // Emerald ECU example files + pub const EMERALD_IDLE_REV: &str = "exampleLogs/emerald/EM Log MG ZS Turbo idle and rev.lg1"; + pub const EMERALD_SHORT_DRIVE: &str = "exampleLogs/emerald/EM Log MG ZS Turbo short drive.lg1"; + pub const EMERALD_DIFF_CHANNELS: &str = + "exampleLogs/emerald/EM Log MG ZS Turbo short drive back diff channels.lg1"; } /// Test data generators for synthetic tests diff --git a/tests/core/normalize_tests.rs b/tests/core/normalize_tests.rs index 924fc42..b8da0db 100644 --- a/tests/core/normalize_tests.rs +++ b/tests/core/normalize_tests.rs @@ -19,12 +19,29 @@ use ultralog::normalize::{ #[test] fn test_normalize_afr_variants() { + // Generic/overall AFR readings normalize to "AFR" assert_eq!(normalize_channel_name("Act_AFR"), "AFR"); assert_eq!(normalize_channel_name("R_EGO"), "AFR"); assert_eq!(normalize_channel_name("Air Fuel Ratio"), "AFR"); assert_eq!(normalize_channel_name("AFR"), "AFR"); - assert_eq!(normalize_channel_name("AFR1"), "AFR"); - assert_eq!(normalize_channel_name("WB2 AFR 1"), "AFR"); + assert_eq!(normalize_channel_name("Wideband O2"), "AFR"); + assert_eq!(normalize_channel_name("Wideband O2 Overall"), "AFR"); +} + +#[test] +fn test_normalize_afr_numbered_channels() { + // Numbered AFR/wideband channels normalize to distinct names + assert_eq!(normalize_channel_name("AFR1"), "AFR Channel 1"); + assert_eq!(normalize_channel_name("AFR 1"), "AFR Channel 1"); + assert_eq!(normalize_channel_name("WB2 AFR 1"), "AFR Channel 1"); + assert_eq!(normalize_channel_name("Wideband O2 1"), "AFR Channel 1"); + assert_eq!(normalize_channel_name("Wideband 1"), "AFR Channel 1"); + + assert_eq!(normalize_channel_name("AFR2"), "AFR Channel 2"); + assert_eq!(normalize_channel_name("AFR 2"), "AFR Channel 2"); + assert_eq!(normalize_channel_name("WB2 AFR 2"), "AFR Channel 2"); + assert_eq!(normalize_channel_name("Wideband O2 2"), "AFR Channel 2"); + assert_eq!(normalize_channel_name("Wideband 2"), "AFR Channel 2"); } #[test] diff --git a/tests/core/state_tests.rs b/tests/core/state_tests.rs index ead5594..f2e9d77 100644 --- a/tests/core/state_tests.rs +++ b/tests/core/state_tests.rs @@ -12,9 +12,10 @@ use ultralog::parsers::haltech::{ChannelType, HaltechChannel}; use ultralog::parsers::types::{EcuType, Log, Value}; use ultralog::parsers::Channel; use ultralog::state::{ - ActiveTool, CacheKey, LoadResult, LoadedFile, LoadingState, ScatterPlotConfig, - ScatterPlotState, SelectedChannel, SelectedHeatmapPoint, Tab, ToastType, CHART_COLORS, - COLORBLIND_COLORS, MAX_CHANNELS, MAX_CHART_POINTS, SUPPORTED_EXTENSIONS, + ActiveTool, CacheKey, HistogramConfig, HistogramGridSize, HistogramMode, HistogramState, + LoadResult, LoadedFile, LoadingState, ScatterPlotConfig, ScatterPlotState, SelectedChannel, + SelectedHeatmapPoint, SelectedHistogramCell, Tab, ToastType, CHART_COLORS, COLORBLIND_COLORS, + MAX_CHANNELS, MAX_CHART_POINTS, SUPPORTED_EXTENSIONS, }; // ============================================ @@ -181,6 +182,7 @@ fn test_active_tool_default() { fn test_active_tool_names() { assert_eq!(ActiveTool::LogViewer.name(), "Log Viewer"); assert_eq!(ActiveTool::ScatterPlot.name(), "Scatter Plots"); + assert_eq!(ActiveTool::Histogram.name(), "Histogram"); } #[test] @@ -188,7 +190,10 @@ fn test_active_tool_equality() { // Use pattern matching since ActiveTool doesn't implement Debug assert!(ActiveTool::LogViewer == ActiveTool::LogViewer); assert!(ActiveTool::ScatterPlot == ActiveTool::ScatterPlot); + assert!(ActiveTool::Histogram == ActiveTool::Histogram); assert!(ActiveTool::LogViewer != ActiveTool::ScatterPlot); + assert!(ActiveTool::LogViewer != ActiveTool::Histogram); + assert!(ActiveTool::ScatterPlot != ActiveTool::Histogram); } #[test] @@ -596,3 +601,313 @@ fn test_loaded_file_clone() { assert_eq!(cloned.name, file.name); assert_eq!(cloned.channels_with_data, file.channels_with_data); } + +// ============================================ +// HistogramMode Tests +// ============================================ + +#[test] +fn test_histogram_mode_default() { + let mode = HistogramMode::default(); + assert!(matches!(mode, HistogramMode::AverageZ)); +} + +#[test] +fn test_histogram_mode_variants() { + let average_z = HistogramMode::AverageZ; + let hit_count = HistogramMode::HitCount; + + assert!(matches!(average_z, HistogramMode::AverageZ)); + assert!(matches!(hit_count, HistogramMode::HitCount)); +} + +#[test] +fn test_histogram_mode_equality() { + assert!(HistogramMode::AverageZ == HistogramMode::AverageZ); + assert!(HistogramMode::HitCount == HistogramMode::HitCount); + assert!(HistogramMode::AverageZ != HistogramMode::HitCount); +} + +#[test] +fn test_histogram_mode_copy() { + let mode1 = HistogramMode::HitCount; + let mode2 = mode1; + assert!(mode1 == mode2); +} + +// ============================================ +// HistogramGridSize Tests +// ============================================ + +#[test] +fn test_histogram_grid_size_default() { + let size = HistogramGridSize::default(); + // Default should be 32x32 + assert!(matches!(size, HistogramGridSize::Size32)); +} + +#[test] +fn test_histogram_grid_size_values() { + assert_eq!(HistogramGridSize::Size16.size(), 16); + assert_eq!(HistogramGridSize::Size32.size(), 32); + assert_eq!(HistogramGridSize::Size64.size(), 64); +} + +#[test] +fn test_histogram_grid_size_names() { + assert_eq!(HistogramGridSize::Size16.name(), "16x16"); + assert_eq!(HistogramGridSize::Size32.name(), "32x32"); + assert_eq!(HistogramGridSize::Size64.name(), "64x64"); +} + +#[test] +fn test_histogram_grid_size_equality() { + assert!(HistogramGridSize::Size16 == HistogramGridSize::Size16); + assert!(HistogramGridSize::Size32 == HistogramGridSize::Size32); + assert!(HistogramGridSize::Size64 == HistogramGridSize::Size64); + assert!(HistogramGridSize::Size16 != HistogramGridSize::Size32); + assert!(HistogramGridSize::Size32 != HistogramGridSize::Size64); +} + +#[test] +fn test_histogram_grid_size_copy() { + let size1 = HistogramGridSize::Size64; + let size2 = size1; + assert!(size1 == size2); +} + +// ============================================ +// SelectedHistogramCell Tests +// ============================================ + +#[test] +fn test_selected_histogram_cell_default() { + let cell = SelectedHistogramCell::default(); + + assert_eq!(cell.x_bin, 0); + assert_eq!(cell.y_bin, 0); + assert_eq!(cell.x_range, (0.0, 0.0)); + assert_eq!(cell.y_range, (0.0, 0.0)); + assert_eq!(cell.hit_count, 0); + assert_eq!(cell.cell_weight, 0.0); + assert_eq!(cell.variance, 0.0); + assert_eq!(cell.std_dev, 0.0); + assert_eq!(cell.minimum, 0.0); + assert_eq!(cell.mean, 0.0); + assert_eq!(cell.maximum, 0.0); +} + +#[test] +fn test_selected_histogram_cell_with_values() { + let cell = SelectedHistogramCell { + x_bin: 5, + y_bin: 10, + x_range: (100.0, 200.0), + y_range: (50.0, 75.0), + hit_count: 42, + cell_weight: 1234.56, + variance: 12.5, + std_dev: 3.54, + minimum: 10.0, + mean: 29.4, + maximum: 50.0, + }; + + assert_eq!(cell.x_bin, 5); + assert_eq!(cell.y_bin, 10); + assert_eq!(cell.x_range, (100.0, 200.0)); + assert_eq!(cell.y_range, (50.0, 75.0)); + assert_eq!(cell.hit_count, 42); + assert_eq!(cell.cell_weight, 1234.56); + assert_eq!(cell.variance, 12.5); + assert_eq!(cell.std_dev, 3.54); + assert_eq!(cell.minimum, 10.0); + assert_eq!(cell.mean, 29.4); + assert_eq!(cell.maximum, 50.0); +} + +#[test] +fn test_selected_histogram_cell_clone() { + let cell = SelectedHistogramCell { + x_bin: 3, + y_bin: 7, + x_range: (0.0, 100.0), + y_range: (0.0, 50.0), + hit_count: 100, + cell_weight: 500.0, + variance: 25.0, + std_dev: 5.0, + minimum: 5.0, + mean: 25.0, + maximum: 45.0, + }; + + let cloned = cell.clone(); + + assert_eq!(cloned.x_bin, cell.x_bin); + assert_eq!(cloned.y_bin, cell.y_bin); + assert_eq!(cloned.x_range, cell.x_range); + assert_eq!(cloned.y_range, cell.y_range); + assert_eq!(cloned.hit_count, cell.hit_count); + assert_eq!(cloned.cell_weight, cell.cell_weight); + assert_eq!(cloned.variance, cell.variance); + assert_eq!(cloned.std_dev, cell.std_dev); + assert_eq!(cloned.minimum, cell.minimum); + assert_eq!(cloned.mean, cell.mean); + assert_eq!(cloned.maximum, cell.maximum); +} + +// ============================================ +// HistogramConfig Tests +// ============================================ + +#[test] +fn test_histogram_config_default() { + let config = HistogramConfig::default(); + + assert!(config.x_channel.is_none()); + assert!(config.y_channel.is_none()); + assert!(config.z_channel.is_none()); + assert!(matches!(config.mode, HistogramMode::AverageZ)); + assert!(matches!(config.grid_size, HistogramGridSize::Size32)); + assert!(config.selected_cell.is_none()); +} + +#[test] +fn test_histogram_config_with_values() { + let mut config = HistogramConfig::default(); + config.x_channel = Some(0); + config.y_channel = Some(1); + config.z_channel = Some(2); + config.mode = HistogramMode::HitCount; + config.grid_size = HistogramGridSize::Size64; + config.selected_cell = Some(SelectedHistogramCell::default()); + + assert_eq!(config.x_channel, Some(0)); + assert_eq!(config.y_channel, Some(1)); + assert_eq!(config.z_channel, Some(2)); + assert!(matches!(config.mode, HistogramMode::HitCount)); + assert!(matches!(config.grid_size, HistogramGridSize::Size64)); + assert!(config.selected_cell.is_some()); +} + +#[test] +fn test_histogram_config_clone() { + let mut config = HistogramConfig::default(); + config.x_channel = Some(5); + config.y_channel = Some(10); + config.mode = HistogramMode::HitCount; + + let cloned = config.clone(); + + assert_eq!(cloned.x_channel, Some(5)); + assert_eq!(cloned.y_channel, Some(10)); + assert!(matches!(cloned.mode, HistogramMode::HitCount)); +} + +// ============================================ +// HistogramState Tests +// ============================================ + +#[test] +fn test_histogram_state_default() { + let state = HistogramState::default(); + + assert!(state.config.x_channel.is_none()); + assert!(state.config.y_channel.is_none()); + assert!(state.config.z_channel.is_none()); +} + +#[test] +fn test_histogram_state_clone() { + let mut state = HistogramState::default(); + state.config.x_channel = Some(1); + state.config.y_channel = Some(2); + state.config.z_channel = Some(3); + state.config.mode = HistogramMode::HitCount; + state.config.grid_size = HistogramGridSize::Size16; + + let cloned = state.clone(); + + assert_eq!(cloned.config.x_channel, Some(1)); + assert_eq!(cloned.config.y_channel, Some(2)); + assert_eq!(cloned.config.z_channel, Some(3)); + assert!(matches!(cloned.config.mode, HistogramMode::HitCount)); + assert!(matches!(cloned.config.grid_size, HistogramGridSize::Size16)); +} + +// ============================================ +// Tab Histogram State Tests +// ============================================ + +#[test] +fn test_tab_histogram_state_initialization() { + let tab = Tab::new(0, "test.csv".to_string()); + + // Histogram state should be initialized with defaults + assert!(tab.histogram_state.config.x_channel.is_none()); + assert!(tab.histogram_state.config.y_channel.is_none()); + assert!(tab.histogram_state.config.z_channel.is_none()); + assert!(matches!( + tab.histogram_state.config.mode, + HistogramMode::AverageZ + )); + assert!(matches!( + tab.histogram_state.config.grid_size, + HistogramGridSize::Size32 + )); + assert!(tab.histogram_state.config.selected_cell.is_none()); +} + +#[test] +fn test_tab_histogram_state_persists_across_clone() { + let mut tab = Tab::new(0, "test.csv".to_string()); + tab.histogram_state.config.x_channel = Some(5); + tab.histogram_state.config.y_channel = Some(10); + tab.histogram_state.config.z_channel = Some(15); + tab.histogram_state.config.mode = HistogramMode::HitCount; + tab.histogram_state.config.grid_size = HistogramGridSize::Size64; + + let cloned = tab.clone(); + + assert_eq!(cloned.histogram_state.config.x_channel, Some(5)); + assert_eq!(cloned.histogram_state.config.y_channel, Some(10)); + assert_eq!(cloned.histogram_state.config.z_channel, Some(15)); + assert!(matches!( + cloned.histogram_state.config.mode, + HistogramMode::HitCount + )); + assert!(matches!( + cloned.histogram_state.config.grid_size, + HistogramGridSize::Size64 + )); +} + +#[test] +fn test_tab_histogram_selected_cell_persists() { + let mut tab = Tab::new(0, "test.csv".to_string()); + + let selected = SelectedHistogramCell { + x_bin: 8, + y_bin: 12, + x_range: (0.0, 500.0), + y_range: (0.0, 100.0), + hit_count: 50, + cell_weight: 250.0, + variance: 10.0, + std_dev: 3.16, + minimum: 2.0, + mean: 5.0, + maximum: 10.0, + }; + + tab.histogram_state.config.selected_cell = Some(selected); + + let cloned = tab.clone(); + + assert!(cloned.histogram_state.config.selected_cell.is_some()); + let cloned_cell = cloned.histogram_state.config.selected_cell.unwrap(); + assert_eq!(cloned_cell.x_bin, 8); + assert_eq!(cloned_cell.y_bin, 12); + assert_eq!(cloned_cell.hit_count, 50); +} diff --git a/tests/parsers/emerald_tests.rs b/tests/parsers/emerald_tests.rs new file mode 100644 index 0000000..3522d9e --- /dev/null +++ b/tests/parsers/emerald_tests.rs @@ -0,0 +1,878 @@ +//! Comprehensive tests for the Emerald ECU LG1/LG2 binary parser +//! +//! Tests cover: +//! - Binary format detection (24-byte records with OLE timestamps) +//! - LG2 channel definition parsing +//! - LG1 binary data parsing +//! - Channel ID mapping +//! - Real file parsing with example logs +//! - Edge cases and error handling + +#[path = "../common/mod.rs"] +mod common; + +use common::assertions::*; +use common::example_files::*; +use common::{example_file_exists, read_example_binary}; +use std::path::Path; +use ultralog::parsers::emerald::Emerald; +use ultralog::parsers::types::Meta; + +// ============================================ +// Format Detection Tests +// ============================================ + +#[test] +fn test_emerald_detection_valid_lg1() { + // Create minimal valid LG1 data (one record = 24 bytes) + // OLE timestamp (f64) + 8 x u16 values + let mut valid_data = Vec::with_capacity(24); + + // Valid OLE timestamp (e.g., 46022.5 = Dec 2025) + let timestamp: f64 = 46022.5; + valid_data.extend_from_slice(×tamp.to_le_bytes()); + + // 8 channel values (16 bytes) + for i in 0..8 { + let value: u16 = (i * 100) as u16; + valid_data.extend_from_slice(&value.to_le_bytes()); + } + + assert_eq!(valid_data.len(), 24); + assert!( + Emerald::detect(&valid_data), + "Should detect valid LG1 data with correct timestamp" + ); +} + +#[test] +fn test_emerald_detection_multiple_records() { + // Create 3 records of valid data + let mut data = Vec::with_capacity(72); + + for record_idx in 0..3 { + let timestamp: f64 = 46022.5 + (record_idx as f64 * 0.001); // ~1 minute apart + data.extend_from_slice(×tamp.to_le_bytes()); + + for i in 0..8 { + let value: u16 = ((record_idx * 8 + i) * 10) as u16; + data.extend_from_slice(&value.to_le_bytes()); + } + } + + assert_eq!(data.len(), 72); + assert!( + Emerald::detect(&data), + "Should detect valid LG1 data with multiple records" + ); +} + +#[test] +fn test_emerald_detection_empty() { + assert!(!Emerald::detect(b""), "Should not detect empty data"); +} + +#[test] +fn test_emerald_detection_too_short() { + let too_short = vec![0u8; 23]; + assert!( + !Emerald::detect(&too_short), + "Should not detect data shorter than 24 bytes" + ); +} + +#[test] +fn test_emerald_detection_wrong_size() { + // Not a multiple of 24 bytes + let wrong_size = vec![0u8; 25]; + assert!( + !Emerald::detect(&wrong_size), + "Should not detect data with size not multiple of 24" + ); +} + +#[test] +fn test_emerald_detection_invalid_timestamp_too_old() { + let mut data = vec![0u8; 24]; + let old_timestamp: f64 = 1000.0; // Way too old (around year 1902) + data[0..8].copy_from_slice(&old_timestamp.to_le_bytes()); + + assert!( + !Emerald::detect(&data), + "Should not detect data with timestamp before 1995" + ); +} + +#[test] +fn test_emerald_detection_invalid_timestamp_too_new() { + let mut data = vec![0u8; 24]; + let future_timestamp: f64 = 100000.0; // Way too far in future (around year 2173) + data[0..8].copy_from_slice(&future_timestamp.to_le_bytes()); + + assert!( + !Emerald::detect(&data), + "Should not detect data with timestamp after 2050" + ); +} + +#[test] +fn test_emerald_detection_divergent_timestamps() { + // Two records with timestamps more than 1 day apart + let mut data = vec![0u8; 48]; + + let timestamp1: f64 = 46022.5; + data[0..8].copy_from_slice(×tamp1.to_le_bytes()); + + let timestamp2: f64 = 46025.5; // 3 days later - suspicious + data[24..32].copy_from_slice(×tamp2.to_le_bytes()); + + assert!( + !Emerald::detect(&data), + "Should not detect data with timestamps >1 day apart" + ); +} + +#[test] +fn test_emerald_detection_other_formats() { + assert!( + !Emerald::detect(b"MLVLG"), + "Should not detect Speeduino MLG format" + ); + assert!( + !Emerald::detect(b" = log.channels.iter().map(|c| c.name()).collect(); + eprintln!("Diff channels file has: {:?}", channel_names); + + eprintln!( + "Emerald diff channels log: {} channels, {} records, {:.1}s duration", + log.channels.len(), + log.data.len(), + log.times.last().unwrap_or(&0.0) + ); +} + +// ============================================ +// Channel Tests +// ============================================ + +#[test] +fn test_emerald_channel_properties() { + if !example_file_exists(EMERALD_IDLE_REV) { + eprintln!("Skipping test: {} not found", EMERALD_IDLE_REV); + return; + } + + let log = Emerald::parse_file(Path::new(EMERALD_IDLE_REV)).expect("Should parse"); + + for channel in &log.channels { + let name = channel.name(); + let unit = channel.unit(); + + // Names should be non-empty + assert!(!name.is_empty(), "Channel should have a name"); + + // Names should be printable + for c in name.chars() { + assert!( + c.is_ascii_graphic() || c == ' ' || c == '°', + "Channel name should contain printable chars: {:?}", + name + ); + } + + // Units should be valid + for c in unit.chars() { + assert!( + c.is_ascii_graphic() || c == ' ' || c == '°' || c == '%' || c == 'λ', + "Channel unit should contain valid chars: {:?}", + unit + ); + } + + eprintln!(" Channel: {} [{}]", name, unit); + } +} + +#[test] +fn test_emerald_expected_channels() { + if !example_file_exists(EMERALD_IDLE_REV) { + eprintln!("Skipping test: {} not found", EMERALD_IDLE_REV); + return; + } + + let log = Emerald::parse_file(Path::new(EMERALD_IDLE_REV)).expect("Should parse"); + + let channel_names: Vec = log.channels.iter().map(|c| c.name()).collect(); + + // These files typically have standard channels + let has_rpm = channel_names.iter().any(|n| n.contains("RPM")); + let has_temp = channel_names + .iter() + .any(|n| n.contains("Temp") || n.contains("temp")); + + assert!(has_rpm, "Should have an RPM channel: {:?}", channel_names); + assert!( + has_temp, + "Should have a temperature channel: {:?}", + channel_names + ); +} + +#[test] +fn test_emerald_channel_data_extraction() { + if !example_file_exists(EMERALD_IDLE_REV) { + eprintln!("Skipping test: {} not found", EMERALD_IDLE_REV); + return; + } + + let log = Emerald::parse_file(Path::new(EMERALD_IDLE_REV)).expect("Should parse"); + + // Test get_channel_data for each channel + for idx in 0..log.channels.len() { + let channel_data = log.get_channel_data(idx); + assert_eq!( + channel_data.len(), + log.data.len(), + "Channel {} data length should match record count", + idx + ); + } + + // Out of bounds should return empty + let oob = log.get_channel_data(999); + assert!(oob.is_empty(), "Out of bounds should return empty"); +} + +#[test] +fn test_emerald_find_channel_index() { + if !example_file_exists(EMERALD_IDLE_REV) { + eprintln!("Skipping test: {} not found", EMERALD_IDLE_REV); + return; + } + + let log = Emerald::parse_file(Path::new(EMERALD_IDLE_REV)).expect("Should parse"); + + // Find first channel + if !log.channels.is_empty() { + let first_name = log.channels[0].name(); + let found = log.find_channel_index(&first_name); + assert_eq!(found, Some(0)); + } + + // Non-existent channel + let not_found = log.find_channel_index("NonExistentChannel12345"); + assert_eq!(not_found, None); +} + +// ============================================ +// Data Value Tests +// ============================================ + +#[test] +fn test_emerald_data_values_reasonable() { + if !example_file_exists(EMERALD_IDLE_REV) { + eprintln!("Skipping test: {} not found", EMERALD_IDLE_REV); + return; + } + + let log = Emerald::parse_file(Path::new(EMERALD_IDLE_REV)).expect("Should parse"); + + // Find RPM channel and check values are reasonable + for (idx, channel) in log.channels.iter().enumerate() { + if channel.name().contains("RPM") { + let rpm_data = log.get_channel_data(idx); + let min = rpm_data.iter().cloned().fold(f64::INFINITY, f64::min); + let max = rpm_data.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + + eprintln!("RPM range: {} to {}", min, max); + + // RPM should be in reasonable range (0-15000) + assert!(min >= 0.0, "RPM min should be >= 0"); + assert!(max <= 15000.0, "RPM max should be <= 15000"); + break; + } + } + + // Find Coolant Temp and check values are reasonable + for (idx, channel) in log.channels.iter().enumerate() { + if channel.name().contains("Coolant") { + let temp_data = log.get_channel_data(idx); + let min = temp_data.iter().cloned().fold(f64::INFINITY, f64::min); + let max = temp_data.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + + eprintln!("Coolant temp range: {} to {} °C", min, max); + + // Temperature should be in reasonable range (-40°C to 200°C) + assert!(min >= -40.0, "Coolant temp min should be >= -40"); + assert!(max <= 200.0, "Coolant temp max should be <= 200"); + break; + } + } +} + +// ============================================ +// Metadata Tests +// ============================================ + +#[test] +fn test_emerald_metadata_extraction() { + if !example_file_exists(EMERALD_IDLE_REV) { + eprintln!("Skipping test: {} not found", EMERALD_IDLE_REV); + return; + } + + let log = Emerald::parse_file(Path::new(EMERALD_IDLE_REV)).expect("Should parse"); + + // Check metadata + if let Meta::Emerald(meta) = &log.meta { + eprintln!("Source file: {}", meta.source_file); + eprintln!("Record count: {}", meta.record_count); + eprintln!("Duration: {:.1}s", meta.duration_seconds); + eprintln!("Sample rate: {:.1} Hz", meta.sample_rate_hz); + + assert!( + meta.record_count > 0, + "Should have positive record count: {}", + meta.record_count + ); + assert!( + meta.duration_seconds > 0.0, + "Should have positive duration: {}", + meta.duration_seconds + ); + assert!( + meta.sample_rate_hz > 0.0, + "Should have positive sample rate: {}", + meta.sample_rate_hz + ); + + // Sample rate should be reasonable (10-100 Hz typical) + assert!( + meta.sample_rate_hz >= 5.0 && meta.sample_rate_hz <= 200.0, + "Sample rate should be reasonable: {} Hz", + meta.sample_rate_hz + ); + } else { + panic!("Expected Emerald metadata variant"); + } +} + +// ============================================ +// Data Integrity Tests +// ============================================ + +#[test] +fn test_emerald_data_structure() { + if !example_file_exists(EMERALD_IDLE_REV) { + eprintln!("Skipping test: {} not found", EMERALD_IDLE_REV); + return; + } + + let log = Emerald::parse_file(Path::new(EMERALD_IDLE_REV)).expect("Should parse"); + + // Verify times and data match + assert_eq!(log.times.len(), log.data.len()); + + // Verify each record has correct channel count + let channel_count = log.channels.len(); + for (i, record) in log.data.iter().enumerate() { + assert_eq!( + record.len(), + channel_count, + "Record {} should have {} values", + i, + channel_count + ); + } +} + +#[test] +fn test_emerald_timestamp_monotonicity() { + if !example_file_exists(EMERALD_IDLE_REV) { + eprintln!("Skipping test: {} not found", EMERALD_IDLE_REV); + return; + } + + let log = Emerald::parse_file(Path::new(EMERALD_IDLE_REV)).expect("Should parse"); + assert_monotonic_times(&log); +} + +#[test] +fn test_emerald_values_are_finite() { + if !example_file_exists(EMERALD_IDLE_REV) { + eprintln!("Skipping test: {} not found", EMERALD_IDLE_REV); + return; + } + + let log = Emerald::parse_file(Path::new(EMERALD_IDLE_REV)).expect("Should parse"); + assert_finite_values(&log); +} + +// ============================================ +// Timeline Tests +// ============================================ + +#[test] +fn test_emerald_timeline_validity() { + if !example_file_exists(EMERALD_IDLE_REV) { + eprintln!("Skipping test: {} not found", EMERALD_IDLE_REV); + return; + } + + let log = Emerald::parse_file(Path::new(EMERALD_IDLE_REV)).expect("Should parse"); + + let times = log.get_times_as_f64(); + + if !times.is_empty() { + // First timestamp should be 0 (relative to start) + assert!( + times[0] == 0.0, + "First timestamp should be 0, got {}", + times[0] + ); + + // All timestamps should be finite and reasonable + for (i, &t) in times.iter().enumerate() { + assert!(t.is_finite(), "Timestamp {} should be finite", i); + assert!( + t < 10000.0, + "Timestamp {} should be reasonable (<10000s)", + i + ); + } + + // Last timestamp indicates duration + let duration = times.last().unwrap(); + eprintln!("Log duration: {:.1} seconds", duration); + } +} + +// ============================================ +// All Files Test +// ============================================ + +#[test] +fn test_emerald_all_example_files() { + let emerald_files = [EMERALD_IDLE_REV, EMERALD_SHORT_DRIVE, EMERALD_DIFF_CHANNELS]; + + for file_path in emerald_files { + if !example_file_exists(file_path) { + eprintln!("Skipping: {} not found", file_path); + continue; + } + + let data = read_example_binary(file_path); + + assert!( + Emerald::detect(&data), + "Should detect {} as Emerald format", + file_path + ); + + let result = Emerald::parse_file(Path::new(file_path)); + assert!(result.is_ok(), "Should parse {} without error", file_path); + + let log = result.unwrap(); + + assert_eq!( + log.channels.len(), + 8, + "{} should have 8 channels", + file_path + ); + assert!(!log.data.is_empty(), "{} should have data", file_path); + + eprintln!( + "{}: {} channels, {} records, {:.1}s", + file_path, + log.channels.len(), + log.data.len(), + log.times.last().unwrap_or(&0.0) + ); + } +} + +// ============================================ +// Error Handling Tests +// ============================================ + +#[test] +fn test_emerald_missing_lg2_file() { + // Try to parse a non-existent file + let result = Emerald::parse_file(Path::new("/nonexistent/path/test.lg1")); + + assert!(result.is_err(), "Should fail for missing files"); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("lg2") || err_msg.contains("LG2"), + "Error should mention missing LG2 file: {}", + err_msg + ); +} + +#[test] +fn test_emerald_parse_invalid_binary_data() { + // Invalid data should fail detection + let invalid = b"NOT_EMERALD_FORMAT_DATA"; + assert!( + !Emerald::detect(invalid), + "Should not detect invalid data as Emerald" + ); +} + +// ============================================ +// Performance Tests +// ============================================ + +#[test] +fn test_emerald_parse_performance() { + if !example_file_exists(EMERALD_SHORT_DRIVE) { + eprintln!("Skipping test: {} not found", EMERALD_SHORT_DRIVE); + return; + } + + let start = std::time::Instant::now(); + let log = Emerald::parse_file(Path::new(EMERALD_SHORT_DRIVE)).expect("Should parse"); + let elapsed = start.elapsed(); + + eprintln!("Parsed {} records in {:?}", log.data.len(), elapsed); + + // Should complete in reasonable time (well under 1 second for small files) + assert!( + elapsed.as_secs() < 5, + "Parsing should complete in reasonable time" + ); +} + +// ============================================ +// Channel Configuration Variation Tests +// ============================================ + +#[test] +fn test_emerald_different_channel_configs() { + // Compare channel names between different log files + // to verify we correctly parse different channel configurations + + let files = [ + (EMERALD_IDLE_REV, "idle/rev"), + (EMERALD_DIFF_CHANNELS, "diff channels"), + ]; + + let mut configs: Vec<(String, Vec)> = Vec::new(); + + for (file_path, name) in files { + if !example_file_exists(file_path) { + eprintln!("Skipping: {} not found", file_path); + continue; + } + + let log = Emerald::parse_file(Path::new(file_path)).expect("Should parse"); + let channel_names: Vec = log.channels.iter().map(|c| c.name()).collect(); + + eprintln!("{} channels: {:?}", name, channel_names); + configs.push((name.to_string(), channel_names)); + } + + // If we have both files, verify they have different configurations + if configs.len() >= 2 { + let config1 = &configs[0].1; + let config2 = &configs[1].1; + + // They should have some different channels + let different = config1.iter().zip(config2.iter()).any(|(a, b)| a != b); + + assert!( + different, + "Different log files should have different channel configurations" + ); + } +} diff --git a/tests/parsers/mod.rs b/tests/parsers/mod.rs index 3097ae0..56a680b 100644 --- a/tests/parsers/mod.rs +++ b/tests/parsers/mod.rs @@ -8,6 +8,7 @@ pub mod aim_tests; pub mod ecumaster_tests; +pub mod emerald_tests; pub mod format_detection_tests; pub mod haltech_tests; pub mod link_tests;