From 3271af320dea087ce6f5e2123db27465d9a208e0 Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Tue, 2 Jun 2026 10:13:03 -0400 Subject: [PATCH 1/3] feat(woolich): add Woolich Racing Tuned CSV parser Parses CSV exports from Woolich Racing Tuned flash-tuning software for Yamaha/Kawasaki/Suzuki/Honda/BMW sportbike ECUs. Handles HH:MM:SS.mmm Log Time timestamps, boolean channels (Clutch In True/False), trailing commas, and name-based unit inference. Detection keys on the distinctive 'Log Time' header plus a timestamp format check, running before the Haltech fallback. Wired through the Channel/Meta/EcuType enums, the test_parser CLI, and the MCP tool desc. Adds parser unit tests, a real-file integration module, and cross-format detection coverage. Bumps version to 2.10.0. Refs #70 --- CLAUDE.md | 6 +- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 14 +- exampleLogs/woolich/Woolich.csv | 28282 ++++++++++++++++++++++ src/app.rs | 12 +- src/bin/test_parser.rs | 13 +- src/mcp/server.rs | 2 +- src/parsers/mod.rs | 2 + src/parsers/types.rs | 13 + src/parsers/woolich.rs | 348 + tests/common/mod.rs | 3 + tests/parsers/format_detection_tests.rs | 61 + tests/parsers/mod.rs | 1 + tests/parsers/woolich_tests.rs | 146 + 15 files changed, 28899 insertions(+), 8 deletions(-) create mode 100644 exampleLogs/woolich/Woolich.csv create mode 100644 src/parsers/woolich.rs create mode 100644 tests/parsers/woolich_tests.rs diff --git a/CLAUDE.md b/CLAUDE.md index 6edfa95..b19a4c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,7 +74,8 @@ src/ │ ├── romraider.rs # RomRaider CSV parser │ ├── speeduino.rs # Speeduino/rusEFI MLG binary parser │ ├── aim.rs # AiM XRK/DRK binary parser -│ └── link.rs # Link ECU LLG binary parser +│ ├── link.rs # Link ECU LLG binary parser +│ └── woolich.rs # Woolich Racing Tuned CSV parser └── ui/ ├── mod.rs # UI module exports ├── sidebar.rs # File list and view options panel @@ -199,6 +200,7 @@ The parser system uses a trait-based design for supporting multiple ECU formats: - **`parsers/speeduino.rs`** - Speeduino/rusEFI MLG binary format parser - **`parsers/aim.rs`** - AiM XRK/DRK binary format parser for motorsport data loggers - **`parsers/link.rs`** - Link ECU LLG binary format parser +- **`parsers/woolich.rs`** - Woolich Racing Tuned CSV parser (HH:MM:SS.mmm timestamps, boolean channels) **Supported ECU Systems:** @@ -210,6 +212,7 @@ The parser system uses a trait-based design for supporting multiple ECU formats: - AiM (XRK/DRK binary) - Link ECU (LLG binary) - Motorsport Electronics ME221/ME442 (ME Tuner CSV export) +- Woolich Racing Tuned (WRT CSV export — motorcycle ECUs) To add a new ECU format: @@ -291,4 +294,5 @@ Example log files are in `exampleLogs/` organized by ECU type: - `exampleLogs/haltech/` - Haltech NSP CSV exports - `exampleLogs/aim/` - AiM XRK/DRK files - `exampleLogs/link/` - Link ECU LLG files +- `exampleLogs/woolich/` - Woolich Racing Tuned CSV exports - Additional formats for parser testing diff --git a/Cargo.lock b/Cargo.lock index 2660ea4..334d708 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4958,7 +4958,7 @@ checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "ultralog" -version = "2.9.0" +version = "2.10.0" dependencies = [ "anyhow", "arboard", diff --git a/Cargo.toml b/Cargo.toml index 0365c72..37fecbc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ultralog" -version = "2.9.0" +version = "2.10.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 5d243b6..028c38a 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/ClassicMiniDIY/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-2.9.0-green.svg) +![Version](https://img.shields.io/badge/version-2.10.0-green.svg) --- @@ -151,6 +151,13 @@ Configurable units for 8 measurement categories: - **Features:** Automatic channel and unit detection from BlueDriver live data exports - **Supported data:** All OBD-II PIDs logged by BlueDriver including RPM, speed, coolant temp, MAF, fuel trims, O2 sensors, and more +### Woolich Racing Tuned - Full Support + +- **File type:** CSV exports from Woolich Racing Tuned (WRT) flash-tuning software +- **Features:** `HH:MM:SS.mmm` timestamp normalization, boolean channel parsing (`Clutch In` True/False), unit inference, trailing-comma handling +- **Supported devices:** Yamaha, Kawasaki, Suzuki, Honda, and BMW sportbike ECUs flashed/logged via the WRT datalogger +- **Supported data:** RPM, TPS, IAP (intake air pressure), AFR, Gear, Clutch In, Coolant Temp, IAT, and all logged channels + ### Coming Soon - AEM - MaxxECU @@ -284,6 +291,7 @@ UltraLog automatically detects the ECU format based on file contents: - **AiM:** Identified by ` Ok((l, EcuType::Woolich)), + Err(e) => Err(LoadResult::Error(format!( + "Failed to parse Woolich file: {}", + e + ))), + } } else { // Default to Haltech format let parser = Haltech; diff --git a/src/bin/test_parser.rs b/src/bin/test_parser.rs index dde3627..62edea3 100644 --- a/src/bin/test_parser.rs +++ b/src/bin/test_parser.rs @@ -5,7 +5,7 @@ use std::path::Path; // Import from the library use ultralog::parsers::{ BlueDriver, EcuMaster, EcuType, Emerald, Haltech, Link, Locomotive, MegaSquirt, Parseable, - Speeduino, + Speeduino, Woolich, }; use encoding_rs::{UTF_16BE, UTF_16LE}; @@ -124,6 +124,17 @@ fn main() { std::process::exit(1); } } + } else if Woolich::detect(&contents) { + println!("\nDetected: Woolich Racing Tuned format"); + println!("Parsing Woolich log..."); + let parser = Woolich; + match parser.parse(&contents) { + Ok(log) => (EcuType::Woolich, log), + Err(e) => { + eprintln!("Parse error: {}", e); + std::process::exit(1); + } + } } else { println!("\nDetected: Haltech format"); println!("Parsing Haltech log..."); diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 8dfd69d..30cabe2 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -326,7 +326,7 @@ impl UltraLogMcpServer { } #[tool( - description = "Load an ECU log file. Supports Haltech CSV, ECUMaster CSV, RomRaider CSV, Speeduino/rusEFI MLG, AiM XRK/DRK, and Link LLG formats." + description = "Load an ECU log file. Supports Haltech CSV, ECUMaster CSV, RomRaider CSV, Speeduino/rusEFI MLG, AiM XRK/DRK, Link LLG, and Woolich Racing Tuned CSV formats." )] async fn load_file( &self, diff --git a/src/parsers/mod.rs b/src/parsers/mod.rs index 9b77b00..eea03f1 100644 --- a/src/parsers/mod.rs +++ b/src/parsers/mod.rs @@ -11,6 +11,7 @@ pub mod motorsport_electronics; pub mod romraider; pub mod speeduino; pub mod types; +pub mod woolich; pub use aim::Aim; pub use bluedriver::BlueDriver; @@ -25,3 +26,4 @@ pub use motorsport_electronics::MotorsportElectronics; pub use romraider::RomRaider; pub use speeduino::Speeduino; pub use types::{Channel, EcuType, Log, Parseable, Value}; +pub use woolich::Woolich; diff --git a/src/parsers/types.rs b/src/parsers/types.rs index 396ad89..4808cb3 100644 --- a/src/parsers/types.rs +++ b/src/parsers/types.rs @@ -13,6 +13,7 @@ use super::megasquirt::{MegaSquirtChannel, MegaSquirtMeta}; use super::motorsport_electronics::{MotorsportElectronicsChannel, MotorsportElectronicsMeta}; use super::romraider::{RomRaiderChannel, RomRaiderMeta}; use super::speeduino::{SpeeduinoChannel, SpeeduinoMeta}; +use super::woolich::{WoolichChannel, WoolichMeta}; use crate::adapters::{get_channel_metadata, ChannelCategory, ChannelMetadata}; /// Metadata enum supporting different ECU formats @@ -30,6 +31,7 @@ pub enum Meta { MotorsportElectronics(MotorsportElectronicsMeta), RomRaider(RomRaiderMeta), Speeduino(SpeeduinoMeta), + Woolich(WoolichMeta), #[default] Empty, } @@ -60,6 +62,7 @@ pub enum Channel { MotorsportElectronics(MotorsportElectronicsChannel), RomRaider(RomRaiderChannel), Speeduino(SpeeduinoChannel), + Woolich(WoolichChannel), /// A computed/virtual channel derived from a formula Computed(ComputedChannelInfo), } @@ -82,6 +85,7 @@ impl Serialize for Channel { Channel::MotorsportElectronics(m) => m.serialize(serializer), Channel::RomRaider(r) => r.serialize(serializer), Channel::Speeduino(s) => s.serialize(serializer), + Channel::Woolich(w) => w.serialize(serializer), Channel::Computed(c) => c.serialize(serializer), } } @@ -102,6 +106,7 @@ impl Channel { Channel::MotorsportElectronics(m) => m.name.clone(), Channel::RomRaider(r) => r.name.clone(), Channel::Speeduino(s) => s.name.clone(), + Channel::Woolich(w) => w.name.clone(), Channel::Computed(c) => c.name.clone(), } } @@ -121,6 +126,7 @@ impl Channel { Channel::MotorsportElectronics(m) => m.name.clone(), Channel::RomRaider(r) => r.name.clone(), Channel::Speeduino(s) => s.name.clone(), + Channel::Woolich(w) => w.name.clone(), Channel::Computed(c) => format!("computed_{}", c.name), } } @@ -139,6 +145,7 @@ impl Channel { Channel::MotorsportElectronics(_) => "Motorsport Electronics".to_string(), Channel::RomRaider(_) => "RomRaider".to_string(), Channel::Speeduino(_) => "Speeduino/rusEFI".to_string(), + Channel::Woolich(_) => "Woolich".to_string(), Channel::Computed(_) => "Computed".to_string(), } } @@ -160,6 +167,7 @@ impl Channel { Channel::MotorsportElectronics(_) => None, Channel::RomRaider(_) => None, Channel::Speeduino(_) => None, + Channel::Woolich(_) => None, Channel::Computed(_) => None, }; @@ -184,6 +192,7 @@ impl Channel { Channel::MotorsportElectronics(_) => None, Channel::RomRaider(_) => None, Channel::Speeduino(_) => None, + Channel::Woolich(_) => None, Channel::Computed(_) => None, }; @@ -220,6 +229,7 @@ impl Channel { Channel::MotorsportElectronics(m) => m.unit(), Channel::RomRaider(r) => r.unit(), Channel::Speeduino(s) => s.unit(), + Channel::Woolich(w) => w.unit(), Channel::Computed(c) => &c.unit, } } @@ -317,6 +327,7 @@ pub enum EcuType { Locomotive, RomRaider, Speeduino, + Woolich, Unknown, } @@ -338,6 +349,7 @@ impl EcuType { EcuType::Locomotive => "Locomotive", EcuType::RomRaider => "RomRaider", EcuType::Speeduino => "Speeduino/rusEFI", + EcuType::Woolich => "Woolich", EcuType::Unknown => "Unknown", } } @@ -642,6 +654,7 @@ mod tests { assert_eq!(EcuType::Link.name(), "Link"); assert_eq!(EcuType::RomRaider.name(), "RomRaider"); assert_eq!(EcuType::Speeduino.name(), "Speeduino/rusEFI"); + assert_eq!(EcuType::Woolich.name(), "Woolich"); assert_eq!(EcuType::Unknown.name(), "Unknown"); } diff --git a/src/parsers/woolich.rs b/src/parsers/woolich.rs new file mode 100644 index 0000000..d99a35b --- /dev/null +++ b/src/parsers/woolich.rs @@ -0,0 +1,348 @@ +//! Woolich Racing Tuned (WRT) ECU log file parser. +//! +//! Parses CSV log files exported from Woolich Racing Tuned, the flash-tuning +//! software used on Yamaha, Kawasaki, Suzuki, Honda and BMW sportbike ECUs. +//! These logs come from the bike's stock ECU read back through the WRT +//! datalogger rather than a standalone aftermarket ECU. +//! +//! Format characteristics: +//! - Comma-delimited CSV with a trailing comma (so each row carries an empty +//! final field that must be ignored). +//! - First column is `Log Time`, formatted `HH:MM:SS.mmm` (wall-clock elapsed), +//! which we normalize to relative seconds starting at zero. +//! - Channel names carry no inline units; units are inferred from the name. +//! - Boolean channels such as `Clutch In` are exported as `True`/`False` and +//! are mapped to 1.0 / 0.0 so they can be charted. +//! +//! Example: +//! ```text +//! Log Time,RPM,TPS,IAP,AFR,Gear,Clutch In,Coolant Temp,IAT, +//! 00:00:00.040,0,0.00,100.00,0.0,0,False,24.0,25.0, +//! ``` + +use serde::Serialize; +use std::error::Error; + +use super::types::{Channel, Log, Meta, Parseable, Value}; + +/// Woolich Racing Tuned log file metadata +#[derive(Clone, Debug, Default, Serialize)] +pub struct WoolichMeta { + pub channel_count: usize, + pub data_points: usize, +} + +/// Woolich Racing Tuned channel definition +#[derive(Clone, Debug, Default, Serialize)] +pub struct WoolichChannel { + /// Full column name from the header (e.g. "Coolant Temp") + pub name: String, + /// Unit inferred from the name (WRT does not embed units) + pub unit: String, +} + +impl WoolichChannel { + /// Create a channel from a raw header column name. + pub fn from_header(header: &str) -> Self { + let name = header.trim().to_string(); + let unit = Self::infer_unit(&name); + Self { name, unit } + } + + /// Infer a display unit from the channel name. WRT exports strip units + /// from the header, so map known Woolich channel names back to + /// conventional units. + fn infer_unit(name: &str) -> String { + let n = name.to_lowercase(); + + if n == "rpm" || n.ends_with(" rpm") { + return "RPM".to_string(); + } + + // Throttle / accelerator position + if n == "tps" || n.contains("throttle") || n.contains("aps") { + return "%".to_string(); + } + + // Intake air pressure / manifold pressure (kPa on WRT logs) + if n == "iap" || n == "map" || n.contains("pressure") { + return "kPa".to_string(); + } + + // Air/fuel ratio and lambda + if n.contains("afr") || n.contains("lambda") { + return "AFR".to_string(); + } + + // Temperatures (coolant, intake air, engine, oil) + if n.contains("temp") || n == "iat" || n == "ect" { + return "°C".to_string(); + } + + // Speed + if n.contains("speed") { + return "km/h".to_string(); + } + + // Voltages + if n.contains("voltage") || n.contains("volt") { + return "V".to_string(); + } + + // Ignition advance / timing angles + if n.contains("advance") || n.contains("ignition") || n.contains("timing") { + return "°".to_string(); + } + + // Gear, clutch and other unitless / boolean channels + String::new() + } + + pub fn unit(&self) -> &str { + &self.unit + } +} + +/// Parse a `HH:MM:SS.mmm` Woolich log timestamp into seconds. +fn parse_log_time(value: &str) -> Option { + let value = value.trim(); + let mut parts = value.split(':'); + let hours: f64 = parts.next()?.trim().parse().ok()?; + let minutes: f64 = parts.next()?.trim().parse().ok()?; + let seconds: f64 = parts.next()?.trim().parse().ok()?; + // A well-formed timestamp has exactly three components. + if parts.next().is_some() { + return None; + } + Some(hours * 3600.0 + minutes * 60.0 + seconds) +} + +/// Parse a single Woolich field into an f64, mapping booleans to 1.0 / 0.0. +fn parse_field(value: &str) -> f64 { + let v = value.trim(); + if v.eq_ignore_ascii_case("true") { + 1.0 + } else if v.eq_ignore_ascii_case("false") { + 0.0 + } else { + v.parse::().unwrap_or(0.0) + } +} + +/// Woolich Racing Tuned log parser +pub struct Woolich; + +impl Woolich { + /// Detect if the file contents look like a Woolich Racing Tuned CSV export. + /// + /// Requires the first column to be exactly `Log Time` and the first data + /// row to begin with an `HH:MM:SS.mmm` timestamp. No other supported + /// format uses a `Log Time` header, so this is specific enough on its own. + pub fn detect(contents: &str) -> bool { + let mut lines = contents.lines(); + + let Some(header) = lines.next() else { + return false; + }; + if !header.contains(',') { + return false; + } + let first_col = header.split(',').next().unwrap_or("").trim(); + if !first_col.eq_ignore_ascii_case("Log Time") { + return false; + } + + // Confirm the first non-empty data row starts with a valid timestamp. + for line in lines { + let line = line.trim(); + if line.is_empty() { + continue; + } + let first_field = line.split(',').next().unwrap_or(""); + return parse_log_time(first_field).is_some(); + } + + // Header matched but there were no data rows; accept on the header alone. + true + } +} + +impl Parseable for Woolich { + fn parse(&self, file_contents: &str) -> Result> { + let line_count = file_contents.lines().count(); + let estimated_rows = line_count.saturating_sub(1); + + let mut lines = file_contents.lines(); + let header = lines.next().ok_or("Empty file: no header found")?; + + let column_names: Vec<&str> = header.split(',').collect(); + if column_names.is_empty() { + return Err("Invalid Woolich log: no columns found".into()); + } + if !column_names[0].trim().eq_ignore_ascii_case("Log Time") { + return Err("Invalid Woolich log: first column must be Log Time".into()); + } + + // The header carries a trailing comma, so the final field is empty. + // Build channels from every non-empty column after "Log Time". + let mut channels: Vec = Vec::with_capacity(column_names.len()); + // Column index (into the raw row split) for each channel. + let mut channel_columns: Vec = Vec::with_capacity(column_names.len()); + for (idx, name) in column_names.iter().enumerate().skip(1) { + if name.trim().is_empty() { + continue; + } + channels.push(Channel::Woolich(WoolichChannel::from_header(name))); + channel_columns.push(idx); + } + + if channels.is_empty() { + return Err("Invalid Woolich log: no data channels found".into()); + } + + let mut times: Vec = Vec::with_capacity(estimated_rows); + let mut data: Vec> = Vec::with_capacity(estimated_rows); + let mut first_time: Option = None; + + for line in lines { + let line = line.trim(); + if line.is_empty() { + continue; + } + + let fields: Vec<&str> = line.split(',').collect(); + let Some(time_val) = fields.first().and_then(|f| parse_log_time(f)) else { + continue; + }; + + let relative_time = match first_time { + Some(first) => time_val - first, + None => { + first_time = Some(time_val); + 0.0 + } + }; + times.push(relative_time); + + let mut row: Vec = Vec::with_capacity(channels.len()); + for &col in &channel_columns { + let value = fields.get(col).map(|f| parse_field(f)).unwrap_or(0.0); + row.push(Value::Float(value)); + } + data.push(row); + } + + tracing::info!( + "Parsed Woolich log: {} channels, {} data points", + channels.len(), + data.len() + ); + + Ok(Log { + meta: Meta::Woolich(WoolichMeta { + channel_count: channels.len(), + data_points: data.len(), + }), + channels, + times, + data, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = "Log Time,RPM,TPS,IAP,AFR,Gear,Clutch In,Coolant Temp,IAT,\n\ + 00:00:00.040,0,0.00,100.00,0.0,0,False,24.0,25.0,\n\ + 00:00:00.100,1224,12.50,59.00,13.2,0,True,92.0,26.0,\n\ + 00:00:00.160,3065,7.84,91.00,14.0,2,False,27.0,26.0,\n"; + + #[test] + fn detects_woolich_header() { + assert!(Woolich::detect(SAMPLE)); + } + + #[test] + fn rejects_other_formats() { + // RomRaider-style leading "Time" column + assert!(!Woolich::detect("Time (msec),RPM,Load\n0,1000,0.5\n")); + // ECUMaster + assert!(!Woolich::detect("TIME;engine/rpm\n0.0;1000\n")); + // ME221 generic Time CSV + assert!(!Woolich::detect("Time,RPM,Load\n0,1000,50\n")); + // Locomotive + assert!(!Woolich::detect("TimeStamp: Sat Nov 15 19:00:03 2025\n")); + } + + #[test] + fn rejects_log_time_header_without_timestamp_rows() { + // Header matches but data row is not a HH:MM:SS timestamp. + assert!(!Woolich::detect("Log Time,RPM\n1.23,1000\n")); + } + + #[test] + fn parses_sample_log() { + let log = Woolich.parse(SAMPLE).expect("should parse"); + + // 8 channels (trailing empty column dropped, Log Time excluded). + assert_eq!(log.channels.len(), 8); + assert_eq!(log.data.len(), 3); + assert_eq!(log.times.len(), 3); + + assert_eq!(log.channels[0].name(), "RPM"); + assert_eq!(log.channels[5].name(), "Clutch In"); + assert_eq!(log.channels[6].name(), "Coolant Temp"); + + // Times are normalized to start at zero (60 ms steps). + assert!((log.times[0] - 0.0).abs() < 1e-9); + assert!((log.times[1] - 0.06).abs() < 1e-3); + assert!((log.times[2] - 0.12).abs() < 1e-3); + + // RPM column alignment. + assert!((log.data[1][0].as_f64() - 1224.0).abs() < 1e-9); + assert!((log.data[2][0].as_f64() - 3065.0).abs() < 1e-9); + + // Boolean Clutch In maps to 1.0 / 0.0. + assert!((log.data[0][5].as_f64() - 0.0).abs() < 1e-9); + assert!((log.data[1][5].as_f64() - 1.0).abs() < 1e-9); + } + + #[test] + fn parse_log_time_formats() { + assert!((parse_log_time("00:00:00.040").unwrap() - 0.040).abs() < 1e-9); + assert!((parse_log_time("00:01:30.500").unwrap() - 90.5).abs() < 1e-9); + assert!((parse_log_time("01:00:00.000").unwrap() - 3600.0).abs() < 1e-9); + assert!(parse_log_time("not a time").is_none()); + assert!(parse_log_time("1.23").is_none()); + assert!(parse_log_time("00:00").is_none()); + } + + #[test] + fn infers_units() { + assert_eq!(WoolichChannel::from_header("RPM").unit, "RPM"); + assert_eq!(WoolichChannel::from_header("TPS").unit, "%"); + assert_eq!(WoolichChannel::from_header("IAP").unit, "kPa"); + assert_eq!(WoolichChannel::from_header("AFR").unit, "AFR"); + assert_eq!(WoolichChannel::from_header("Coolant Temp").unit, "°C"); + assert_eq!(WoolichChannel::from_header("IAT").unit, "°C"); + assert_eq!(WoolichChannel::from_header("Gear").unit, ""); + assert_eq!(WoolichChannel::from_header("Clutch In").unit, ""); + } + + #[test] + fn empty_file_fails_gracefully() { + assert!(Woolich.parse("").is_err()); + } + + #[test] + fn header_only_yields_no_rows() { + let log = Woolich + .parse("Log Time,RPM,TPS,IAP,AFR,Gear,Clutch In,Coolant Temp,IAT,") + .expect("header only parse"); + assert_eq!(log.channels.len(), 8); + assert_eq!(log.data.len(), 0); + assert_eq!(log.times.len(), 0); + } +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 528fba6..95180de 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -73,6 +73,9 @@ pub mod example_files { 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"; + + // Woolich Racing Tuned example files + pub const WOOLICH_STANDARD: &str = "exampleLogs/woolich/Woolich.csv"; } /// Test data generators for synthetic tests diff --git a/tests/parsers/format_detection_tests.rs b/tests/parsers/format_detection_tests.rs index 3a10132..e03f511 100644 --- a/tests/parsers/format_detection_tests.rs +++ b/tests/parsers/format_detection_tests.rs @@ -13,6 +13,7 @@ use ultralog::parsers::ecumaster::EcuMaster; use ultralog::parsers::link::Link; use ultralog::parsers::romraider::RomRaider; use ultralog::parsers::speeduino::Speeduino; +use ultralog::parsers::woolich::Woolich; // ============================================ // Format Marker Tests @@ -68,6 +69,12 @@ fn test_link_detection() { assert!(Link::detect(&llg), "Should detect Link LLG format"); } +#[test] +fn test_woolich_detection() { + let woolich = "Log Time,RPM,TPS,IAP\n00:00:00.040,0,0.00,100.00\n"; + assert!(Woolich::detect(woolich), "Should detect Woolich"); +} + // ============================================ // Mutual Exclusion Tests // ============================================ @@ -176,6 +183,36 @@ fn test_link_not_detected_as_others() { assert!(!Aim::detect(&llg), "Link should not be AiM"); } +#[test] +fn test_woolich_not_detected_as_others() { + let woolich = "Log Time,RPM,TPS,IAP\n00:00:00.040,0,0.00,100.00\n"; + + assert!( + !woolich.starts_with("%DataLog%"), + "Woolich should not be Haltech" + ); + assert!( + !EcuMaster::detect(woolich), + "Woolich should not be ECUMaster" + ); + assert!( + !RomRaider::detect(woolich), + "Woolich should not be RomRaider (leading 'Log Time' != 'Time')" + ); + assert!( + !Speeduino::detect(woolich.as_bytes()), + "Woolich should not be MLG" + ); + assert!( + !Aim::detect(woolich.as_bytes()), + "Woolich should not be AiM" + ); + assert!( + !Link::detect(woolich.as_bytes()), + "Woolich should not be Link" + ); +} + // ============================================ // Real File Detection Tests // ============================================ @@ -262,6 +299,30 @@ fn test_detect_link_example_file() { assert!(!Aim::detect(&data), "Should not detect as AiM"); } +#[test] +fn test_detect_woolich_example_file() { + if !example_file_exists(WOOLICH_STANDARD) { + eprintln!("Skipping: {} not found", WOOLICH_STANDARD); + return; + } + + let content = read_example_file(WOOLICH_STANDARD); + + assert!(Woolich::detect(&content), "Should detect as Woolich"); + assert!( + !content.starts_with("%DataLog%"), + "Should not detect as Haltech" + ); + assert!( + !EcuMaster::detect(&content), + "Should not detect as ECUMaster" + ); + assert!( + !RomRaider::detect(&content), + "Should not detect as RomRaider" + ); +} + // ============================================ // Edge Cases // ============================================ diff --git a/tests/parsers/mod.rs b/tests/parsers/mod.rs index be8852e..0226110 100644 --- a/tests/parsers/mod.rs +++ b/tests/parsers/mod.rs @@ -15,3 +15,4 @@ pub mod link_tests; pub mod motorsport_electronics_tests; pub mod romraider_tests; pub mod speeduino_tests; +pub mod woolich_tests; diff --git a/tests/parsers/woolich_tests.rs b/tests/parsers/woolich_tests.rs new file mode 100644 index 0000000..a423e86 --- /dev/null +++ b/tests/parsers/woolich_tests.rs @@ -0,0 +1,146 @@ +//! Comprehensive tests for the Woolich Racing Tuned (WRT) parser. +//! +//! Tests cover: +//! - Format detection (and rejection of similar "Time"-prefixed CSVs) +//! - `HH:MM:SS.mmm` timestamp parsing normalized to relative seconds +//! - Boolean channel handling (`True`/`False` -> 1.0 / 0.0) +//! - Unit inference from channel names (WRT doesn't embed units) +//! - Real file parsing using the bundled Woolich example log + +#[path = "../common/mod.rs"] +mod common; + +use common::assertions::*; +use common::example_files::WOOLICH_STANDARD; +use common::float_cmp::*; +use common::{example_file_exists, read_example_file}; +use ultralog::parsers::types::Parseable; +use ultralog::parsers::woolich::Woolich; + +const SAMPLE: &str = "Log Time,RPM,TPS,IAP,AFR,Gear,Clutch In,Coolant Temp,IAT,\n\ + 00:00:00.040,0,0.00,100.00,0.0,0,False,24.0,25.0,\n\ + 00:00:00.100,1224,12.50,59.00,13.2,0,True,92.0,26.0,\n\ + 00:00:00.160,3065,7.84,91.00,14.0,2,False,27.0,26.0,\n"; + +// ============================================ +// Detection +// ============================================ + +#[test] +fn detects_synthetic_woolich_header() { + assert!(Woolich::detect(SAMPLE)); +} + +#[test] +fn rejects_romraider_header() { + let romraider = "Time (msec),Engine Speed (rpm),Throttle (%)\n0,1000,5\n"; + assert!(!Woolich::detect(romraider)); +} + +#[test] +fn rejects_me221_generic_time_csv() { + let generic = "Time,RPM,Load\n0,1000,50\n"; + assert!(!Woolich::detect(generic)); +} + +#[test] +fn rejects_log_time_header_with_non_timestamp_rows() { + // "Log Time" header but the data is plain seconds, not HH:MM:SS. + assert!(!Woolich::detect("Log Time,RPM\n1.23,1000\n")); +} + +// ============================================ +// Timestamp handling +// ============================================ + +#[test] +fn normalizes_first_time_to_zero() { + let log = Woolich.parse(SAMPLE).expect("parses"); + assert_approx_eq(log.times[0], 0.0, 1e-9); + assert_approx_eq(log.times[1], 0.06, 1e-6); + assert_approx_eq(log.times[2], 0.12, 1e-6); +} + +// ============================================ +// Boolean channels +// ============================================ + +#[test] +fn boolean_clutch_maps_to_one_and_zero() { + let log = Woolich.parse(SAMPLE).expect("parses"); + let clutch_idx = log + .channels + .iter() + .position(|c| c.name() == "Clutch In") + .expect("Clutch In channel present"); + + assert_approx_eq(log.data[0][clutch_idx].as_f64(), 0.0, 1e-9); // False + assert_approx_eq(log.data[1][clutch_idx].as_f64(), 1.0, 1e-9); // True +} + +// ============================================ +// Unit inference +// ============================================ + +#[test] +fn infers_units_for_common_channels() { + let log = Woolich.parse(SAMPLE).expect("parses"); + + let units: std::collections::HashMap = log + .channels + .iter() + .map(|c| (c.name(), c.unit().to_string())) + .collect(); + + assert_eq!(units.get("RPM").map(String::as_str), Some("RPM")); + assert_eq!(units.get("TPS").map(String::as_str), Some("%")); + assert_eq!(units.get("IAP").map(String::as_str), Some("kPa")); + assert_eq!(units.get("AFR").map(String::as_str), Some("AFR")); + assert_eq!(units.get("Coolant Temp").map(String::as_str), Some("°C")); + assert_eq!(units.get("IAT").map(String::as_str), Some("°C")); +} + +// ============================================ +// Trailing comma handling +// ============================================ + +#[test] +fn drops_trailing_empty_column() { + // The header carries a trailing comma; it must not become a phantom channel. + let log = Woolich.parse(SAMPLE).expect("parses"); + assert_eq!(log.channels.len(), 8); + assert_eq!(log.data[0].len(), 8); +} + +// ============================================ +// Real example file +// ============================================ + +#[test] +fn parses_woolich_standard_example_file() { + if !example_file_exists(WOOLICH_STANDARD) { + eprintln!("Skipping test: {} not found", WOOLICH_STANDARD); + return; + } + + let content = read_example_file(WOOLICH_STANDARD); + assert!(Woolich::detect(&content), "Woolich log should be detected"); + + let log = Woolich.parse(&content).expect("Should parse Woolich log"); + + assert_valid_log_structure(&log); + assert_finite_values(&log); + assert_monotonic_times(&log); + assert_valid_time_range(&log); + + // The bundled log has 8 channels and ~28k records over several minutes. + assert_eq!(log.channels.len(), 8); + assert_minimum_records(&log, 1000); + + let last = *log.get_times_as_f64().last().unwrap(); + assert!( + (10.0..=3600.0).contains(&last), + "Last timestamp ({}) should be in seconds", + last + ); +} From 9681d3b9c46693708d3c6948c48f708d302a2d4f Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Tue, 2 Jun 2026 10:26:12 -0400 Subject: [PATCH 2/3] fix(woolich): address PR #71 review feedback - Strip UTF-8 BOM in detect() and parse() for Windows CSV exports - Reuse a single field buffer across rows to avoid per-row allocations - Add UTF-8 BOM regression test --- src/parsers/woolich.rs | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/parsers/woolich.rs b/src/parsers/woolich.rs index d99a35b..a723d6d 100644 --- a/src/parsers/woolich.rs +++ b/src/parsers/woolich.rs @@ -139,6 +139,8 @@ impl Woolich { /// row to begin with an `HH:MM:SS.mmm` timestamp. No other supported /// format uses a `Log Time` header, so this is specific enough on its own. pub fn detect(contents: &str) -> bool { + // Strip UTF-8 BOM if present (common on Windows CSV exports). + let contents = contents.trim_start_matches('\u{FEFF}'); let mut lines = contents.lines(); let Some(header) = lines.next() else { @@ -169,6 +171,8 @@ impl Woolich { impl Parseable for Woolich { fn parse(&self, file_contents: &str) -> Result> { + // Strip UTF-8 BOM if present (common on Windows CSV exports). + let file_contents = file_contents.trim_start_matches('\u{FEFF}'); let line_count = file_contents.lines().count(); let estimated_rows = line_count.saturating_sub(1); @@ -203,6 +207,9 @@ impl Parseable for Woolich { let mut times: Vec = Vec::with_capacity(estimated_rows); let mut data: Vec> = Vec::with_capacity(estimated_rows); let mut first_time: Option = None; + // Reuse one buffer for line splitting to avoid a heap allocation per + // row (logs routinely run tens of thousands of rows). + let mut fields: Vec<&str> = Vec::with_capacity(column_names.len()); for line in lines { let line = line.trim(); @@ -210,7 +217,8 @@ impl Parseable for Woolich { continue; } - let fields: Vec<&str> = line.split(',').collect(); + fields.clear(); + fields.extend(line.split(',')); let Some(time_val) = fields.first().and_then(|f| parse_log_time(f)) else { continue; }; @@ -331,6 +339,18 @@ mod tests { assert_eq!(WoolichChannel::from_header("Clutch In").unit, ""); } + #[test] + fn handles_utf8_bom() { + // Windows CSV exports often prepend a UTF-8 BOM. + let with_bom = format!("\u{FEFF}{}", SAMPLE); + assert!(Woolich::detect(&with_bom)); + + let log = Woolich.parse(&with_bom).expect("should parse with BOM"); + assert_eq!(log.channels.len(), 8); + assert_eq!(log.channels[0].name(), "RPM"); + assert_eq!(log.data.len(), 3); + } + #[test] fn empty_file_fails_gracefully() { assert!(Woolich.parse("").is_err()); From e22f3318022837c4a74da5dccc7682f755cc96a9 Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Tue, 2 Jun 2026 11:00:29 -0400 Subject: [PATCH 3/3] fix(woolich): address PR #71 round-2 review feedback - Estimate row capacity from byte length instead of a second full file scan via lines().count() - Handle wall-clock midnight wrap-around: accumulate a 24h offset when Log Time jumps backwards, keeping relative times monotonic - Add midnight-crossing regression test --- src/parsers/woolich.rs | 42 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/src/parsers/woolich.rs b/src/parsers/woolich.rs index a723d6d..f7386af 100644 --- a/src/parsers/woolich.rs +++ b/src/parsers/woolich.rs @@ -173,8 +173,9 @@ impl Parseable for Woolich { fn parse(&self, file_contents: &str) -> Result> { // Strip UTF-8 BOM if present (common on Windows CSV exports). let file_contents = file_contents.trim_start_matches('\u{FEFF}'); - let line_count = file_contents.lines().count(); - let estimated_rows = line_count.saturating_sub(1); + // Estimate row count from byte length (rows are ~50 bytes) to size the + // output buffers without a second full pass over the file. + let estimated_rows = (file_contents.len() / 50).max(16); let mut lines = file_contents.lines(); let header = lines.next().ok_or("Empty file: no header found")?; @@ -207,6 +208,11 @@ impl Parseable for Woolich { let mut times: Vec = Vec::with_capacity(estimated_rows); let mut data: Vec> = Vec::with_capacity(estimated_rows); let mut first_time: Option = None; + // `Log Time` is a wall-clock time-of-day, so a session that crosses + // midnight wraps from ~86400 back to 0. Track the previous in-day value + // and accumulate a 24h offset each time the clock jumps backwards. + let mut prev_raw = f64::NEG_INFINITY; + let mut day_offset = 0.0_f64; // Reuse one buffer for line splitting to avoid a heap allocation per // row (logs routinely run tens of thousands of rows). let mut fields: Vec<&str> = Vec::with_capacity(column_names.len()); @@ -219,10 +225,18 @@ impl Parseable for Woolich { fields.clear(); fields.extend(line.split(',')); - let Some(time_val) = fields.first().and_then(|f| parse_log_time(f)) else { + let Some(raw) = fields.first().and_then(|f| parse_log_time(f)) else { continue; }; + // A backwards jump of more than a second means midnight was crossed + // (the 1s guard avoids tripping on duplicate/jittered timestamps). + if raw + 1.0 < prev_raw { + day_offset += 86_400.0; + } + prev_raw = raw; + let time_val = raw + day_offset; + let relative_time = match first_time { Some(first) => time_val - first, None => { @@ -339,6 +353,28 @@ mod tests { assert_eq!(WoolichChannel::from_header("Clutch In").unit, ""); } + #[test] + fn handles_midnight_wraparound() { + // Session starts just before midnight and crosses into the next day. + let log_str = "Log Time,RPM,\n\ + 23:59:59.000,1000,\n\ + 23:59:59.500,1100,\n\ + 00:00:00.500,1200,\n\ + 00:00:01.500,1300,\n"; + + let log = Woolich.parse(log_str).expect("should parse"); + assert_eq!(log.times.len(), 4); + + // Times must stay monotonically increasing across the midnight boundary. + assert!((log.times[0] - 0.0).abs() < 1e-9); + assert!((log.times[1] - 0.5).abs() < 1e-9); + assert!((log.times[2] - 1.5).abs() < 1e-9); + assert!((log.times[3] - 2.5).abs() < 1e-9); + for w in log.times.windows(2) { + assert!(w[1] >= w[0], "times must not jump backwards at midnight"); + } + } + #[test] fn handles_utf8_bom() { // Windows CSV exports often prepend a UTF-8 BOM.