From 1c2305fea8d978fc0db6ed298283dc048fd7dd48 Mon Sep 17 00:00:00 2001 From: James Barney Date: Fri, 18 Sep 2026 12:35:09 -0500 Subject: [PATCH 1/6] feat(analysis): lambda delay and acceleration enrichment table generators (#3, #4) (#90) * feat(analysis): add table generators for lambda delay (#4) and accel enrichment (#3) Adds src/analysis/tables/: a sibling TableAnalyzer trait for generators whose output is a 2-D tuning table rather than one value per timestamp, with robust statistics (median/MAD, update instants, effective update interval), edge-list axes with lower-edge-inclusive binning and confidence tiers, channel roles with three-tier auto-suggestion, event primitives (sentinel masking, steadiness gates, sample-and-hold aware crossing interpolation, rate runs), an accumulator that folds several logs into one grid and re-bins per measure, and CSV/clipboard export. Lambda delay: injector pulse-width steps to the first wideband crossing of max(k*sigma, min_delta), gated on steady RPM/load, spacing, fuel cut, clutch, coolant and closed-loop movement; dead time primary, t63 secondary; strict/relaxed profiles; ms, engine cycles or ignition events on export. Accel enrichment: tip-ins from a native throttle-rate channel (scale detected) or a computed TPS/MAP derivative, AFR window shifted by a session lambda-delay cell or an assumed delay, signed excursion depth, duration and area against target or baseline, suggested starting correction clamped, "additional" kind when an ECU AE-activity role is mapped. Synthetic ground-truth tests cover 10-100 Hz logs, 10 Hz sensors inside 100 Hz logs, noise, AFR vs lambda input, every rejection reason, ragged logs, and lambda-delay to AE composition. LoadedFile gains a per-load nonce so accumulated events never key on a file index; the histogram's cell math now shares binning::uniform_bin. Claude-Session: https://claude.ai/code/session_01VFsMnFcGNJfTPz6QBiEEUN * feat(normalize): map injector on-time and throttle-rate channel names Injector 1 On Time, Injection Stage 1 Average Injection Time, Injection Effective/Actual PW, Fuel: Last inj pulse width, Base PW and INJ Duration(ms) now normalize to Pulse Width; TPS DOT, Throttle Position Derivative and TPS Delta to a new TPS Rate canonical, so the table generators' auto-suggestion resolves them outright. Claude-Session: https://claude.ai/code/session_01VFsMnFcGNJfTPz6QBiEEUN * test(analysis): table generator fixtures against MegaSquirt, Haltech, rusEFI logs Auto-suggestion regression on all three, driving-log rejection breakdown and low-rate warning on MegaSquirt, sentinel masking and the strict >= 90 % rejection on the Haltech blip log, tip-in detection checked against Haltech's Transient Throttle Load Derivative and rusEFI's Fuel: TPS AE Active, AE events binned and exported, lambda delay grid feeding AE, and a timing guard on the 88 MB Haltech log when present. Claude-Session: https://claude.ai/code/session_01VFsMnFcGNJfTPz6QBiEEUN * feat(ui): add table generator window with heatmap, inspector and export Tools panel gains a Table Generators section opening a window with setup (auto-suggested channel roles with ambiguity flags, editable axis edges, parameter grid, gating profile), a Viridis heatmap with value text and confidence-coloured count badges, hover tooltips, a per-cell event inspector with jump-to-time, add/remove log, reset, measure selector, CSV export and tab-separated clipboard copy. Strings under table_gen.* in all 15 locales. Claude-Session: https://claude.ai/code/session_01VFsMnFcGNJfTPz6QBiEEUN * docs: describe the table generators and record implementation notes Brings the 2026-07-16 design doc onto main with an implementation-notes section, adds the Table Generators contracts to CLAUDE.md and the feature to the README. Claude-Session: https://claude.ai/code/session_01VFsMnFcGNJfTPz6QBiEEUN --------- Co-authored-by: Claude --- CLAUDE.md | 71 +- README.md | 4 + .../2026-07-16-tuning-table-generators.md | 362 +++++ i18n/ar.yaml | 48 + i18n/bn.yaml | 48 + i18n/de.yaml | 48 + i18n/en.yaml | 48 + i18n/es.yaml | 49 + i18n/fr.yaml | 48 + i18n/hi.yaml | 48 + i18n/id.yaml | 48 + i18n/it.yaml | 48 + i18n/ja.yaml | 48 + i18n/pt-BR.yaml | 49 + i18n/pt-PT.yaml | 48 + i18n/ru.yaml | 48 + i18n/ur.yaml | 48 + i18n/zh-CN.yaml | 49 + src/analysis/mod.rs | 1 + src/analysis/tables/accel_enrich.rs | 1217 ++++++++++++++++ src/analysis/tables/binning.rs | 471 ++++++ src/analysis/tables/channel_map.rs | 625 ++++++++ src/analysis/tables/events.rs | 309 ++++ src/analysis/tables/export.rs | 363 +++++ src/analysis/tables/lambda_delay.rs | 1289 +++++++++++++++++ src/analysis/tables/mod.rs | 510 +++++++ src/analysis/tables/stats.rs | 198 +++ src/analysis/tables/synthetic.rs | 170 +++ src/app.rs | 5 + src/normalize.rs | 28 + src/state.rs | 10 + src/ui/histogram.rs | 14 +- src/ui/mod.rs | 1 + src/ui/table_generator.rs | 1103 ++++++++++++++ src/ui/tools_panel.rs | 58 + tests/core/mod.rs | 1 + tests/core/table_generator_tests.rs | 528 +++++++ 37 files changed, 8053 insertions(+), 8 deletions(-) create mode 100644 docs/plans/2026-07-16-tuning-table-generators.md create mode 100644 src/analysis/tables/accel_enrich.rs create mode 100644 src/analysis/tables/binning.rs create mode 100644 src/analysis/tables/channel_map.rs create mode 100644 src/analysis/tables/events.rs create mode 100644 src/analysis/tables/export.rs create mode 100644 src/analysis/tables/lambda_delay.rs create mode 100644 src/analysis/tables/mod.rs create mode 100644 src/analysis/tables/stats.rs create mode 100644 src/analysis/tables/synthetic.rs create mode 100644 src/ui/table_generator.rs create mode 100644 tests/core/table_generator_tests.rs diff --git a/CLAUDE.md b/CLAUDE.md index 7ab3ecca..a9910795 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,17 @@ src/ │ ├── afr.rs # AFR/Lambda analysis (fuel trim drift CUSUM, rich/lean zones) │ ├── derived.rs # Derived metrics (Volumetric Efficiency, injector duty cycle, ...) │ ├── filters.rs # Signal-processing filters (moving average, etc.) -│ └── statistics.rs # Descriptive statistics and correlation analysis +│ ├── statistics.rs # Descriptive statistics and correlation analysis +│ └── tables/ # Table generators (issues #3, #4) — see Table Generators below +│ ├── mod.rs # TableAnalyzer trait, TableEvent/RejectReason/RunReport, TableAccumulator +│ ├── stats.rs # median/MAD/percentile, update instants, effective update interval, robust sigma +│ ├── binning.rs # AxisSpec (edge lists), CellStats, Confidence, TableGrid, uniform_bin +│ ├── channel_map.rs # ChannelRole, ChannelMapping, three-tier auto-suggestion +│ ├── events.rs # invalid-sample masking, steadiness, interpolated crossings, rate runs +│ ├── lambda_delay.rs # Lambda delay table generator (#4) +│ ├── accel_enrich.rs # Acceleration enrichment table generator (#3) +│ ├── export.rs # CSV / clipboard TSV rendering, delay unit conversion +│ └── synthetic.rs # Synthetic-log builder + xorshift RNG for ground-truth tests ├── ipc/ │ ├── mod.rs # IPC module exports, DEFAULT_IPC_PORT │ ├── commands.rs # IpcCommand/IpcResponse wire types shared with mcp/client.rs @@ -122,6 +132,7 @@ src/ ├── settings_panel.rs # Consolidated settings (display, units, normalization, updates) ├── tool_properties_panel.rs # Dynamic panel showing controls for the active tool (channels / histogram / scatter) ├── analysis_panel.rs # Window for running analysis algorithms (src/analysis) on the active log + ├── table_generator.rs # Table generator window (setup / heatmap results / event inspector / export) ├── data_panel.rs # Right-side data panel hosting DataWidget panes (rail, header, hide/restore) ├── widgets/ │ ├── mod.rs # DataWidget trait + static widget registry @@ -234,6 +245,63 @@ Trait-based framework (`Analyzer`) for algorithms that process log data and can The `analysis_panel.rs` UI module (see below) hosts these analyzers, with category tabs and configurable parameters per algorithm. +### Table Generators (src/analysis/tables/) + +Two generators mine events out of loaded logs into 2-D tuning tables (design doc: +`docs/plans/2026-07-16-tuning-table-generators.md`, revised per the 2026-09-17 review on issues #3/#4): + +- **`lambda_delay.rs`** (issue #4) - injector pulse-width steps → first wideband crossing of + `max(k·σ, min_delta)`, binned RPM × load (MAP or TPS). Primary value is dead time (ms); t63, + response magnitude and step size are alternate measures. Strict/relaxed gating profiles. +- **`accel_enrich.rs`** (issue #3) - tip-ins from a native throttle-rate channel (scale detected; + Haltech's parser already divides ×10) or a computed TPS/MAP derivative → delay-compensated + signed excursion vs target/baseline, binned RPM × peak rate. Measures: correction %, depth, + duration, area-based %, area, delay used. When an `AeActive` role is mapped the table kind is + *additional* (multiply the ECU's current value) and events without ECU activity are rejected + (`AeKindMismatch`) so one cell never mixes kinds. + +They implement the sibling `TableAnalyzer` trait (not `Analyzer`, whose result is one value per +timestamp). Every event, accepted or rejected with a `RejectReason`, stays in the list so the +inspector and the run report's rejection breakdown ("41 events found · 39 rejected: 30 unsteady, +9 no response") can tell the user what to log next. `TableAccumulator` keeps raw per-event values +(medians/MADs cannot be merged incrementally) so logs can be added and removed, and re-bins per +measure on demand. Cells are median + MAD + `Confidence` (Empty / Low / Medium / High); empty and +low cells are never interpolated and export blank. + +**Load-bearing behaviors:** + +- **Sample-and-hold interpolation** (`events::find_crossing`, `stats::update_instants`) - CAN + widebands often update at 10-20 Hz inside a 50-500 Hz log. Noise (`robust_sigma_diff`) and + crossings are computed over distinct-value *update instants*, never raw samples (most raw first + differences are exactly zero). The previous reading for interpolation is the last update, but no + earlier than `t - effective_update_interval`: a flat baseline is still being sampled every + interval, so interpolating from its last value change would place every crossing far too early. +- **Invalid-sample masking** (`events::mask_invalid`) - Haltech writes an i32 sentinel family + (`-2147483617`, `…637`, …) for "no reading"; those and out-of-band samples become `NaN` before any + math, and a window with >10 % masked lambda rejects as `InvalidSamples`. +- **Alignment** (`tables::mapped_column`) - `Log::get_channel_data` drops ragged rows, so a column + can be shorter than `times`; the generators refuse with `ComputationError` rather than index + misaligned pairs (same contract as `channel_series` in the IPC handler). +- **Log identity** - accumulated events key on `LoadedFile::load_id`, a per-load nonce, never the + file index (shifts when a tab closes) or the bare file name (every rusEFI install has a + `Log1.mlg`). The UI's mapping cache keys on the same nonce. +- **Lambda-delay → AE composition** - `GeneratorContext::delay_table` carries the session's + lambda-delay grid; an AE event uses the matching cell's median when that cell is Medium/High, + else `assumed_delay_ms`, and records which in `TableEvent::note` and the `delay_used_ms` measure. +- **Axis cap** - `binning::MAX_BINS_PER_AXIS` (64) keeps any future MCP payload well under the + 512 KiB response guard. `histogram.rs` now calls `binning::uniform_bin` for its cell math so both + tools agree on boundaries. +- **Auto-suggestion** (`channel_map::suggest_mapping`) - normalization hit (100) → strong name + hints (50) → spec category + hint (60) → generic hints (40), then a data-plausibility veto on the + channel median; `overall|avg|average` names lose 10 points so a single sensor beats an averaged + one (averaging sensors with different transport delays smears the rise). Ties within 10 points + are flagged ⚠ in the UI. New built-in normalization entries back this: injector on-time names → + `Pulse Width`, and `TPS DOT` / `Throttle Position Derivative` / `TPS Delta` → `TPS Rate`. + +Not yet implemented from the design: MCP tools for the generators, per-ECU mapping presets on +disk (mapping and accumulators are session-only), the windowed cross-correlation mode, and the +wiki page. + ### IPC + MCP System (src/ipc/, src/mcp/) UltraLog embeds an MCP (Model Context Protocol) HTTP server so Claude Desktop can drive the running GUI — select channels, add computed channels, and query log data. @@ -485,6 +553,7 @@ The Track Map widget can draw map tile backgrounds. Tiles are **opt-in** (off by - **Multi-ECU Support** - Haltech, ECUMaster, RomRaider, Speeduino, rusEFI, AiM, Link, Emerald, MegaSquirt, TunerStudio MSL, MHD Tuning, Motorsport Electronics, RaceChrono, Woolich Racing Tuned, BlueDriver, DynamicEFI, and Locomotive log formats - **Computed Channels** - Create virtual channels from mathematical formulas with time-shifting (e.g., `RPM[-1]`, `Boost@-0.5s`) - **Analysis Algorithms** - AFR/Lambda drift and zone detection, derived metrics (VE, injector duty cycle), signal filters, and descriptive statistics (`src/analysis/`) +- **Table Generators** - Lambda delay and acceleration enrichment tuning tables mined from one or more logs, with auto-suggested channel roles, confidence-tiered cells, an event inspector, and CSV/clipboard export (`src/analysis/tables/`, `src/ui/table_generator.rs`) - **GPS Track Map** - Right-side data panel with a track map: lap detection, channel-colored polyline (Viridis/Turbo with editable range), hover-scrub/click-seek cursor sync, and opt-in Esri/OSM tile backgrounds (`src/ui/widgets/track_map.rs`, `src/tiles.rs`, `src/laps.rs`). GPS coordinate encodings are auto-detected and normalized to decimal degrees (`GpsCoordSpec` in `src/laps.rs`): NMEA `DDMM.mmmm`, milli/micro/1e-7-scaled integer degrees, and 0-360 longitude. Detection is conservative - values already in valid degree ranges are never transformed, and radians are deliberately not detected (ambiguous with genuine near-equator degree tracks). - **Claude Desktop / MCP Integration** - Embedded MCP server (`src/mcp/`) lets Claude control the running app over `http://localhost:52385/mcp` — select channels, add computed channels, query log data - **Unit Preferences** - Users can select display units for temperature, pressure, speed, distance, fuel economy, volume, flow rate, and acceleration diff --git a/README.md b/README.md index d1953d91..2a090604 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,10 @@ Configurable units for 8 measurement categories: - **Scatter Plot** - XY scatter visualization for channel correlation analysis - **Histogram** - 2D heatmap visualization with configurable grid sizes (10x10 to 25x25) for analyzing channel distributions - **MCP Server** - Built-in Model Context Protocol server lets Claude Desktop (or any MCP client) control UltraLog — load files, select channels, get stats, create computed channels, and run analysis via `http://localhost:52453/mcp` +- **Table Generators** - Mine logged events into tuning tables (Tools panel → Table Generators): + - **Lambda Delay Table** - time from injector pulse-width steps to the wideband response, binned by RPM × load, for closed-loop O2 delay tables (ms, engine cycles or ignition events) + - **Acceleration Enrichment Table** - tip-in lean/rich excursion depth, duration and a suggested starting correction, binned by RPM × throttle rate, with lambda-delay compensation from a table generated in the same session + - Auto-suggested channel roles, per-cell sample counts and confidence, multi-log accumulation, event inspector with jump-to-time, CSV export and tab-separated clipboard copy - **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 diff --git a/docs/plans/2026-07-16-tuning-table-generators.md b/docs/plans/2026-07-16-tuning-table-generators.md new file mode 100644 index 00000000..7b0b5e4d --- /dev/null +++ b/docs/plans/2026-07-16-tuning-table-generators.md @@ -0,0 +1,362 @@ +# Tuning Table Generators — Lambda Delay & Acceleration Enrichment + +- **Date:** 2026-07-16 +- **Status:** Implemented (core + UI) — see *Implementation notes* at the end; revised per the 2026-09-17 design review recorded on issues #3 and #4 +- **Issues:** [#4 Lambda delay table generator](https://github.com/ClassicMiniDIY/UltraLog/issues/4), [#3 Acceleration enrichment table generator](https://github.com/ClassicMiniDIY/UltraLog/issues/3) + +## Summary + +Two requested features mine loaded logs to auto-generate tuning tables: + +1. **Lambda delay table (#4)** — measure the time between an injector pulse width change and the corresponding lambda/AFR response, binned by RPM × load. Tuners currently do this by hand across multiple logs; the result feeds the ECU's closed-loop O2 control delay table. +2. **Acceleration enrichment table (#3)** — detect tip-in events, measure the transient lean excursion versus target, and suggest an enrichment correction binned by RPM × TPS rate-of-change. + +The two features share roughly 80% of their machinery: channel-role mapping, event detection over time series, 2D binning into an RPM × load grid, a table/heatmap results view with per-cell statistics, CSV export, and multi-log accumulation. This doc designs that shared framework once, with the two features as pluggable analyzers on top of it. + +## Goals + +- One **table-generation framework** in `src/analysis/` that both analyzers (and future ones — VE table verification, ignition scatter, etc.) plug into. +- **Channel mapping with auto-suggestion** driven by the existing normalization system (`src/normalize.rs`), editable by the user when auto-detection is wrong or ambiguous. +- **Per-cell sample counts and confidence** so sparse cells are visibly untrustworthy rather than silently wrong. +- **Multi-log accumulation** — several capture sessions fill one table before export. +- **CSV export** (and tab-separated clipboard copy) suitable for pasting into NSP, TunerStudio, EMU Black software, etc. +- Validate against `exampleLogs/haltech/` and `exampleLogs/speeduino/speeduino.mlg`. + +## Non-Goals + +- Writing tables back to an ECU or generating vendor-native calibration files. Output is CSV/clipboard only. +- Real-time analysis during log streaming. +- Automatic fuel-map (VE) correction. That is a separate, larger feature; this framework is a prerequisite for it. + +## Background — existing infrastructure this builds on + +| Piece | Location | What we reuse | +| --- | --- | --- | +| Analyzer trait + registry | `src/analysis/mod.rs` | `AnalysisError`, `LogDataAccess`, `timed_analyze`, registration/discovery patterns. The existing `Analyzer` trait returns one value per timestamp — tables don't fit it, so table generators get a sibling trait (below), not a shoehorn. | +| AFR/lambda unit handling | `src/analysis/afr.rs` | `FuelMixtureUnit`, `detect_fuel_mixture_unit()` — auto-detects whether a channel logs lambda (~1.0) or AFR (~14.7) and converts. Both analyzers need this. | +| Field normalization | `src/normalize.rs` | `normalize_channel_name_with_custom()` and the built-in map (RPM, MAP, TPS, `Pulse Width`, `Duty Cycle`, AFR/Lambda variants, `AFR Target`) power channel auto-suggestion. | +| 2D binning + heatmap render | `src/ui/scatter_plot.rs` | Precedent for the grid histogram (`HEATMAP_BINS`, per-cell hit counting) and the painted heatmap with hover/click cell inspection and legend. | +| Analysis panel UI | `src/ui/analysis_panel.rs` | `ParamDef`/`ParamType` (including `ParamType::Channel` dropdowns), category tabs, config get/set round-trip — the table-generator dialog follows the same conventions. | +| Tools panel entry point | `src/ui/tools_panel.rs` | Analyzers surface through the tools side panel; table generators get a section here. | +| Rate-of-change | `src/analysis/statistics.rs` (`RateOfChangeAnalyzer`) | Derivative computation for PW-dot / TPS-dot when the ECU doesn't log a derivative channel natively. | + +## Architecture + +### Module layout + +```text +src/analysis/ +├── mod.rs # + register table generators, re-export tables module +├── tables/ +│ ├── mod.rs # TableAnalyzer trait, TableResult, TableGeneratorRegistry +│ ├── channel_map.rs # ChannelRole, ChannelMapping, auto-suggestion +│ ├── binning.rs # AxisSpec, TableGrid, CellStats, accumulation & merge +│ ├── events.rs # derivative helpers, step/tip-in detection primitives +│ ├── lambda_delay.rs # LambdaDelayGenerator (#4) +│ └── accel_enrich.rs # AccelEnrichGenerator (#3) +src/ui/ +└── table_generator.rs # dialog (mapping + params), results view, CSV export +``` + +### Core trait + +The existing `Analyzer` trait produces a `Vec` aligned to log timestamps. Table generators produce a 2D grid plus per-event diagnostics, so they get a parallel trait rather than an awkward encoding: + +```rust +/// A generator that mines events from one or more logs into a 2D table. +pub trait TableAnalyzer: Send + Sync { + fn id(&self) -> &str; + fn name(&self) -> &str; + fn description(&self) -> &str; + + /// Channel roles this generator needs (required + optional). + fn channel_roles(&self) -> Vec; + + /// Default axis specs (user-editable before running). + fn default_axes(&self, log: Option<&Log>) -> (AxisSpec, AxisSpec); + + /// Detect events in one log and fold them into the accumulator. + /// Called once per log; accumulation across logs happens in `TableAccumulator`. + fn analyze( + &self, + log: &Log, + mapping: &ChannelMapping, + axes: &(AxisSpec, AxisSpec), + acc: &mut TableAccumulator, + ) -> Result; + + fn get_config(&self) -> AnalyzerConfig; // reuse existing config type + fn set_config(&mut self, config: &AnalyzerConfig); + fn clone_box(&self) -> Box; +} +``` + +`AnalyzerConfig` (string-keyed parameters) is reused verbatim so parameter persistence and the panel's parameter widgets carry over. + +### Data structures + +```rust +/// Semantic role a mapped channel plays in the analysis. +pub enum ChannelRole { + Rpm, + Load, // MAP or TPS — user picks which flavor; affects axis label/units + PulseWidth, // injector PW (ms/us) or duty cycle (%) + Lambda, // wideband lambda or AFR (auto-detected via FuelMixtureUnit) + LambdaTarget,// optional; enables excursion-vs-target instead of vs-baseline + TpsDot, // optional; native derivative channel (Haltech/Speeduino log these) +} + +pub struct RoleSpec { + pub role: ChannelRole, + pub required: bool, + pub label: String, // i18n key + pub hint: String, // tooltip: what kinds of channels qualify +} + +/// User's channel assignment for one run. Persisted per ECU type. +pub struct ChannelMapping { + pub assignments: HashMap, // role -> raw channel name in log + pub load_kind: LoadKind, // Map | Tps +} + +/// One table axis: explicit breakpoints (uneven spacing allowed, like real ECU tables). +pub struct AxisSpec { + pub label: String, // "RPM", "MAP (kPa)", "TPS δ (%/s)" + pub breakpoints: Vec, // cell edges; N+1 edges -> N bins +} + +/// Everything measured for one detected event (kept for the inspector view). +pub struct TableEvent { + pub time: f64, // event start in log time + pub log_name: String, // provenance for multi-log accumulation + pub rpm: f64, + pub load: f64, + pub value: f64, // delay in ms (lambda delay) or correction % (AE) + pub quality: f32, // 0..1 per-event quality (see analyzers below) +} + +pub struct CellStats { + pub samples: Vec, // raw per-event values (needed for median/MAD merge) + pub median: f64, + pub mad: f64, // median absolute deviation — robust spread + pub count: usize, + pub confidence: Confidence, // High | Medium | Low | Empty (derived, see below) +} + +/// The accumulating table. Lives in app state; logs are folded in one at a time. +pub struct TableAccumulator { + pub generator_id: String, + pub axes: (AxisSpec, AxisSpec), + pub cells: Vec>, // [load_bin][rpm_bin] + pub events: Vec, // all events, for inspection/undo of a log + pub logs_included: Vec, +} + +pub struct AnalysisRunReport { + pub events_detected: usize, + pub events_rejected: usize, // failed quality gates + pub warnings: Vec, // e.g. "sample rate 5 Hz is coarse for delay measurement" +} +``` + +Storing raw samples per cell (not just running stats) is deliberate: medians and MADs can't be merged incrementally, and it enables "remove log X from the table" by re-folding the remaining events. Memory is trivial — even an aggressive session yields a few thousand events. + +Axis defaults are derived from data when a log is loaded: 1st–99th percentile of the mapped RPM/load channels, rounded to tuner-friendly steps (RPM: 250/500; MAP: 10 kPa; TPS: 10%; TPS-dot: geometric 25/50/100/200/400/800 %/s). Users can edit breakpoints as a comma-separated list before running, matching how ECU software presents axes. + +### Channel mapping & auto-suggestion + +The mapping dialog runs once per (generator, ECU type) and is persisted (JSON via eframe storage, like `computed_library`): + +1. For each `RoleSpec`, score every channel in the loaded log: + - `normalize_channel_name_with_custom(name, custom_mappings)` equals the role's canonical target (`"RPM"`, `"MAP"`/`"TPS"`, `"Pulse Width"`/`"Duty Cycle"`, `"Lambda 1"`/`"AFR"`/`"AFR Channel 1"`, `"AFR Target"`) → strong match. + - OpenECU Alliance spec metadata (`normalize::get_spec_metadata`) category/unit consistent with the role → medium match. + - Substring heuristics as last resort (`"injector"`, `"pulse width"`, `"on time"`, `"derivative"`, `"dot"`). +2. Best match pre-fills the dropdown; ambiguity (multiple strong matches, e.g. Haltech's `Wideband O2 1` and `Wideband O2 Overall`) is flagged with a ⚠ so the user confirms. +3. Every role is an editable dropdown over all channels (same widget as `ParamType::Channel` in the analysis panel). + +Multi-log accumulation re-runs auto-suggestion per log (channel sets can differ between captures) but keeps the user's explicit overrides when the same channel name exists. + +### Event detection primitives (`events.rs`) + +Shared by both analyzers: + +- `smooth_median3(values)` — 3-sample median pre-filter to kill single-sample spikes before differentiation. +- `derivative(values, times)` — central difference, units/second. Used when no native derivative channel is mapped. When the ECU logs one (Haltech `Throttle Position Derivative`, Speeduino `TPS DOT`), the native channel is preferred — it's computed at ECU tick rate, not log rate. +- `noise_sigma(values, window)` — robust noise estimate (1.4826 × MAD of first differences) used to scale response thresholds so they adapt to sensor noise instead of using fixed magic numbers. +- `find_crossing_interpolated(times, values, threshold, from_idx)` — first threshold crossing with linear interpolation between samples. Critical for lambda delay: at a 20 Hz log rate one sample is 50 ms, a large fraction of a typical 80–300 ms delay; interpolation recovers sub-sample timing. +- `steady_state(values, window, tolerance)` — gate that rejects events where RPM/load is still moving (both analyzers need quasi-steady operating point for the bin assignment to be meaningful). + +Sample-rate awareness: each run computes the log's median sample interval. Below 10 Hz, lambda-delay results get a warning and per-event `quality` is derated; below 4 Hz the run refuses with an explanatory error (`AnalysisError::InvalidParameter`). + +## Lambda delay analyzer (#4) + +**Physical model:** a step increase in injector PW enriches the charge; the wideband reads it after transport delay (exhaust travel) + sensor response time. That total delay, mapped over RPM × load, is what ECU closed-loop control wants. Delay shrinks with RPM/load (higher exhaust velocity), typically 50–500 ms on a small NA engine at low load, down to tens of ms at high load. + +### Primary algorithm — step detection + +1. **Pre-filter** PW and lambda with `smooth_median3`. +2. **Find PW steps:** compute PW-dot; a step event starts where `|ΔPW| / PW_baseline ≥ min_step_pct` within `step_window_ms` (defaults: 8%, 150 ms). `PW_baseline` = median PW over the 300 ms before the candidate. Both rising and falling steps are used (rising → lambda falls / AFR falls; falling → the reverse); the expected response direction is recorded per event. +3. **Gates:** + - Steady operating point: RPM within ±200 rpm and load within ±8 kPa (or ±5% TPS) across the measurement window — otherwise the bin assignment is ambiguous; reject. + - No overlapping step within `min_event_spacing_ms` (default 600 ms) — overlapping responses can't be attributed; reject. + - PW above a floor (default 1.0 ms) to skip decel-fuel-cut regions; if a DFCO/decel-cut channel exists (Speeduino `DFCO`, Haltech `Decel Cut State`), reject events while it's active. +4. **Measure response:** lambda baseline = median over the 250 ms pre-event. Response threshold = `max(response_k × noise_sigma, min_response_delta)` in the expected direction (defaults: k = 3, min delta = 0.005 λ / 0.1 AFR — converted via `FuelMixtureUnit`). Delay = interpolated first-crossing time − step time. Timeout `response_timeout_ms` (default 1500 ms) → reject. +5. **Per-event quality:** product of factors — response magnitude vs noise, steadiness margin, sample-rate factor. Stored on `TableEvent`; cells can optionally weight the median by quality (off by default; plain median is more explainable). +6. **Bin** by (RPM, load) at the step instant; **median per cell**, MAD as spread. + +### Alternative mode — windowed cross-correlation + +Exposed as a mode toggle (`method = steps | xcorr`). For logs without crisp PW steps (steady cruise with dither), slide a 3 s window with 50% overlap; within each window with sufficient PW variance (coefficient of variation ≥ 2%), compute normalized cross-correlation between detrended PW and lambda over lags 0–1000 ms; accept the peak lag if peak correlation ≥ 0.6. Each accepted window contributes one event binned at the window's mean RPM/load. This mode ships in Phase 3 (see plan) — step detection covers the primary "do pulls, get table" workflow and is far easier to validate. + +### Tunable parameters (all surfaced in the dialog, persisted via `AnalyzerConfig`) + +| Parameter | Default | Notes | +| --- | --- | --- | +| `min_step_pct` | 8 % | Minimum PW step relative to baseline | +| `step_window_ms` | 150 | Step must complete within this | +| `min_event_spacing_ms` | 600 | Attribution guard | +| `response_k` | 3.0 | Threshold = k × noise σ | +| `min_response_delta` | 0.005 λ | Floor in lambda units, converted per `FuelMixtureUnit` | +| `response_timeout_ms` | 1500 | Reject if lambda never responds | +| `pw_floor_ms` | 1.0 | Skip fuel-cut regions | +| `steady_rpm_band` | ±200 rpm | Steadiness gate | +| `steady_load_band` | ±8 kPa / ±5 % | Per `LoadKind` | +| `method` | `steps` | `steps` \| `xcorr` (Phase 3) | + +Output unit: **milliseconds**, formatted per cell to 0 decimal places. + +## Acceleration enrichment analyzer (#3) + +**Physical model:** on tip-in, airflow rises faster than fuel film delivers; without AE the mixture spikes lean for 100 ms–1 s. The tuner wants to know, per RPM × tip-in-rate cell: how deep and long the lean excursion is, and roughly how much extra fuel would have flattened it. + +### Algorithm + +1. **Tip-in detection:** TPS-dot (native channel preferred, else derivative of smoothed TPS) crosses `tps_dot_threshold` (default 50 %/s) and stays above it for ≥ 2 samples. Event magnitude = peak TPS-dot during the ramp. MAP-dot (default 400 kPa/s) is the fallback trigger for logs without TPS — same pipeline, different axis label. +2. **Lambda delay compensation:** the AFR response to the tip-in arrives one lambda-delay later. The AFR window is shifted by a delay estimate before excursion measurement — taken from a completed lambda-delay table for the matching cell when one exists in the session (the two features compose), else the `assumed_delay_ms` parameter (default 120 ms). Without this shift, excursions at high RPM get attributed to the wrong instant and depth is underestimated. +3. **Excursion measurement** over a window from tip-in until AFR recovers to within `recovery_band` of reference for 100 ms, capped at `max_event_ms` (default 2000 ms): + - Reference = mapped `LambdaTarget` channel when present (Haltech `Target Lambda`, Speeduino `AFR Target`); else the pre-event 250 ms baseline. + - **Depth:** peak lean deviation, in lambda units. + - **Duration:** time above `lean_band` (default 0.02 λ over reference). + - **Area:** integral of deviation over the window (reported in the event inspector; not binned in v1). +4. **Suggested correction:** `correction_pct = (peak_lambda / reference_lambda − 1) × 100`, clamped to 0–50%. This is the steady-flow fuel deficit at the excursion peak — an honest first-order starting point, and the doc/UI labels it as *"suggested starting correction"*, not a final value (wall-wetting dynamics mean the true transient dose differs; tuners iterate from here). +5. **Gates:** clutch/gearshift rejection when a clutch state channel exists (Haltech `Clutch State`); events where RPM changes > 25% during the window are rejected (shift mid-event); overlapping tip-ins merge into the larger event. +6. **Bin** by (RPM at tip-in, peak TPS-dot); **median correction per cell** — matching how ECUs axis their AE tables (Speeduino: TPSdot × correction; Haltech transient throttle: load-dot based). + +### Tunable parameters + +| Parameter | Default | Notes | +| --- | --- | --- | +| `tps_dot_threshold` | 50 %/s | Tip-in trigger | +| `map_dot_threshold` | 400 kPa/s | Fallback trigger | +| `assumed_delay_ms` | 120 | Used when no lambda-delay table in session | +| `lean_band` | 0.02 λ | Deviation counted as "lean" | +| `recovery_band` | 0.01 λ | Event end condition | +| `max_event_ms` | 2000 | Window cap | +| `max_rpm_change_pct` | 25 % | Gearshift guard | +| `correction_clamp_pct` | 50 % | Sanity clamp on suggestions | + +Output unit: **% enrichment**, 1 decimal place. Secondary grids (depth in λ, duration in ms) are selectable views over the same events — the accumulator keeps `TableEvent`s, so re-binning a different measure is free. + +## Confidence and sparse cells + +Per-cell `Confidence` derives from count and dispersion: + +| Level | Rule | Rendering | +| --- | --- | --- | +| Empty | n = 0 | Blank cell, no fill | +| Low | n < `min_samples` (default 3) **or** MAD/median > 0.5 | Value in gray italic + count badge red | +| Medium | n ≥ 3 and MAD/median ≤ 0.5 | Normal value, amber count badge | +| High | n ≥ `good_samples` (default 8) and MAD/median ≤ 0.25 | Normal value, green count badge | + +Empty and Low cells are **never interpolated or smoothed in v1** — fabricated numbers in a tuning table are worse than gaps. CSV export writes empty cells as blank (not 0), and the export dialog offers "exclude Low-confidence cells" (on by default). A count grid and a MAD grid export alongside the value grid so downstream judgment is possible. + +The results view surfaces a coverage line — "34/91 cells filled, 21 high confidence" — plus the per-log event counts from `AnalysisRunReport`, which tells the user *what kind of driving to log next* (e.g., no events above 4000 rpm → go do high-rpm pulls). + +## UI flow + +Entry point: a **"Table Generators"** collapsing section in the tools panel (`src/ui/tools_panel.rs`), listing the two generators with a status line when an accumulator is active ("Lambda Delay — 3 logs, 214 events"). Clicking opens the generator window (`src/ui/table_generator.rs`, an `egui::Window` like the analysis panel — no new `ActiveTool` variant; the result is a table, not a chart mode). + +The window has three states: + +1. **Setup** — channel mapping (auto-suggested dropdowns with ⚠ on ambiguity), axis breakpoint editors, parameter grid (same widget conventions as `analysis_panel.rs` `ParamDef`), and a *Run on current file* button. +2. **Results** — painted heatmap grid (cell fill = value on a color ramp, adapted from `scatter_plot.rs` rendering; cell text = value; corner badge = count with confidence color). Hover shows the cell tooltip (median, MAD, n, contributing logs); click opens an event inspector listing each `TableEvent` (time, log, value, quality) with a *jump to time in chart* action — this makes the tool auditable instead of a black box. Toolbar: **Add current file** (fold another loaded tab into the accumulator), **Remove log…**, **Reset**, measure selector (AE: correction / depth / duration), **Export CSV**, **Copy for paste** (tab-separated, no headers — what tuning-software grids accept). +3. **Empty/error** — no file loaded, or run report with zero events: show the rejection breakdown ("41 steps found, 39 rejected: 30 unsteady, 9 no response") so threshold tuning is guided, not guesswork. + +All new strings go through `rust_i18n` `t!()` keys under `table_gen.*`, consistent with the analysis panel. + +### CSV format + +```csv +# UltraLog Lambda Delay Table (ms), generated 2026-07-16 +# Logs: 2025-07-18_0215pm_Log1118.csv, ... +# Rows: MAP (kPa), Columns: RPM +,1000,1500,2000,2500,3000 +30,,182,164,151, +40,201,176,158,143,139 +... +# Sample counts +,1000,1500,2000,2500,3000 +30,0,4,9,12,2 +... +``` + +Value grid, count grid, MAD grid in one file, `#`-prefixed comment separators. Clipboard copy is the bare value grid only. + +## Testing strategy + +**Unit tests (synthetic signals)** — the core value of this feature is measurement correctness, so events.rs and both analyzers get synthetic-signal tests with known ground truth: + +- Square PW step + lambda response delayed by exactly N ms at various sample rates (5/10/20/50 Hz) → recovered delay within half a sample interval (interpolation working). +- Noise-injected variants (σ scaled to real wideband noise) → detection still fires, delay error bounded. +- Steps during RPM sweeps → rejected by the steadiness gate. +- Tip-in ramp + lean excursion of known depth/duration → recovered within tolerance; overlapping tip-ins merge; mid-event RPM collapse (simulated shift) rejected. +- Binning: events on exact breakpoints land deterministically (lower-edge inclusive); median/MAD/confidence math; accumulator merge and per-log removal round-trips. + +**Integration tests (example logs)** — `cargo test` fixtures against real files, asserting plausibility envelopes rather than exact values: + +- `exampleLogs/haltech/2025-07-18_0215pm_Log1118.csv` — map `RPM`, `Manifold Pressure`, `Injector 1 On Time` (or `Injection Stage 1 Average Injection Time`), `Wideband O2 Overall`, `Target Lambda`. Assert: >0 lambda-delay events detected, all cell medians in 20–800 ms, auto-suggestion picks these channels unaided (this doubles as a normalization regression test — `Wideband O2 Overall` → `AFR` already has coverage in `normalize.rs`). +- Same file for AE: the log contains `Throttle Position Derivative` and the `Transient Throttle *` channels — assert our tip-in events temporally overlap regions where Haltech's own `Transient Throttle Fuel Peak Synchronous Output` is active (the ECU's AE detector is our reference detector). +- `exampleLogs/speeduino/speeduino.mlg` — map `RPM`, `MAP`, `PW`, `AFR`, `AFR Target`, native `TPS DOT`. Same plausibility assertions; additionally cross-check tip-in detection against the logged `Accel Enrich` / `Gammae` channels (events should coincide with `Accel Enrich` > 100%). +- One binary + one CSV format in CI keeps parser-interaction regressions covered; the remaining `exampleLogs/` formats are manual-QA targets. + +**Manual QA:** run against a fresh Haltech capture on the actual car; sanity-check the delay table against the known-good hand-derived values that motivated #4. + +## Phased implementation plan + +**Phase 1 — framework + lambda delay (ships as one PR series):** + +1. `src/analysis/tables/` — trait, binning, accumulator, channel mapping + auto-suggestion, events.rs primitives, with unit tests. +2. `lambda_delay.rs` step-detection analyzer + synthetic tests. +3. `src/ui/table_generator.rs` — setup/results/empty states, heatmap grid, event inspector, CSV + clipboard export; tools-panel section; i18n keys. +4. Integration fixtures for haltech + speeduino logs; wiki page draft (`UltraLog.wiki`). + +**Phase 2 — acceleration enrichment:** + +1. `accel_enrich.rs` — tip-in detection, delay compensation (session lambda-delay table lookup), excursion + correction math, synthetic tests. +2. Measure selector in the results view (correction/depth/duration); Speeduino `Accel Enrich` cross-check fixture. +3. Wiki page. + +**Phase 3 — enhancements (each optional, independent):** + +- Cross-correlation mode for lambda delay. +- Quality-weighted medians toggle. +- PNG/PDF export of the table view (reuse `src/ui/export.rs` plumbing). +- Persisted per-ECU mapping presets shared between the two generators. + +## Open questions + +1. **PW channel semantics vary** — Haltech logs per-injector on-time (ms), Speeduino logs PW1 (ms) and duty (%). Step detection is relative, so units don't matter for #4, but the mapping UI should display the detected unit so users pick the right channel. Any ECU that only logs *commanded* fuel including AE compensation will show AE events in the PW trace — acceptable for delay measurement (steps are steps), worth a wiki note. +2. **AE table axis flavors** — Speeduino bins AE by TPSdot only (1D × RPM scaling), Haltech by load-dot. v1 bins RPM × TPS-dot with MAP-dot fallback; if users ask for vendor-exact axis shapes, that's an `AxisSpec` preset, not a redesign. +3. **Where does the accumulator live across app restarts?** v1: session-only (in `UltraLogApp` state). Persisting partial tables to disk is a small follow-up if requested. + +## Implementation notes (2026-09-18) + +Shipped in `src/analysis/tables/` and `src/ui/table_generator.rs`, following the 2026-09-17 review comments on [#4](https://github.com/ClassicMiniDIY/UltraLog/issues/4) and [#3](https://github.com/ClassicMiniDIY/UltraLog/issues/3). Deltas from this document: + +- `AxisSpec` holds `edges` rather than `breakpoints`; `TableEvent` carries a `values` vector aligned with the generator's `MeasureSpec` list instead of a single `value`, so the measure selector re-bins the same events (dead time / t63 / magnitude; correction / depth / duration / area). +- The previous reading used for crossing interpolation is the last update instant but no earlier than one effective update interval before the crossing sample (B4 refined): interpolating from the last *value change* after a flat baseline placed crossings far too early on synthetic ground truth. +- Noise sigma is the smaller of the whole-log estimate and a local one from the second before each step (when it has ≥ 8 updates), because in a driving log most whole-log differences are real mixture changes. +- The accel-enrichment gear-shift gate rejects an RPM *drop* of more than `max_rpm_change_pct`; a rise is the engine responding to the tip-in (the Haltech blip fixture revs 630 → 3500). +- Haltech's parser already scales `Throttle Position Derivative` by 0.1, so the native-rate scale detector returns ×1 for it; the detector still snaps to decades for exporters that do not. +- Fixtures: MegaSquirt `2026-04-12_12.49.36.mlg` is the primary real-log test for both generators; `rusefilog.mlg` has no wideband data (`Lambda` is all zero) and is used for tip-in detection against `Fuel: TPS AE Active` only; the Link `.llg5` fixtures carry corrupted extremes and are not used. +- Not yet implemented: MCP tools (B15), per-ECU mapping presets on disk (B16), the cross-correlation mode, the wiki page, and a version bump. diff --git a/i18n/ar.yaml b/i18n/ar.yaml index e11d043b..2bd7b153 100644 --- a/i18n/ar.yaml +++ b/i18n/ar.yaml @@ -348,6 +348,54 @@ analysis: remove_result_tooltip: "إزالة النتيجة" add_to_chart_result: "إضافة إلى الرسم البياني كقناة" +# Table generators (src/ui/table_generator.rs, src/ui/tools_panel.rs) +table_gen: + section_title: "مولدات الجداول" + section_help: "استخراج الأحداث المسجلة في جداول تأخير لامدا وإثراء التسارع." + load_file_hint: "حمّل ملفًا لإنشاء جداول" + status: "%{logs} سجل(ات)، %{events} أحداث" + window_title: "مولدات الجداول" + setup_header: "الإعداد - %{file}" + channels: "القنوات" + auto_detect: "الكشف التلقائي" + none: "(لا شيء)" + ambiguous_hint: "حصلت قناتان على درجات متشابهة تقريبًا لهذا الدور. تأكد من الاختيار." + required_hint: "هذا الدور مطلوب." + load_axis: "محور التحميل:" + axes: "المحاور (حواف الخلية)" + reset_axes: "إعادة تعيين من البيانات" + bins: "%{n} حاويات" + invalid_axis: "أدخل رقمين على الأقل" + parameters: "المعاملات" + run: "تشغيل الملف الحالي" + add_file: "إضافة الملف الحالي إلى الجدول" + rerun: "إعادة تشغيل الملف الحالي" + missing_roles: "تعيين الأدوار المطلوبة: %{roles}" + results: "النتائج" + export_csv: "تصدير CSV" + copy: "نسخ للصق" + copy_hint: "شبكة قيم مفصولة بعلامات جدولة للصق في برنامج الضبط" + exclude_low: "خلايا فارغة منخفضة الثقة" + exclude_low_hint: "تُترك الخلايا التي تحتوي على أقل من 3 عينات أو نطاق واسع فارغة عند التصدير" + cylinders: "الأسطوانات" + remove_log: "إزالة السجل…" + reset: "إعادة تعيين" + coverage: "%{events} أحداث مقبولة من %{logs} سجل(ات) · %{filled}/%{total} خلايا مملوءة، %{high} ثقة عالية" + notes: "الملاحظات (%{n})" + no_events: "لا توجد أحداث مقبولة حتى الآن. تحقق من تفصيل الرفض أعلاه واضبط المعاملات أو سجل المزيد من نطاق التشغيل هذا." + cell_title: "%{x} × %{y}: %{n} أحداث، الوسيط %{median}، MAD %{mad}، %{confidence} ثقة" + cell_tooltip: "الوسيط %{median}، MAD %{mad}، n = %{n}، %{confidence} ثقة" + empty_cell: "لا توجد أحداث في هذه الخلية" + col_time: "الوقت" + col_log: "السجل" + col_note: "ملاحظة" + jump: "قفز" + log_unloaded: "هذا السجل لم يعد محملاً" + copied: "تم نسخ الجدول إلى الحافظة" + copy_failed: "فشل نسخ الجدول إلى الحافظة" + exported: "تم تصدير الجدول إلى %{path}" + export_failed: "فشل التصدير" + # Tool switcher (src/ui/tool_switcher.rs) tools: log_viewer: "عارض السجل" diff --git a/i18n/bn.yaml b/i18n/bn.yaml index de515f1e..49584f33 100644 --- a/i18n/bn.yaml +++ b/i18n/bn.yaml @@ -348,6 +348,54 @@ analysis: remove_result_tooltip: "ফলাফল সরান" add_to_chart_result: "চ্যানেল হিসেবে চার্টে যোগ করুন" +# Table generators (src/ui/table_generator.rs, src/ui/tools_panel.rs) +table_gen: + section_title: "টেবিল জেনারেটর" + section_help: "লগ করা ইভেন্টগুলি ল্যাম্বডা বিলম্ব এবং ত্বরণ সমৃদ্ধকরণ টেবিলে মাইন করুন।" + load_file_hint: "টেবিল তৈরি করতে একটি ফাইল লোড করুন" + status: "%{logs} লগ(গুলি), %{events} ইভেন্ট" + window_title: "টেবিল জেনারেটর" + setup_header: "সেটআপ - %{file}" + channels: "চ্যানেল" + auto_detect: "স্বয়ংক্রিয় সনাক্ত" + none: "(কোনটি নয়)" + ambiguous_hint: "দুটি চ্যানেল এই ভূমিকার জন্য প্রায় একই স্কোর পেয়েছে। পছন্দটি নিশ্চিত করুন।" + required_hint: "এই ভূমিকা প্রয়োজনীয়।" + load_axis: "লোড অক্ষ:" + axes: "অক্ষ (কক্ষের প্রান্ত)" + reset_axes: "ডেটা থেকে রিসেট করুন" + bins: "%{n} বিন" + invalid_axis: "কমপক্ষে দুটি সংখ্যা প্রবেশ করুন" + parameters: "প্যারামিটার" + run: "বর্তমান ফাইলে চালান" + add_file: "বর্তমান ফাইল টেবিলে যোগ করুন" + rerun: "বর্তমান ফাইল পুনরায় চালান" + missing_roles: "প্রয়োজনীয় ভূমিকা ম্যাপ করুন: %{roles}" + results: "ফলাফল" + export_csv: "CSV রপ্তানি করুন" + copy: "পেস্টের জন্য অনুলিপি করুন" + copy_hint: "টিউনিং সফটওয়্যারে আটকানোর জন্য ট্যাব-বিভাজিত মান গ্রিড" + exclude_low: "কম আত্মবিশ্বাসী কক্ষ ফাঁক করুন" + exclude_low_hint: "৩ টিরও কম নমুনা বা বিস্তৃত ছড়িয়ে থাকা কক্ষগুলি রপ্তানিতে ফাঁক থাকে" + cylinders: "সিলিন্ডার" + remove_log: "লগ সরান…" + reset: "রিসেট করুন" + coverage: "%{logs} লগ(গুলি) থেকে %{events} গৃহীত ইভেন্ট · %{filled}/%{total} কক্ষ পূর্ণ, %{high} উচ্চ আত্মবিশ্বাস" + notes: "নোট (%{n})" + no_events: "এখনও কোনও গৃহীত ইভেন্ট নেই। উপরে প্রত্যাখ্যানের বিভাজন পরীক্ষা করুন এবং পরামিতিগুলি সামঞ্জস্য করুন বা সেই অপারেটিং রেঞ্জ থেকে আরও লগ করুন।" + cell_title: "%{x} × %{y}: %{n} ইভেন্ট, মধ্যক %{median}, MAD %{mad}, %{confidence} আত্মবিশ্বাস" + cell_tooltip: "মধ্যক %{median}, MAD %{mad}, n = %{n}, %{confidence} আত্মবিশ্বাস" + empty_cell: "এই কক্ষে কোনও ইভেন্ট নেই" + col_time: "সময়" + col_log: "লগ" + col_note: "নোট" + jump: "লাফ দিন" + log_unloaded: "সেই লগ আর লোড নেই" + copied: "টেবিল ক্লিপবোর্ডে অনুলিপি করা হয়েছে" + copy_failed: "ক্লিপবোর্ডে অনুলিপি করতে ব্যর্থ" + exported: "টেবিল %{path} এ রপ্তানি করা হয়েছে" + export_failed: "রপ্তানি ব্যর্থ হয়েছে" + # Tool switcher (src/ui/tool_switcher.rs) tools: log_viewer: "লগ ভিউয়ার" diff --git a/i18n/de.yaml b/i18n/de.yaml index 2f244596..7f4156f6 100644 --- a/i18n/de.yaml +++ b/i18n/de.yaml @@ -348,6 +348,54 @@ analysis: remove_result_tooltip: "Ergebnis entfernen" add_to_chart_result: "Als Kanal zum Diagramm hinzufügen" +# Table generators (src/ui/table_generator.rs, src/ui/tools_panel.rs) +table_gen: + section_title: "Tabellengeneratoren" + section_help: "Protokollierte Ereignisse in Lambda-Verzögerungs- und Anreicherungstabellen abbauen." + load_file_hint: "Laden Sie eine Datei, um Tabellen zu generieren" + status: "%{logs} Log(s), %{events} Ereignisse" + window_title: "Tabellengeneratoren" + setup_header: "Setup - %{file}" + channels: "Kanäle" + auto_detect: "Automatisch erkennen" + none: "(keine)" + ambiguous_hint: "Zwei Kanäle haben fast gleiche Ergebnisse für diese Rolle. Bitte bestätigen Sie die Auswahl." + required_hint: "Diese Rolle ist erforderlich." + load_axis: "Lastachse:" + axes: "Achsen (Zellgrenzen)" + reset_axes: "Von Daten zurücksetzen" + bins: "%{n} Intervalle" + invalid_axis: "Geben Sie mindestens zwei Zahlen ein" + parameters: "Parameter" + run: "Für aktuelle Datei ausführen" + add_file: "Aktuelle Datei zur Tabelle hinzufügen" + rerun: "Aktuelle Datei erneut ausführen" + missing_roles: "Erforderliche Rollen zuordnen: %{roles}" + results: "Ergebnisse" + export_csv: "CSV exportieren" + copy: "Zum Einfügen kopieren" + copy_hint: "Tabulatorgetrennte Wertetabelle zum Einfügen in Tuning-Software" + exclude_low: "Zellen mit niedriger Konfidenz ausblenden" + exclude_low_hint: "Zellen mit weniger als 3 Messungen oder großer Streuung bleiben beim Export leer" + cylinders: "Zylinder" + remove_log: "Log entfernen…" + reset: "Zurücksetzen" + coverage: "%{events} akzeptierte Ereignisse aus %{logs} Log(s) · %{filled}/%{total} Zellen gefüllt, %{high} hohe Konfidenz" + notes: "Notizen (%{n})" + no_events: "Noch keine akzeptierten Ereignisse. Überprüfen Sie die Ablehnungsanalyse oben und passen Sie die Parameter an oder protokollieren Sie mehr aus diesem Betriebsbereich." + cell_title: "%{x} × %{y}: %{n} Ereignisse, Median %{median}, MAD %{mad}, %{confidence} Konfidenz" + cell_tooltip: "Median %{median}, MAD %{mad}, n = %{n}, %{confidence} Konfidenz" + empty_cell: "Keine Ereignisse in dieser Zelle" + col_time: "Zeit" + col_log: "Log" + col_note: "Notiz" + jump: "Springen" + log_unloaded: "Dieses Log wird nicht mehr geladen" + copied: "Tabelle in Zwischenablage kopiert" + copy_failed: "Fehler beim Kopieren in die Zwischenablage" + exported: "Tabelle exportiert nach %{path}" + export_failed: "Export fehlgeschlagen" + # Tool switcher (src/ui/tool_switcher.rs) tools: log_viewer: "Log-Betrachter" diff --git a/i18n/en.yaml b/i18n/en.yaml index e3bd087a..33c27315 100644 --- a/i18n/en.yaml +++ b/i18n/en.yaml @@ -348,6 +348,54 @@ analysis: remove_result_tooltip: "Remove result" add_to_chart_result: "Add to chart as a channel" +# Table generators (src/ui/table_generator.rs, src/ui/tools_panel.rs) +table_gen: + section_title: "Table Generators" + section_help: "Mine logged events into lambda delay and acceleration enrichment tables." + load_file_hint: "Load a file to generate tables" + status: "%{logs} log(s), %{events} events" + window_title: "Table Generators" + setup_header: "Setup - %{file}" + channels: "Channels" + auto_detect: "Auto-detect" + none: "(none)" + ambiguous_hint: "Two channels scored almost the same for this role. Confirm the choice." + required_hint: "This role is required." + load_axis: "Load axis:" + axes: "Axes (cell edges)" + reset_axes: "Reset from data" + bins: "%{n} bins" + invalid_axis: "Enter at least two numbers" + parameters: "Parameters" + run: "Run on current file" + add_file: "Add current file to table" + rerun: "Re-run current file" + missing_roles: "Map required roles: %{roles}" + results: "Results" + export_csv: "Export CSV" + copy: "Copy for paste" + copy_hint: "Tab-separated value grid for pasting into tuning software" + exclude_low: "Blank low-confidence cells" + exclude_low_hint: "Cells with fewer than 3 samples or a wide spread are left blank on export" + cylinders: "Cylinders" + remove_log: "Remove log…" + reset: "Reset" + coverage: "%{events} accepted events from %{logs} log(s) · %{filled}/%{total} cells filled, %{high} high confidence" + notes: "Notes (%{n})" + no_events: "No accepted events yet. Check the rejection breakdown above and adjust the parameters or log more of that operating range." + cell_title: "%{x} × %{y}: %{n} events, median %{median}, MAD %{mad}, %{confidence} confidence" + cell_tooltip: "median %{median}, MAD %{mad}, n = %{n}, %{confidence} confidence" + empty_cell: "No events in this cell" + col_time: "Time" + col_log: "Log" + col_note: "Note" + jump: "Jump" + log_unloaded: "That log is no longer loaded" + copied: "Table copied to clipboard" + copy_failed: "Clipboard copy failed" + exported: "Table exported to %{path}" + export_failed: "Export failed" + # Tool switcher (src/ui/tool_switcher.rs) tools: log_viewer: "Log Viewer" diff --git a/i18n/es.yaml b/i18n/es.yaml index 80c3cf02..f1e1e6a4 100644 --- a/i18n/es.yaml +++ b/i18n/es.yaml @@ -349,6 +349,55 @@ analysis: add_to_chart_result: "Agregar al grafico como canal" # Selector de herramientas (src/ui/tool_switcher.rs) +# Table generators (src/ui/table_generator.rs, src/ui/tools_panel.rs) +table_gen: + section_title: "Generadores de Tablas" + section_help: "Minar eventos registrados en tablas de retraso lambda y enriquecimiento de aceleración." + load_file_hint: "Cargue un archivo para generar tablas" + status: "%{logs} registro(s), %{events} eventos" + window_title: "Generadores de Tablas" + setup_header: "Configuración - %{file}" + channels: "Canales" + auto_detect: "Detección automática" + none: "(ninguno)" + ambiguous_hint: "Dos canales obtuvieron casi la misma puntuación para este rol. Confirme la selección." + required_hint: "Este rol es requerido." + load_axis: "Eje de carga:" + axes: "Ejes (bordes de celda)" + reset_axes: "Restablecer desde datos" + bins: "%{n} contenedores" + invalid_axis: "Ingrese al menos dos números" + parameters: "Parámetros" + run: "Ejecutar en el archivo actual" + add_file: "Agregar archivo actual a tabla" + rerun: "Re-ejecutar archivo actual" + missing_roles: "Asignar roles requeridos: %{roles}" + results: "Resultados" + export_csv: "Exportar CSV" + copy: "Copiar para pegar" + copy_hint: "Cuadrícula de valores separados por tabulaciones para pegar en software de afinación" + exclude_low: "Celdas en blanco de confianza baja" + exclude_low_hint: "Las celdas con menos de 3 muestras o un rango amplio se dejan en blanco al exportar" + cylinders: "Cilindros" + remove_log: "Eliminar registro…" + reset: "Restablecer" + coverage: "%{events} eventos aceptados de %{logs} registro(s) · %{filled}/%{total} celdas llenas, %{high} confianza alta" + notes: "Notas (%{n})" + no_events: "Aún no hay eventos aceptados. Verifique el desglose de rechazo anterior y ajuste los parámetros o registre más de ese rango de operación." + cell_title: "%{x} × %{y}: %{n} eventos, mediana %{median}, MAD %{mad}, %{confidence} confianza" + cell_tooltip: "mediana %{median}, MAD %{mad}, n = %{n}, %{confidence} confianza" + empty_cell: "Sin eventos en esta celda" + col_time: "Hora" + col_log: "Registro" + col_note: "Nota" + jump: "Saltar" + log_unloaded: "Ese registro ya no se carga" + copied: "Tabla copiada al portapapeles" + copy_failed: "Error al copiar al portapapeles" + exported: "Tabla exportada a %{path}" + export_failed: "Fallo en la exportación" + +# Tool switcher (src/ui/tool_switcher.rs) tools: log_viewer: "Visor de Log" scatter_plots: "Graficos de Dispersion" diff --git a/i18n/fr.yaml b/i18n/fr.yaml index 5c7df8f1..96251454 100644 --- a/i18n/fr.yaml +++ b/i18n/fr.yaml @@ -348,6 +348,54 @@ analysis: remove_result_tooltip: "Supprimer le resultat" add_to_chart_result: "Ajouter au graphique comme canal" +# Table generators (src/ui/table_generator.rs, src/ui/tools_panel.rs) +table_gen: + section_title: "Générateurs de Tableau" + section_help: "Exploiter les événements enregistrés dans les tableaux d'enrichissement de retard lambda et d'accélération." + load_file_hint: "Chargez un fichier pour générer des tableaux" + status: "%{logs} journal(s), %{events} événements" + window_title: "Générateurs de Tableau" + setup_header: "Configuration - %{file}" + channels: "Canaux" + auto_detect: "Détection automatique" + none: "(aucun)" + ambiguous_hint: "Deux canaux ont obtenu un score presque identique pour ce rôle. Confirmez le choix." + required_hint: "Ce rôle est requis." + load_axis: "Axe de charge :" + axes: "Axes (bords de cellule)" + reset_axes: "Réinitialiser à partir des données" + bins: "%{n} intervalles" + invalid_axis: "Entrez au moins deux nombres" + parameters: "Paramètres" + run: "Exécuter sur le fichier actuel" + add_file: "Ajouter le fichier actuel au tableau" + rerun: "Ré-exécuter le fichier actuel" + missing_roles: "Mapper les rôles requis: %{roles}" + results: "Résultats" + export_csv: "Exporter CSV" + copy: "Copier pour coller" + copy_hint: "Grille de valeurs séparées par des tabulations à coller dans un logiciel de tuning" + exclude_low: "Cellules blanches à faible confiance" + exclude_low_hint: "Les cellules avec moins de 3 échantillons ou une large plage restent blanches à l'export" + cylinders: "Cylindres" + remove_log: "Supprimer le journal…" + reset: "Réinitialiser" + coverage: "%{events} événements acceptés de %{logs} journal(s) · %{filled}/%{total} cellules remplies, %{high} haute confiance" + notes: "Notes (%{n})" + no_events: "Aucun événement accepté pour le moment. Consultez la ventilation des rejets ci-dessus et ajustez les paramètres ou enregistrez plus de cette plage opérationnelle." + cell_title: "%{x} × %{y}: %{n} événements, médiane %{median}, MAD %{mad}, %{confidence} confiance" + cell_tooltip: "médiane %{median}, MAD %{mad}, n = %{n}, %{confidence} confiance" + empty_cell: "Aucun événement dans cette cellule" + col_time: "Heure" + col_log: "Journal" + col_note: "Note" + jump: "Sauter" + log_unloaded: "Ce journal n'est plus chargé" + copied: "Tableau copié dans le presse-papiers" + copy_failed: "Échec de la copie dans le presse-papiers" + exported: "Tableau exporté vers %{path}" + export_failed: "Échec de l'exportation" + # Tool switcher (src/ui/tool_switcher.rs) tools: log_viewer: "Visionneuse de journaux" diff --git a/i18n/hi.yaml b/i18n/hi.yaml index 95224fef..644074ac 100644 --- a/i18n/hi.yaml +++ b/i18n/hi.yaml @@ -348,6 +348,54 @@ analysis: remove_result_tooltip: "परिणाम हटाएं" add_to_chart_result: "चैनल के रूप में चार्ट में जोड़ें" +# Table generators (src/ui/table_generator.rs, src/ui/tools_panel.rs) +table_gen: + section_title: "टेबल जेनरेटर" + section_help: "लॉग की गई घटनाओं को लैम्ब्डा विलंब और त्वरण संवर्धन तालिकाओं में खनन करें।" + load_file_hint: "तालिकाएँ बनाने के लिए एक फ़ाइल लोड करें" + status: "%{logs} लॉग, %{events} घटनाएँ" + window_title: "टेबल जेनरेटर" + setup_header: "सेटअप - %{file}" + channels: "चैनल" + auto_detect: "स्वचालित पहचान" + none: "(कोई नहीं)" + ambiguous_hint: "दो चैनलों को इस भूमिका के लिए लगभग समान स्कोर मिला। अपनी पसंद की पुष्टि करें।" + required_hint: "यह भूमिका आवश्यक है।" + load_axis: "लोड अक्ष:" + axes: "अक्ष (सेल किनारे)" + reset_axes: "डेटा से रीसेट करें" + bins: "%{n} बिन" + invalid_axis: "कम से कम दो संख्याएँ दर्ज करें" + parameters: "मापदंड" + run: "वर्तमान फ़ाइल पर चलाएँ" + add_file: "वर्तमान फ़ाइल को तालिका में जोड़ें" + rerun: "वर्तमान फ़ाइल को फिर से चलाएँ" + missing_roles: "आवश्यक भूमिकाओं को मैप करें: %{roles}" + results: "परिणाम" + export_csv: "CSV निर्यात करें" + copy: "पेस्ट के लिए कॉपी करें" + copy_hint: "ट्यूनिंग सॉफ़्टवेयर में पेस्ट करने के लिए टैब-सीमांकित मान ग्रिड" + exclude_low: "कम आत्मविश्वास वाली कोशिकाओं को रिक्त करें" + exclude_low_hint: "3 से कम नमूने वाली या व्यापक प्रसार वाली कोशिकाएँ निर्यात पर खाली रहती हैं" + cylinders: "सिलेंडर" + remove_log: "लॉग हटाएँ…" + reset: "रीसेट करें" + coverage: "%{logs} लॉग से %{events} स्वीकृत घटनाएँ · %{filled}/%{total} कोशिकाएँ भरी गई, %{high} उच्च आत्मविश्वास" + notes: "नोट्स (%{n})" + no_events: "अभी तक कोई स्वीकृत घटना नहीं। ऊपर अस्वीकार का विभाजन देखें और मापदंडों को समायोजित करें या उस ऑपरेटिंग रेंज से अधिक लॉग करें।" + cell_title: "%{x} × %{y}: %{n} घटनाएँ, माध्यिका %{median}, MAD %{mad}, %{confidence} आत्मविश्वास" + cell_tooltip: "माध्यिका %{median}, MAD %{mad}, n = %{n}, %{confidence} आत्मविश्वास" + empty_cell: "इस सेल में कोई घटना नहीं" + col_time: "समय" + col_log: "लॉग" + col_note: "नोट" + jump: "कूदें" + log_unloaded: "वह लॉग अब लोड नहीं है" + copied: "तालिका क्लिपबोर्ड पर कॉपी की गई" + copy_failed: "क्लिपबोर्ड पर कॉपी करने में विफल" + exported: "तालिका %{path} को निर्यात की गई" + export_failed: "निर्यात विफल हुआ" + # Tool switcher (src/ui/tool_switcher.rs) tools: log_viewer: "लॉग व्यूअर" diff --git a/i18n/id.yaml b/i18n/id.yaml index d92e5f7b..a2547e85 100644 --- a/i18n/id.yaml +++ b/i18n/id.yaml @@ -348,6 +348,54 @@ analysis: remove_result_tooltip: "Hapus hasil" add_to_chart_result: "Tambahkan ke grafik sebagai kanal" +# Table generators (src/ui/table_generator.rs, src/ui/tools_panel.rs) +table_gen: + section_title: "Pembuat Tabel" + section_help: "Menambang peristiwa yang dicatat menjadi tabel pengayaan penundaan lambda dan akselerasi." + load_file_hint: "Muat file untuk membuat tabel" + status: "%{logs} log, %{events} peristiwa" + window_title: "Pembuat Tabel" + setup_header: "Pengaturan - %{file}" + channels: "Saluran" + auto_detect: "Deteksi Otomatis" + none: "(tidak ada)" + ambiguous_hint: "Dua saluran mendapat skor yang hampir sama untuk peran ini. Konfirmasi pilihan Anda." + required_hint: "Peran ini diperlukan." + load_axis: "Sumbu beban:" + axes: "Sumbu (tepi sel)" + reset_axes: "Atur Ulang dari Data" + bins: "%{n} tempat sampah" + invalid_axis: "Masukkan setidaknya dua angka" + parameters: "Parameter" + run: "Jalankan di file saat ini" + add_file: "Tambahkan file saat ini ke tabel" + rerun: "Jalankan ulang file saat ini" + missing_roles: "Petakan peran yang diperlukan: %{roles}" + results: "Hasil" + export_csv: "Ekspor CSV" + copy: "Salin untuk tempel" + copy_hint: "Kisi nilai yang dipisahkan tabulasi untuk ditempel ke perangkat lunak penyetelan" + exclude_low: "Sel kosong kepercayaan rendah" + exclude_low_hint: "Sel dengan kurang dari 3 sampel atau penyebaran luas dibiarkan kosong saat diekspor" + cylinders: "Silinder" + remove_log: "Hapus log…" + reset: "Atur Ulang" + coverage: "%{events} peristiwa yang diterima dari %{logs} log · %{filled}/%{total} sel terisi, %{high} kepercayaan tinggi" + notes: "Catatan (%{n})" + no_events: "Belum ada peristiwa yang diterima. Periksa rincian penolakan di atas dan sesuaikan parameter atau catat lebih banyak dari rentang operasi tersebut." + cell_title: "%{x} × %{y}: %{n} peristiwa, median %{median}, MAD %{mad}, %{confidence} kepercayaan" + cell_tooltip: "median %{median}, MAD %{mad}, n = %{n}, %{confidence} kepercayaan" + empty_cell: "Tidak ada peristiwa di sel ini" + col_time: "Waktu" + col_log: "Log" + col_note: "Catatan" + jump: "Lompat" + log_unloaded: "Log itu tidak lagi dimuat" + copied: "Tabel disalin ke clipboard" + copy_failed: "Gagal menyalin ke clipboard" + exported: "Tabel diekspor ke %{path}" + export_failed: "Ekspor gagal" + # Tool switcher (src/ui/tool_switcher.rs) tools: log_viewer: "Penampil Log" diff --git a/i18n/it.yaml b/i18n/it.yaml index 97ee1fd3..c0357c5b 100644 --- a/i18n/it.yaml +++ b/i18n/it.yaml @@ -348,6 +348,54 @@ analysis: remove_result_tooltip: "Rimuovi risultato" add_to_chart_result: "Aggiungi al grafico come canale" +# Table generators (src/ui/table_generator.rs, src/ui/tools_panel.rs) +table_gen: + section_title: "Generatori di Tabelle" + section_help: "Estrarre gli eventi registrati in tabelle di arricchimento del ritardo lambda e dell'accelerazione." + load_file_hint: "Carica un file per generare tabelle" + status: "%{logs} registro(i), %{events} eventi" + window_title: "Generatori di Tabelle" + setup_header: "Configurazione - %{file}" + channels: "Canali" + auto_detect: "Rilevamento automatico" + none: "(nessuno)" + ambiguous_hint: "Due canali hanno ottenuto un punteggio quasi identico per questo ruolo. Confermare la scelta." + required_hint: "Questo ruolo è obbligatorio." + load_axis: "Asse di carico:" + axes: "Assi (bordi cella)" + reset_axes: "Ripristina dai dati" + bins: "%{n} contenitori" + invalid_axis: "Inserire almeno due numeri" + parameters: "Parametri" + run: "Esegui sul file corrente" + add_file: "Aggiungi file corrente alla tabella" + rerun: "Riesegui file corrente" + missing_roles: "Mappa ruoli richiesti: %{roles}" + results: "Risultati" + export_csv: "Esporta CSV" + copy: "Copia per incollare" + copy_hint: "Griglia di valori separati da tabulazioni per incollare nel software di tuning" + exclude_low: "Celle vuote a bassa confidenza" + exclude_low_hint: "Le celle con meno di 3 campioni o un ampio intervallo rimangono vuote all'esportazione" + cylinders: "Cilindri" + remove_log: "Rimuovi registro…" + reset: "Ripristina" + coverage: "%{events} eventi accettati da %{logs} registro(i) · %{filled}/%{total} celle riempite, %{high} alta confidenza" + notes: "Note (%{n})" + no_events: "Nessun evento accettato ancora. Controlla la ripartizione dei rifiuti sopra e regola i parametri o registra più da questo intervallo operativo." + cell_title: "%{x} × %{y}: %{n} eventi, mediana %{median}, MAD %{mad}, %{confidence} confidenza" + cell_tooltip: "mediana %{median}, MAD %{mad}, n = %{n}, %{confidence} confidenza" + empty_cell: "Nessun evento in questa cella" + col_time: "Ora" + col_log: "Registro" + col_note: "Nota" + jump: "Salta" + log_unloaded: "Quel registro non è più caricato" + copied: "Tabella copiata negli appunti" + copy_failed: "Copia negli appunti non riuscita" + exported: "Tabella esportata in %{path}" + export_failed: "Esportazione non riuscita" + # Tool switcher (src/ui/tool_switcher.rs) tools: log_viewer: "Visualizzatore Log" diff --git a/i18n/ja.yaml b/i18n/ja.yaml index 28e3e82a..406215a8 100644 --- a/i18n/ja.yaml +++ b/i18n/ja.yaml @@ -348,6 +348,54 @@ analysis: remove_result_tooltip: "結果を削除" add_to_chart_result: "チャンネルとしてチャートに追加" +# Table generators (src/ui/table_generator.rs, src/ui/tools_panel.rs) +table_gen: + section_title: "テーブルジェネレータ" + section_help: "ログされたイベントをラムダ遅延と加速エンリッチメントテーブルに分類します。" + load_file_hint: "ファイルを読み込んでテーブルを生成します" + status: "%{logs}個のログ、%{events}個のイベント" + window_title: "テーブルジェネレータ" + setup_header: "セットアップ - %{file}" + channels: "チャンネル" + auto_detect: "自動検出" + none: "(なし)" + ambiguous_hint: "2つのチャンネルがこのロール向けにほぼ同じスコアを獲得しました。選択を確認してください。" + required_hint: "このロールは必須です。" + load_axis: "負荷軸:" + axes: "軸 (セルエッジ)" + reset_axes: "データからリセット" + bins: "%{n}個のビン" + invalid_axis: "少なくとも2つの数値を入力してください" + parameters: "パラメータ" + run: "現在のファイルで実行" + add_file: "現在のファイルをテーブルに追加" + rerun: "現在のファイルを再実行" + missing_roles: "必須ロールをマップします: %{roles}" + results: "結果" + export_csv: "CSVをエクスポート" + copy: "貼り付け用にコピー" + copy_hint: "チューニングソフトウェアに貼り付けるタブ区切り値グリッド" + exclude_low: "低信頼度セルを空白にする" + exclude_low_hint: "3未満のサンプルまたは幅広いセルはエクスポート時に空白のままになります" + cylinders: "シリンダー" + remove_log: "ログを削除…" + reset: "リセット" + coverage: "%{logs}個のログから%{events}個の受け入れられたイベント · %{filled}/%{total}セルが埋まっています、%{high}信頼度が高い" + notes: "ノート (%{n})" + no_events: "まだ受け入れられたイベントはありません。上記の却下分類を確認し、パラメータを調整するか、その動作範囲からさらにログを記録してください。" + cell_title: "%{x} × %{y}: %{n}イベント、中央値%{median}、MAD %{mad}、%{confidence}信頼度" + cell_tooltip: "中央値%{median}、MAD %{mad}、n = %{n}、%{confidence}信頼度" + empty_cell: "このセルにはイベントがありません" + col_time: "時刻" + col_log: "ログ" + col_note: "メモ" + jump: "ジャンプ" + log_unloaded: "そのログはもう読み込まれていません" + copied: "テーブルをクリップボードにコピーしました" + copy_failed: "クリップボードへのコピーが失敗しました" + exported: "テーブルが%{path}にエクスポートされました" + export_failed: "エクスポートに失敗しました" + # Tool switcher (src/ui/tool_switcher.rs) tools: log_viewer: "ログビューア" diff --git a/i18n/pt-BR.yaml b/i18n/pt-BR.yaml index c72cb905..08a7c755 100644 --- a/i18n/pt-BR.yaml +++ b/i18n/pt-BR.yaml @@ -349,6 +349,55 @@ analysis: add_to_chart_result: "Adicionar ao gráfico como canal" # Alternador de ferramentas (src/ui/tool_switcher.rs) +# Table generators (src/ui/table_generator.rs, src/ui/tools_panel.rs) +table_gen: + section_title: "Geradores de Tabela" + section_help: "Minerar eventos registrados em tabelas de enriquecimento de atraso lambda e aceleração." + load_file_hint: "Carregue um arquivo para gerar tabelas" + status: "%{logs} registro(s), %{events} eventos" + window_title: "Geradores de Tabela" + setup_header: "Configuração - %{file}" + channels: "Canais" + auto_detect: "Detecção automática" + none: "(nenhum)" + ambiguous_hint: "Dois canais obtiveram pontuações quase iguais para este papel. Confirme a escolha." + required_hint: "Este papel é obrigatório." + load_axis: "Eixo de carga:" + axes: "Eixos (bordas de célula)" + reset_axes: "Redefinir a partir dos dados" + bins: "%{n} caixas" + invalid_axis: "Digite pelo menos dois números" + parameters: "Parâmetros" + run: "Executar no arquivo atual" + add_file: "Adicionar arquivo atual à tabela" + rerun: "Re-executar arquivo atual" + missing_roles: "Mapear papéis obrigatórios: %{roles}" + results: "Resultados" + export_csv: "Exportar CSV" + copy: "Copiar para colar" + copy_hint: "Grade de valores separados por tabulação para colar no software de tuning" + exclude_low: "Células em branco com baixa confiança" + exclude_low_hint: "Células com menos de 3 amostras ou um intervalo amplo ficam em branco na exportação" + cylinders: "Cilindros" + remove_log: "Remover registro…" + reset: "Redefinir" + coverage: "%{events} eventos aceitos de %{logs} registro(s) · %{filled}/%{total} células preenchidas, %{high} confiança alta" + notes: "Notas (%{n})" + no_events: "Nenhum evento aceito ainda. Verifique o detalhamento de rejeição acima e ajuste os parâmetros ou registre mais desse intervalo de operação." + cell_title: "%{x} × %{y}: %{n} eventos, mediana %{median}, MAD %{mad}, %{confidence} confiança" + cell_tooltip: "mediana %{median}, MAD %{mad}, n = %{n}, %{confidence} confiança" + empty_cell: "Nenhum evento nesta célula" + col_time: "Hora" + col_log: "Registro" + col_note: "Nota" + jump: "Pular" + log_unloaded: "Esse registro não está mais carregado" + copied: "Tabela copiada para a área de transferência" + copy_failed: "Falha ao copiar para a área de transferência" + exported: "Tabela exportada para %{path}" + export_failed: "Falha na exportação" + +# Tool switcher (src/ui/tool_switcher.rs) tools: log_viewer: "Visualizador de Logs" scatter_plots: "Gráficos de Dispersão" diff --git a/i18n/pt-PT.yaml b/i18n/pt-PT.yaml index 59ecb0ab..15c1f81a 100644 --- a/i18n/pt-PT.yaml +++ b/i18n/pt-PT.yaml @@ -348,6 +348,54 @@ analysis: remove_result_tooltip: "Remover resultado" add_to_chart_result: "Adicionar ao gráfico como canal" +# Table generators (src/ui/table_generator.rs, src/ui/tools_panel.rs) +table_gen: + section_title: "Geradores de Tabela" + section_help: "Extrair eventos registados em tabelas de enriquecimento de atraso lambda e aceleração." + load_file_hint: "Carregue um ficheiro para gerar tabelas" + status: "%{logs} registo(s), %{events} eventos" + window_title: "Geradores de Tabela" + setup_header: "Configuração - %{file}" + channels: "Canais" + auto_detect: "Detecção automática" + none: "(nenhum)" + ambiguous_hint: "Dois canais obtiveram pontuações quase iguais para este papel. Confirme a escolha." + required_hint: "Este papel é obrigatório." + load_axis: "Eixo de carga:" + axes: "Eixos (bordas de célula)" + reset_axes: "Repor a partir dos dados" + bins: "%{n} caixas" + invalid_axis: "Digite pelo menos dois números" + parameters: "Parâmetros" + run: "Executar no ficheiro actual" + add_file: "Adicionar ficheiro actual à tabela" + rerun: "Re-executar ficheiro actual" + missing_roles: "Mapear papéis obrigatórios: %{roles}" + results: "Resultados" + export_csv: "Exportar CSV" + copy: "Copiar para colar" + copy_hint: "Grelha de valores separados por tabulação para colar no software de tuning" + exclude_low: "Células em branco com baixa confiança" + exclude_low_hint: "Células com menos de 3 amostras ou um intervalo amplo ficam em branco na exportação" + cylinders: "Cilindros" + remove_log: "Remover registo…" + reset: "Repor" + coverage: "%{events} eventos aceites de %{logs} registo(s) · %{filled}/%{total} células preenchidas, %{high} confiança alta" + notes: "Notas (%{n})" + no_events: "Nenhum evento aceite ainda. Verifique a desagregação da rejeição acima e ajuste os parâmetros ou registe mais desse intervalo operacional." + cell_title: "%{x} × %{y}: %{n} eventos, mediana %{median}, MAD %{mad}, %{confidence} confiança" + cell_tooltip: "mediana %{median}, MAD %{mad}, n = %{n}, %{confidence} confiança" + empty_cell: "Nenhum evento nesta célula" + col_time: "Hora" + col_log: "Registo" + col_note: "Nota" + jump: "Saltar" + log_unloaded: "Esse registo não está mais carregado" + copied: "Tabela copiada para a área de transferência" + copy_failed: "Falha ao copiar para a área de transferência" + exported: "Tabela exportada para %{path}" + export_failed: "Falha na exportação" + # Tool switcher (src/ui/tool_switcher.rs) tools: log_viewer: "Visualizador de Registos" diff --git a/i18n/ru.yaml b/i18n/ru.yaml index a23d814a..3a7792fe 100644 --- a/i18n/ru.yaml +++ b/i18n/ru.yaml @@ -348,6 +348,54 @@ analysis: remove_result_tooltip: "Удалить результат" add_to_chart_result: "Добавить на график как канал" +# Table generators (src/ui/table_generator.rs, src/ui/tools_panel.rs) +table_gen: + section_title: "Генераторы таблиц" + section_help: "Извлечение записанных событий в таблицы задержки лямбда и обогащения ускорения." + load_file_hint: "Загрузите файл для создания таблиц" + status: "%{logs} журнал(ов), %{events} события" + window_title: "Генераторы таблиц" + setup_header: "Настройка - %{file}" + channels: "Каналы" + auto_detect: "Автоматическое определение" + none: "(нет)" + ambiguous_hint: "Два канала получили почти одинаковые оценки для этой роли. Подтвердите выбор." + required_hint: "Эта роль обязательна." + load_axis: "Ось нагрузки:" + axes: "Оси (края ячейки)" + reset_axes: "Сбросить по данным" + bins: "%{n} ячейки" + invalid_axis: "Введите хотя бы два числа" + parameters: "Параметры" + run: "Запустить в текущем файле" + add_file: "Добавить текущий файл в таблицу" + rerun: "Переисполнить текущий файл" + missing_roles: "Назначить требуемые роли: %{roles}" + results: "Результаты" + export_csv: "Экспорт CSV" + copy: "Копировать для вставки" + copy_hint: "Сетка значений с разделением табуляцией для вставки в программное обеспечение настройки" + exclude_low: "Пустые ячейки низкой уверенности" + exclude_low_hint: "Ячейки с менее чем 3 образцами или большим разбросом остаются пустыми при экспорте" + cylinders: "Цилиндры" + remove_log: "Удалить журнал…" + reset: "Сброс" + coverage: "%{events} принятых событий из %{logs} журнала(ов) · %{filled}/%{total} ячейки заполнены, %{high} высокая уверенность" + notes: "Заметки (%{n})" + no_events: "Пока нет принятых событий. Проверьте подробную информацию об отклонениях выше и отрегулируйте параметры или произведите логирование большего количества этого диапазона работы." + cell_title: "%{x} × %{y}: %{n} события, медиана %{median}, MAD %{mad}, %{confidence} уверенность" + cell_tooltip: "медиана %{median}, MAD %{mad}, n = %{n}, %{confidence} уверенность" + empty_cell: "Нет событий в этой ячейке" + col_time: "Время" + col_log: "Журнал" + col_note: "Примечание" + jump: "Перейти" + log_unloaded: "Этот журнал больше не загружен" + copied: "Таблица скопирована в буфер обмена" + copy_failed: "Ошибка копирования в буфер обмена" + exported: "Таблица экспортирована в %{path}" + export_failed: "Ошибка экспорта" + # Tool switcher (src/ui/tool_switcher.rs) tools: log_viewer: "Просмотр логов" diff --git a/i18n/ur.yaml b/i18n/ur.yaml index 3aa60417..1581905b 100644 --- a/i18n/ur.yaml +++ b/i18n/ur.yaml @@ -349,6 +349,54 @@ analysis: remove_result_tooltip: "نتیجہ ہٹائیں" add_to_chart_result: "چینل کے طور پر چارٹ میں شامل کریں" +# Table generators (src/ui/table_generator.rs, src/ui/tools_panel.rs) +table_gen: + section_title: "ٹیبل جنریٹرز" + section_help: "لاگ شدہ واقعات کو lambda تاخیر اور سرعت میں اضافے کی میزیں معدن سے نکالیں۔" + load_file_hint: "ٹیبل تیار کرنے کے لیے فائل لوڈ کریں" + status: "%{logs} لاگ، %{events} واقعات" + window_title: "ٹیبل جنریٹرز" + setup_header: "سیٹ اپ - %{file}" + channels: "چینلز" + auto_detect: "خودکار دریافت" + none: "(کوئی نہیں)" + ambiguous_hint: "دونوں چینلز نے اس کردار کے لیے تقریباً یکساں اسکور حاصل کیے۔ اپنی پسند کی تصدیق کریں۔" + required_hint: "یہ کردار ضروری ہے۔" + load_axis: "بوجھ کی سمت:" + axes: "محور (سیل کے کنارے)" + reset_axes: "ڈیٹا سے دوبارہ سیٹ کریں" + bins: "%{n} ڈبے" + invalid_axis: "کم از کم دو نمبر درج کریں" + parameters: "پیرامیٹرز" + run: "موجودہ فائل پر چلائیں" + add_file: "موجودہ فائل کو ٹیبل میں شامل کریں" + rerun: "موجودہ فائل کو دوبارہ چلائیں" + missing_roles: "ضروری کرداریں درج کریں: %{roles}" + results: "نتائج" + export_csv: "CSV برآمد کریں" + copy: "لگانے کے لیے کاپی کریں" + copy_hint: "ٹیب سے الگ کی گئی قدر گرڈ ٹیون کرنے والے سافٹ ویئر میں لگانے کے لیے" + exclude_low: "کم اعتماد والے خانے خالی کریں" + exclude_low_hint: "3 سے کم نمونوں یا وسیع پھیلاؤ والے خانے برآمد کرتے وقت خالی رہتے ہیں" + cylinders: "سلنڈر" + remove_log: "لاگ ہٹائیں…" + reset: "دوبارہ سیٹ کریں" + coverage: "%{logs} لاگ سے %{events} قبول شدہ واقعات · %{filled}/%{total} خانے بھرے ہوئے، %{high} اعلیٰ اعتماد" + notes: "نوٹس (%{n})" + no_events: "ابھی کوئی قبول شدہ واقعات نہیں۔ اوپر انکار کی تفصیل چیک کریں اور پیرامیٹرز میں ترمیم کریں یا اس آپریٹنگ رینج سے مزید لاگ کریں۔" + cell_title: "%{x} × %{y}: %{n} واقعات، میڈین %{median}، MAD %{mad}، %{confidence} اعتماد" + cell_tooltip: "میڈین %{median}، MAD %{mad}، n = %{n}، %{confidence} اعتماد" + empty_cell: "اس خانے میں کوئی واقعات نہیں" + col_time: "وقت" + col_log: "لاگ" + col_note: "نوٹ" + jump: "جمپ کریں" + log_unloaded: "وہ لاگ اب لوڈ نہیں ہے" + copied: "ٹیبل کلپ بورڈ پر کاپی کیا گیا" + copy_failed: "کلپ بورڈ پر کاپی کرنے میں ناکام" + exported: "ٹیبل %{path} پر برآمد کیا گیا" + export_failed: "برآمد ناکام" + # Tool switcher (src/ui/tool_switcher.rs) tools: log_viewer: "لاگ ویور" diff --git a/i18n/zh-CN.yaml b/i18n/zh-CN.yaml index 3d8486f7..efd5e2a4 100644 --- a/i18n/zh-CN.yaml +++ b/i18n/zh-CN.yaml @@ -349,6 +349,55 @@ analysis: add_to_chart_result: "作为通道添加到图表" # 工具切换器 (src/ui/tool_switcher.rs) +# Table generators (src/ui/table_generator.rs, src/ui/tools_panel.rs) +table_gen: + section_title: "表格生成器" + section_help: "将记录的事件挖掘到λ延迟和加速浓缩表中。" + load_file_hint: "加载文件以生成表格" + status: "%{logs}个日志,%{events}个事件" + window_title: "表格生成器" + setup_header: "设置 - %{file}" + channels: "通道" + auto_detect: "自动检测" + none: "(无)" + ambiguous_hint: "两个通道在此角色的评分几乎相同。请确认您的选择。" + required_hint: "此角色是必需的。" + load_axis: "负载轴:" + axes: "轴(单元格边界)" + reset_axes: "从数据重置" + bins: "%{n}个箱" + invalid_axis: "请输入至少两个数字" + parameters: "参数" + run: "在当前文件上运行" + add_file: "将当前文件添加到表格" + rerun: "重新运行当前文件" + missing_roles: "映射所需角色: %{roles}" + results: "结果" + export_csv: "导出CSV" + copy: "复制粘贴" + copy_hint: "制表符分隔的值网格,用于粘贴到调谐软件中" + exclude_low: "空白低置信度单元格" + exclude_low_hint: "少于3个样本或范围宽的单元格在导出时留空" + cylinders: "气缸" + remove_log: "删除日志…" + reset: "重置" + coverage: "%{logs}个日志中%{events}个接受的事件·%{filled}/%{total}个单元格已填充,%{high}高置信度" + notes: "注释(%{n})" + no_events: "尚无接受的事件。检查上面的拒绝明细,调整参数或记录更多该操作范围。" + cell_title: "%{x}×%{y}:%{n}个事件,中位数%{median},MAD %{mad},%{confidence}置信度" + cell_tooltip: "中位数%{median},MAD %{mad},n = %{n},%{confidence}置信度" + empty_cell: "此单元格中没有事件" + col_time: "时间" + col_log: "日志" + col_note: "备注" + jump: "跳转" + log_unloaded: "该日志不再加载" + copied: "表格已复制到剪贴板" + copy_failed: "复制到剪贴板失败" + exported: "表格导出到%{path}" + export_failed: "导出失败" + +# Tool switcher (src/ui/tool_switcher.rs) tools: log_viewer: "日志查看器" scatter_plots: "散点图" diff --git a/src/analysis/mod.rs b/src/analysis/mod.rs index 17de11e7..a31f595e 100644 --- a/src/analysis/mod.rs +++ b/src/analysis/mod.rs @@ -14,6 +14,7 @@ pub mod afr; pub mod derived; pub mod filters; pub mod statistics; +pub mod tables; use crate::parsers::types::Log; use std::collections::HashMap; diff --git a/src/analysis/tables/accel_enrich.rs b/src/analysis/tables/accel_enrich.rs new file mode 100644 index 00000000..3cde7853 --- /dev/null +++ b/src/analysis/tables/accel_enrich.rs @@ -0,0 +1,1217 @@ +//! Acceleration enrichment table generator (issue #3). +//! +//! Physical model: on tip-in, airflow rises faster than the fuel film +//! delivers and the mixture spikes lean for 100 ms to 1 s. Per RPM × tip-in +//! rate cell the tuner wants the depth and duration of that excursion and a +//! starting-point correction. +//! +//! Algorithm, per log: +//! +//! 1. Throttle rate: the native derivative channel when mapped (with its +//! scale detected against a computed derivative, Haltech logs ×10), else +//! the derivative of a median-filtered TPS, else MAP rate as a fallback. +//! 2. Tip-in events: runs of at least two samples above the rate threshold; +//! runs closer than `merge_gap_ms` merge into the larger event. +//! 3. Lambda delay compensation: the AFR window is shifted by the matching +//! cell of a lambda-delay table from the same session when that cell is +//! Medium/High confidence, else by `assumed_delay_ms`. +//! 4. Excursion: signed relative deviation from the reference (mapped target +//! channel, else pre-event baseline). Peak, duration above the lean band, +//! and area until recovery. +//! 5. Suggested correction: the steady-flow fuel deficit at the peak, +//! `peak deviation × 100 %`, clamped; **a starting point, not a final +//! value**. When an ECU AE-activity role is mapped the excursion is the +//! residual on top of what the ECU already added, so the correction is +//! *additional* and multiplies the ECU's current value. +//! 6. Bin by RPM × peak rate. Median per cell. + +use std::collections::HashMap; + +use super::channel_map::{ChannelMapping, ChannelRole, RoleSpec}; +use super::events::{ + any_in_window, find_rate_runs, integrate, invalid_fraction, mask_invalid, merge_runs, + window_median, +}; +use super::stats::{index_range, median, median_interval, update_instants}; +use super::{ + AxisSpec, Confidence, GeneratorContext, MeasureSpec, RejectReason, RunReport, TableAnalyzer, + TableEvent, TableParam, TableParamKind, mapped_column, param_f64, required_column, +}; +use crate::analysis::afr::{FuelMixtureUnit, STOICH_AFR_GASOLINE, detect_fuel_mixture_unit}; +use crate::analysis::filters::median_filter; +use crate::analysis::statistics::time_derivative; +use crate::analysis::{AnalysisError, AnalyzerConfig, timed_analyze}; +use crate::parsers::types::Log; + +pub const ID: &str = "accel_enrich"; + +/// Slowest log rate the generator will work with (4 Hz). +const MAX_SAMPLE_INTERVAL_S: f64 = 0.25; +/// Baseline window before the tip-in for RPM / lambda medians. +const BASELINE_S: f64 = 0.25; +/// Time the deviation must stay inside the recovery band to end the event. +const RECOVERY_HOLD_S: f64 = 0.1; +const MAX_INVALID_FRACTION: f64 = 0.10; +/// Geometric rate axis for TPS rate (%/s). +const TPS_RATE_EDGES: [f64; 8] = [25.0, 50.0, 100.0, 200.0, 400.0, 800.0, 1600.0, 3200.0]; +/// Geometric rate axis for MAP rate (kPa/s). +const MAP_RATE_EDGES: [f64; 7] = [100.0, 200.0, 400.0, 800.0, 1600.0, 3200.0, 6400.0]; + +/// Whether the table's suggestions add to, or replace, the ECU's AE value. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CorrectionKind { + /// No ECU AE-activity role mapped: the excursion is the whole deficit. + Absolute, + /// An AE-activity role is mapped: the excursion is the residual on top + /// of the ECU's current enrichment, so multiply the ECU value. + Additional, +} + +impl CorrectionKind { + pub fn label(self) -> &'static str { + match self { + Self::Absolute => "absolute", + Self::Additional => "additional to current AE", + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct AccelEnrichGenerator { + /// Tip-in trigger on throttle rate (%/s). + pub tps_rate_threshold: f64, + /// Tip-in trigger on MAP rate (kPa/s) when no throttle channel is mapped. + pub map_rate_threshold: f64, + /// Lambda delay used when no session delay table covers the cell. + pub assumed_delay_ms: f64, + /// Relative deviation counted as lean/rich for the duration measure. + pub lean_band: f64, + /// Relative deviation inside which the event is considered recovered. + pub recovery_band: f64, + /// AFR window length after the (delay-shifted) tip-in. + pub max_event_ms: f64, + /// RPM drop over the window that marks a gear shift (a rise is the + /// engine responding to the tip-in). + pub max_rpm_change_pct: f64, + /// Clamp on the suggested correction. + pub correction_clamp_pct: f64, + /// Accel time the area-based suggestion is spread over. + pub area_over_ms: f64, + /// Scale applied to a native rate channel; 0 = detect automatically. + pub tps_rate_scale: f64, + /// Rate runs closer than this merge into one event. + pub merge_gap_ms: f64, + /// Minimum coolant temperature when a coolant role is mapped. + pub min_coolant_temp: f64, +} + +impl Default for AccelEnrichGenerator { + fn default() -> Self { + Self { + tps_rate_threshold: 50.0, + map_rate_threshold: 400.0, + assumed_delay_ms: 120.0, + lean_band: 0.02, + recovery_band: 0.01, + max_event_ms: 2000.0, + max_rpm_change_pct: 25.0, + correction_clamp_pct: 50.0, + area_over_ms: 300.0, + tps_rate_scale: 0.0, + merge_gap_ms: 300.0, + min_coolant_temp: 60.0, + } + } +} + +/// Which signal triggered tip-in detection. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RateSource { + NativeTpsRate, + ComputedTpsRate, + ComputedMapRate, +} + +impl RateSource { + fn axis(self) -> (&'static str, &'static str, &'static [f64]) { + match self { + Self::NativeTpsRate | Self::ComputedTpsRate => ("TPS rate", "%/s", &TPS_RATE_EDGES), + Self::ComputedMapRate => ("MAP rate", "kPa/s", &MAP_RATE_EDGES), + } + } +} + +/// Detect the scale of a native rate channel against a computed derivative: +/// the median ratio over samples where the computed rate is clearly above +/// `threshold`. Haltech's `Throttle Position Derivative` is ×10. +pub fn detect_rate_scale(native: &[f64], computed: &[f64], threshold: f64) -> f64 { + let ratios: Vec = native + .iter() + .zip(computed) + .filter(|(n, c)| n.is_finite() && c.is_finite() && c.abs() > threshold && n.abs() > 1e-6) + .map(|(n, c)| n / c) + .collect(); + match median(&ratios) { + Some(r) if ratios.len() >= 5 && r > 0.0 => { + // Snap to a decade so 9.6 reads as ×10. + let decade = 10f64.powf(r.log10().round()); + 1.0 / decade + } + _ => 1.0, + } +} + +/// Resolve the rate signal and its threshold / axis from the mapping. +fn rate_signal( + gen_: &AccelEnrichGenerator, + log: &Log, + mapping: &ChannelMapping, +) -> Result<(Vec, f64, RateSource, f64), AnalysisError> { + let times = &log.times; + let tps = mapped_column(log, mapping, ChannelRole::Tps)? + .map(|v| mask_invalid(&v, Some((-5.0, 105.0)))); + let computed_tps_rate = tps + .as_ref() + .map(|t| time_derivative(&median_filter(t, 3), times)); + if let Some(native) = mapped_column(log, mapping, ChannelRole::TpsRate)? { + let native = mask_invalid(&native, None); + let scale = if gen_.tps_rate_scale > 0.0 { + gen_.tps_rate_scale + } else if let Some(c) = &computed_tps_rate { + detect_rate_scale(&native, c, gen_.tps_rate_threshold) + } else { + 1.0 + }; + let rate: Vec = native.iter().map(|v| v * scale).collect(); + return Ok(( + rate, + gen_.tps_rate_threshold, + RateSource::NativeTpsRate, + scale, + )); + } + if let Some(rate) = computed_tps_rate { + return Ok(( + rate, + gen_.tps_rate_threshold, + RateSource::ComputedTpsRate, + 1.0, + )); + } + if let Some(map) = mapped_column(log, mapping, ChannelRole::Map)? { + let map = mask_invalid(&map, Some((-110.0, 600.0))); + let rate = time_derivative(&median_filter(&map, 3), times); + return Ok(( + rate, + gen_.map_rate_threshold, + RateSource::ComputedMapRate, + 1.0, + )); + } + Err(AnalysisError::MissingChannel( + "Throttle position, throttle rate or manifold pressure".to_string(), + )) +} + +impl TableAnalyzer for AccelEnrichGenerator { + fn id(&self) -> &'static str { + ID + } + + fn name(&self) -> &'static str { + "Acceleration Enrichment Table" + } + + fn description(&self) -> &'static str { + "Detects tip-in events, measures the lean/rich excursion against target after the lambda delay, \ + and suggests a starting enrichment correction binned by RPM and throttle rate." + } + + fn roles(&self) -> Vec { + vec![ + RoleSpec::required(ChannelRole::Rpm), + RoleSpec::optional(ChannelRole::Tps), + RoleSpec::optional(ChannelRole::TpsRate), + RoleSpec::optional(ChannelRole::Map), + RoleSpec::required(ChannelRole::Lambda), + RoleSpec::optional(ChannelRole::LambdaTarget), + RoleSpec::optional(ChannelRole::AeActive), + RoleSpec::optional(ChannelRole::Clutch), + RoleSpec::optional(ChannelRole::CoolantTemp), + ] + } + + fn measures(&self) -> Vec { + vec![ + MeasureSpec { + key: "correction_pct", + label: "Suggested correction", + unit: "%", + decimals: 1, + }, + MeasureSpec { + key: "depth_lambda", + label: "Excursion depth", + unit: "λ", + decimals: 3, + }, + MeasureSpec { + key: "duration_ms", + label: "Excursion duration", + unit: "ms", + decimals: 0, + }, + MeasureSpec { + key: "area_pct", + label: "Area-based correction", + unit: "%", + decimals: 1, + }, + MeasureSpec { + key: "area_lambda_s", + label: "Excursion area", + unit: "λ·s", + decimals: 4, + }, + MeasureSpec { + key: "delay_used_ms", + label: "Delay compensation", + unit: "ms", + decimals: 0, + }, + ] + } + + fn default_axes(&self, log: &Log, mapping: &ChannelMapping) -> (AxisSpec, AxisSpec) { + let rpm = mapped_column(log, mapping, ChannelRole::Rpm) + .ok() + .flatten() + .map(|v| mask_invalid(&v, Some((0.0, 20_000.0)))) + .unwrap_or_default(); + let fallback_rpm: Vec = (1..=16).map(|i| i as f64 * 500.0).collect(); + let x = AxisSpec::from_data("RPM", "", &rpm, 500.0, &fallback_rpm); + let source = match rate_signal(self, log, mapping) { + Ok((_, _, s, _)) => s, + Err(_) => RateSource::ComputedTpsRate, + }; + let (label, unit, edges) = source.axis(); + (x, AxisSpec::new(label, unit, edges.to_vec())) + } + + fn analyze( + &self, + log: &Log, + log_name: &str, + mapping: &ChannelMapping, + axes: &(AxisSpec, AxisSpec), + ctx: &GeneratorContext<'_>, + ) -> Result<(Vec, RunReport), AnalysisError> { + let times = &log.times; + if times.len() < 10 { + return Err(AnalysisError::InsufficientData { + needed: 10, + got: times.len(), + }); + } + let dt = median_interval(times).ok_or_else(|| { + AnalysisError::ComputationError("log has no usable time axis".to_string()) + })?; + if dt > MAX_SAMPLE_INTERVAL_S { + return Err(AnalysisError::InvalidParameter(format!( + "log rate is {:.1} Hz; accel enrichment needs at least 4 Hz", + 1.0 / dt + ))); + } + + let rpm = mask_invalid( + &required_column(log, mapping, ChannelRole::Rpm)?, + Some((0.0, 20_000.0)), + ); + let (rate, threshold, source, scale) = rate_signal(self, log, mapping)?; + let lambda_raw = mask_invalid(&required_column(log, mapping, ChannelRole::Lambda)?, None); + let unit = detect_fuel_mixture_unit( + &lambda_raw + .iter() + .copied() + .filter(|v| v.is_finite()) + .collect::>(), + ); + let to_lambda = |v: f64, u: FuelMixtureUnit| match u { + FuelMixtureUnit::Lambda => v, + FuelMixtureUnit::Afr => v / STOICH_AFR_GASOLINE, + }; + let lambda: Vec = mask_invalid( + &lambda_raw, + Some(match unit { + FuelMixtureUnit::Lambda => (0.4, 2.0), + FuelMixtureUnit::Afr => (5.0, 30.0), + }), + ) + .iter() + .map(|&v| to_lambda(v, unit)) + .collect(); + let target: Option> = match mapped_column(log, mapping, ChannelRole::LambdaTarget)? + { + Some(t) => { + let t = mask_invalid(&t, None); + let tu = detect_fuel_mixture_unit( + &t.iter() + .copied() + .filter(|v| v.is_finite()) + .collect::>(), + ); + Some( + mask_invalid( + &t, + Some(match tu { + FuelMixtureUnit::Lambda => (0.4, 2.0), + FuelMixtureUnit::Afr => (5.0, 30.0), + }), + ) + .iter() + .map(|&v| to_lambda(v, tu)) + .collect(), + ) + } + None => None, + }; + let ae_active = + mapped_column(log, mapping, ChannelRole::AeActive)?.map(|v| mask_invalid(&v, None)); + // Speeduino / MegaSquirt log AE as a percentage that idles at 100. + let ae_threshold = ae_active.as_ref().and_then(|a| median(a)).map_or(0.5, |m| { + if (90.0..=110.0).contains(&m) { + m + 0.5 + } else { + 0.5 + } + }); + let kind = if ae_active.is_some() { + CorrectionKind::Additional + } else { + CorrectionKind::Absolute + }; + let clutch = mapped_column(log, mapping, ChannelRole::Clutch)?; + let coolant = + mapped_column(log, mapping, ChannelRole::CoolantTemp)?.map(|v| mask_invalid(&v, None)); + let load_for_delay = match mapped_column(log, mapping, ChannelRole::Map)? { + Some(m) => Some(mask_invalid(&m, Some((-110.0, 600.0)))), + None => mapped_column(log, mapping, ChannelRole::Tps)? + .map(|t| mask_invalid(&t, Some((-5.0, 105.0)))), + }; + + let mut warnings = Vec::new(); + warnings.push(format!("Lambda channel detected as {}", unit.unit_name())); + match source { + RateSource::NativeTpsRate => warnings.push(format!( + "Using native throttle rate channel (scale ×{scale})" + )), + RateSource::ComputedTpsRate => { + warnings.push("Throttle rate computed from throttle position".to_string()) + } + RateSource::ComputedMapRate => warnings.push( + "No throttle channel mapped; using MAP rate as the tip-in trigger".to_string(), + ), + } + if target.is_none() { + warnings.push( + "No lambda target mapped; excursions are measured against the pre-event baseline" + .to_string(), + ); + } + if ctx.delay_table.is_none() { + warnings.push(format!( + "No lambda delay table in session; using assumed delay of {:.0} ms", + self.assumed_delay_ms + )); + } + warnings.push(format!("Correction kind: {}", kind.label())); + + let instants = update_instants(&lambda); + let max_event_s = self.max_event_ms / 1000.0; + let min_run = 2usize; + + let (events, elapsed) = timed_analyze(|| { + let runs = merge_runs( + times, + &find_rate_runs(&rate, threshold, min_run), + self.merge_gap_ms / 1000.0, + ); + let mut events = Vec::with_capacity(runs.len()); + for run in runs { + let t_start = times[run.start]; + let rpm_at = window_median(times, &rpm, t_start - BASELINE_S, t_start + dt) + .unwrap_or(f64::NAN); + let mut values = vec![f64::NAN; 6]; + let mut note = String::new(); + let make = |reject: Option, + values: Vec, + quality: f32, + note: String| TableEvent { + log_id: 0, + log_name: log_name.to_string(), + time: t_start, + rpm: rpm_at, + axis_value: run.peak, + values, + quality, + reject, + note, + }; + + // Delay compensation. + let mut delay_s = self.assumed_delay_ms / 1000.0; + let mut delay_from_table = false; + if let (Some(grid), Some(load)) = (ctx.delay_table, &load_for_delay) + && let Some(load_at) = + window_median(times, load, t_start - BASELINE_S, t_start + dt) + && let (Some(c), Some(r)) = ( + grid.x_axis.bin_index(rpm_at), + grid.y_axis.bin_index(load_at), + ) + && let Some(cell) = grid.cell(r, c) + && matches!(cell.confidence, Confidence::Medium | Confidence::High) + { + delay_s = cell.median / 1000.0; + delay_from_table = true; + } + values[5] = delay_s * 1000.0; + note.push_str(if delay_from_table { + "delay from table" + } else { + "assumed delay" + }); + let w0 = t_start + delay_s; + let w1 = w0 + max_event_s; + let window = index_range(times, w0, w1); + + if !rpm_at.is_finite() || window.is_empty() { + events.push(make(Some(RejectReason::InvalidSamples), values, 0.0, note)); + continue; + } + if let Some(cl) = &clutch + && any_in_window(times, cl, t_start - BASELINE_S, w1, |v| v > 0.5) + { + events.push(make(Some(RejectReason::Clutch), values, 0.0, note)); + continue; + } + if let Some(ct) = &coolant + && window_median(times, ct, t_start - BASELINE_S, w1) + .is_some_and(|c| c < self.min_coolant_temp) + { + events.push(make(Some(RejectReason::ColdEngine), values, 0.0, note)); + continue; + } + // RPM rising after a tip-in is the engine responding; an RPM + // *drop* of more than the limit mid-window is an upshift. + let rpm_window = index_range(times, t_start, w1); + let rpm_min = rpm[rpm_window.start.min(rpm.len())..rpm_window.end.min(rpm.len())] + .iter() + .copied() + .filter(|v| v.is_finite()) + .fold(f64::INFINITY, f64::min); + if !rpm_min.is_finite() + || rpm_min < rpm_at * (1.0 - self.max_rpm_change_pct / 100.0) + { + events.push(make(Some(RejectReason::GearShift), values, 0.0, note)); + continue; + } + if invalid_fraction(&lambda[window.clone()]) > MAX_INVALID_FRACTION { + events.push(make(Some(RejectReason::InvalidSamples), values, 0.0, note)); + continue; + } + if let Some(ae) = &ae_active { + let active = any_in_window(times, ae, t_start, w1, |v| v > ae_threshold); + if !active { + events.push(make(Some(RejectReason::AeKindMismatch), values, 0.0, note)); + continue; + } + note.push_str(", ECU AE active"); + } + if axes.0.bin_index(rpm_at).is_none() || axes.1.bin_index(run.peak).is_none() { + events.push(make(Some(RejectReason::OutOfAxis), values, 0.0, note)); + continue; + } + + // Reference and deviation. + let baseline = window_median(times, &lambda, t_start - BASELINE_S, t_start); + let reference = |i: usize| -> f64 { + match &target { + Some(t) if t[i].is_finite() => t[i], + _ => baseline.unwrap_or(f64::NAN), + } + }; + let dev = |i: usize| -> f64 { + let r = reference(i); + if lambda[i].is_finite() && r.is_finite() && r > 0.0 { + lambda[i] / r - 1.0 + } else { + f64::NAN + } + }; + let post: Vec = instants + .iter() + .copied() + .filter(|i| window.contains(i)) + .collect(); + let Some(&peak_i) = + post.iter() + .filter(|&&i| dev(i).is_finite()) + .max_by(|&&a, &&b| { + dev(a) + .abs() + .partial_cmp(&dev(b).abs()) + .unwrap_or(std::cmp::Ordering::Equal) + }) + else { + events.push(make(Some(RejectReason::InvalidSamples), values, 0.0, note)); + continue; + }; + let peak_dev = dev(peak_i); + let sign = if peak_dev >= 0.0 { 1.0 } else { -1.0 }; + let depth = lambda[peak_i] - reference(peak_i); + + // Duration above the lean band in the peak's direction. + let mut duration = 0.0; + for i in window.clone() { + let d = dev(i); + if d.is_finite() && d * sign > self.lean_band && i + 1 < times.len() { + duration += times[i + 1] - times[i]; + } + } + + // Area from the first sample outside the recovery band until + // the deviation stays inside it for RECOVERY_HOLD_S. + let first = window + .clone() + .find(|&i| dev(i).is_finite() && dev(i).abs() > self.recovery_band); + let area = match first { + Some(start) => { + let mut end = window.end - 1; + let mut inside_since: Option = None; + for (i, &t) in times.iter().enumerate().take(window.end).skip(peak_i) { + let d = dev(i); + if d.is_finite() && d.abs() <= self.recovery_band { + let since = *inside_since.get_or_insert(t); + if t - since >= RECOVERY_HOLD_S { + end = i; + break; + } + } else { + inside_since = None; + } + } + integrate(times, start, end, dev) + } + None => 0.0, + }; + + let clamp = self.correction_clamp_pct; + let correction = (peak_dev * 100.0).clamp(-clamp, clamp); + let area_pct = (area / (self.area_over_ms / 1000.0) * 100.0).clamp(-clamp, clamp); + values[0] = correction; + values[1] = depth; + values[2] = duration * 1000.0; + values[3] = area_pct; + values[4] = area; + let quality = if delay_from_table { 1.0 } else { 0.7 }; + note.push_str(&format!(", peak rate {:.0}, {}", run.peak, kind.label())); + events.push(make(None, values, quality, note)); + } + events + }); + + let mut report = RunReport::from_events(log_name, &events); + report.warnings = warnings; + report.computation_time_ms = elapsed; + Ok((events, report)) + } + + fn params(&self) -> Vec { + vec![ + TableParam { + key: "tps_rate_threshold", + label: "TPS rate trigger (%/s)", + tooltip: "Throttle rate that starts a tip-in event.", + kind: TableParamKind::Float { + min: 5.0, + max: 2000.0, + speed: 1.0, + }, + }, + TableParam { + key: "map_rate_threshold", + label: "MAP rate trigger (kPa/s)", + tooltip: "Fallback trigger when no throttle channel is mapped.", + kind: TableParamKind::Float { + min: 20.0, + max: 10000.0, + speed: 10.0, + }, + }, + TableParam { + key: "assumed_delay_ms", + label: "Assumed lambda delay (ms)", + tooltip: "Used when no lambda-delay table covers the cell.", + kind: TableParamKind::Float { + min: 0.0, + max: 2000.0, + speed: 5.0, + }, + }, + TableParam { + key: "lean_band", + label: "Lean band (λ)", + tooltip: "Relative deviation counted as an excursion for the duration measure.", + kind: TableParamKind::Float { + min: 0.001, + max: 0.5, + speed: 0.001, + }, + }, + TableParam { + key: "recovery_band", + label: "Recovery band (λ)", + tooltip: "Deviation inside which the event is considered recovered.", + kind: TableParamKind::Float { + min: 0.001, + max: 0.5, + speed: 0.001, + }, + }, + TableParam { + key: "max_event_ms", + label: "Window (ms)", + tooltip: "AFR window length after the delay-shifted tip-in.", + kind: TableParamKind::Float { + min: 200.0, + max: 5000.0, + speed: 10.0, + }, + }, + TableParam { + key: "max_rpm_change_pct", + label: "Gear-shift RPM change (%)", + tooltip: "RPM drop over the window that rejects the event as a gear shift.", + kind: TableParamKind::Float { + min: 5.0, + max: 100.0, + speed: 1.0, + }, + }, + TableParam { + key: "correction_clamp_pct", + label: "Correction clamp (%)", + tooltip: "Suggestions are clamped to ± this value.", + kind: TableParamKind::Float { + min: 5.0, + max: 200.0, + speed: 1.0, + }, + }, + TableParam { + key: "area_over_ms", + label: "Area spread over (ms)", + tooltip: "Accel time the area-based suggestion is spread over (ECU accel time).", + kind: TableParamKind::Float { + min: 50.0, + max: 2000.0, + speed: 10.0, + }, + }, + TableParam { + key: "tps_rate_scale", + label: "Native rate scale (0 = auto)", + tooltip: "Multiplier for a native throttle-rate channel; 0 detects it (Haltech logs ×10).", + kind: TableParamKind::Float { + min: 0.0, + max: 100.0, + speed: 0.01, + }, + }, + TableParam { + key: "merge_gap_ms", + label: "Merge gap (ms)", + tooltip: "Tip-ins closer than this merge into one event.", + kind: TableParamKind::Float { + min: 0.0, + max: 2000.0, + speed: 10.0, + }, + }, + TableParam { + key: "min_coolant_temp", + label: "Min coolant temp", + tooltip: "Events below this coolant temperature are rejected (channel units).", + kind: TableParamKind::Float { + min: -40.0, + max: 400.0, + speed: 1.0, + }, + }, + ] + } + + fn get_config(&self) -> AnalyzerConfig { + let mut p = HashMap::new(); + p.insert( + "tps_rate_threshold".into(), + self.tps_rate_threshold.to_string(), + ); + p.insert( + "map_rate_threshold".into(), + self.map_rate_threshold.to_string(), + ); + p.insert("assumed_delay_ms".into(), self.assumed_delay_ms.to_string()); + p.insert("lean_band".into(), self.lean_band.to_string()); + p.insert("recovery_band".into(), self.recovery_band.to_string()); + p.insert("max_event_ms".into(), self.max_event_ms.to_string()); + p.insert( + "max_rpm_change_pct".into(), + self.max_rpm_change_pct.to_string(), + ); + p.insert( + "correction_clamp_pct".into(), + self.correction_clamp_pct.to_string(), + ); + p.insert("area_over_ms".into(), self.area_over_ms.to_string()); + p.insert("tps_rate_scale".into(), self.tps_rate_scale.to_string()); + p.insert("merge_gap_ms".into(), self.merge_gap_ms.to_string()); + p.insert("min_coolant_temp".into(), self.min_coolant_temp.to_string()); + AnalyzerConfig { + id: ID.to_string(), + name: self.name().to_string(), + parameters: p, + } + } + + fn set_config(&mut self, config: &AnalyzerConfig) { + self.tps_rate_threshold = param_f64(config, "tps_rate_threshold", self.tps_rate_threshold); + self.map_rate_threshold = param_f64(config, "map_rate_threshold", self.map_rate_threshold); + self.assumed_delay_ms = param_f64(config, "assumed_delay_ms", self.assumed_delay_ms); + self.lean_band = param_f64(config, "lean_band", self.lean_band); + self.recovery_band = param_f64(config, "recovery_band", self.recovery_band); + self.max_event_ms = param_f64(config, "max_event_ms", self.max_event_ms); + self.max_rpm_change_pct = param_f64(config, "max_rpm_change_pct", self.max_rpm_change_pct); + self.correction_clamp_pct = + param_f64(config, "correction_clamp_pct", self.correction_clamp_pct); + self.area_over_ms = param_f64(config, "area_over_ms", self.area_over_ms); + self.tps_rate_scale = param_f64(config, "tps_rate_scale", self.tps_rate_scale); + self.merge_gap_ms = param_f64(config, "merge_gap_ms", self.merge_gap_ms); + self.min_coolant_temp = param_f64(config, "min_coolant_temp", self.min_coolant_temp); + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +#[cfg(test)] +mod tests { + use super::super::binning::{ConfidenceRule, TableGrid}; + use super::super::synthetic::{SyntheticLog, Xorshift}; + use super::*; + + fn mapping() -> ChannelMapping { + let mut m = ChannelMapping::default(); + m.set(ChannelRole::Rpm, Some("RPM".into())); + m.set(ChannelRole::Tps, Some("TPS".into())); + m.set(ChannelRole::Map, Some("MAP".into())); + m.set(ChannelRole::Lambda, Some("Lambda".into())); + m + } + + fn axes() -> (AxisSpec, AxisSpec) { + ( + AxisSpec::new("RPM", "", vec![1000.0, 2000.0, 3000.0, 4000.0]), + AxisSpec::new("TPS rate", "%/s", TPS_RATE_EDGES.to_vec()), + ) + } + + /// Tip-ins every 4 s: TPS ramps 5 -> 5 + amplitude over `ramp_s`, and + /// lambda shows a first-order excursion of `depth` (signed, relative) + /// starting `delay_s` after the ramp start and recovering with `tau_s`. + #[allow(clippy::too_many_arguments)] + fn tipin_log( + rate_hz: f64, + ramp_s: f64, + amplitude: f64, + delay_s: f64, + depth: f64, + tau_s: f64, + sigma: f64, + n_events: usize, + ) -> SyntheticLog { + let duration = 4.0 * n_events as f64 + 2.0; + let mut log = SyntheticLog::new(rate_hz, duration); + let n = log.times.len(); + let mut rng = Xorshift::new(9); + let mut tps = vec![5.0; n]; + let mut lambda_true = vec![1.0; n]; + for e in 0..n_events { + let t0 = 2.0 + 4.0 * e as f64; + for i in 0..n { + let t = log.times[i]; + if t >= t0 && t < t0 + ramp_s { + tps[i] = 5.0 + amplitude * (t - t0) / ramp_s; + } else if t >= t0 + ramp_s && t < t0 + 2.5 { + tps[i] = 5.0 + amplitude; + } + if t >= t0 + delay_s { + let x = t - t0 - delay_s; + // Rise over 0.1 s, then decay. + let shape = if x < 0.1 { + x / 0.1 + } else { + (-(x - 0.1) / tau_s).exp() + }; + if shape > 1e-3 { + lambda_true[i] = 1.0 + depth * shape; + } + } + } + } + let lambda = log.sample_and_hold(&lambda_true, rate_hz, sigma, &mut rng); + log.add("RPM", vec![2500.0; n]); + log.add("TPS", tps); + log.add("MAP", vec![50.0; n]); + log.add("Lambda", lambda); + log + } + + fn run( + log: &SyntheticLog, + gen_: &AccelEnrichGenerator, + ctx: &GeneratorContext<'_>, + ) -> (Vec, RunReport) { + gen_.analyze(&log.log, "synthetic", &mapping(), &axes(), ctx) + .expect("analysis runs") + } + + #[test] + fn recovers_depth_and_sign_and_bins_by_peak_rate() { + let gen_ = AccelEnrichGenerator { + assumed_delay_ms: 100.0, + ..Default::default() + }; + for &(depth, rate) in &[(0.08, 50.0), (-0.06, 100.0), (0.12, 20.0)] { + // 40 % over 0.2 s = 200 %/s peak. + let log = tipin_log(rate, 0.2, 40.0, 0.1, depth, 0.3, 0.002, 4); + let (events, report) = run(&log, &gen_, &GeneratorContext::default()); + assert_eq!(report.accepted, 4, "{}", report.summary()); + for e in events.iter().filter(|e| e.accepted()) { + assert!( + (e.value(1) - depth).abs() < 0.01, + "depth {} vs {}", + e.value(1), + depth + ); + assert!( + (e.value(0) - depth * 100.0).abs() < 1.0, + "correction {}", + e.value(0) + ); + assert!(e.value(2) > 100.0, "duration {}", e.value(2)); + assert_eq!(e.value(0).signum(), depth.signum()); + assert_eq!(e.value(5), 100.0); + assert!( + (e.axis_value - 200.0).abs() < 60.0, + "peak rate {}", + e.axis_value + ); + assert_eq!(axes().1.bin_index(e.axis_value), Some(3)); + assert_eq!(e.value(4).signum(), depth.signum()); + } + } + } + + #[test] + fn afr_input_matches_lambda_input() { + let gen_ = AccelEnrichGenerator::default(); + let log = tipin_log(50.0, 0.2, 40.0, 0.12, 0.08, 0.3, 0.0, 3); + let (a, _) = run(&log, &gen_, &GeneratorContext::default()); + let mut afr = log.clone(); + afr.scale("Lambda", 14.7); + let (b, _) = run(&afr, &gen_, &GeneratorContext::default()); + assert_eq!(a.len(), b.len()); + for (x, y) in a.iter().zip(b.iter()) { + assert_eq!(x.reject, y.reject); + assert!((x.value(0) - y.value(0)).abs() < 0.2); + } + } + + #[test] + fn overlapping_ramps_merge_into_one_event() { + let mut log = tipin_log(50.0, 0.2, 40.0, 0.1, 0.08, 0.3, 0.0, 2); + // Add a second stab 0.25 s after the first ramp starts. + let mut tps = log.column("TPS"); + for (i, v) in tps.iter_mut().enumerate() { + let t = log.times[i]; + if (2.25..2.35).contains(&t) { + *v += 30.0 * (t - 2.25) / 0.1; + } else if (2.35..2.5).contains(&t) { + *v += 30.0; + } + } + log.replace("TPS", tps); + let (events, _) = run( + &log, + &AccelEnrichGenerator::default(), + &GeneratorContext::default(), + ); + let near_first: Vec<&TableEvent> = events + .iter() + .filter(|e| (e.time - 2.0).abs() < 0.5) + .collect(); + assert_eq!( + near_first.len(), + 1, + "{:?}", + events.iter().map(|e| e.time).collect::>() + ); + // The larger (300 %/s) peak wins. + assert!(near_first[0].axis_value > 250.0); + } + + #[test] + fn rpm_collapse_is_a_gear_shift() { + let mut log = tipin_log(50.0, 0.2, 40.0, 0.1, 0.08, 0.3, 0.0, 3); + let rpm: Vec = log + .times + .iter() + .map(|&t| { + if (6.2..7.0).contains(&t) { + 1500.0 + } else { + 2500.0 + } + }) + .collect(); + log.replace("RPM", rpm); + let (events, _) = run( + &log, + &AccelEnrichGenerator::default(), + &GeneratorContext::default(), + ); + assert!(events[0].accepted()); + assert_eq!(events[1].reject, Some(RejectReason::GearShift)); + } + + #[test] + fn delay_compensation_prefers_a_confident_table_cell() { + let log = tipin_log(50.0, 0.2, 40.0, 0.25, 0.08, 0.3, 0.0, 3); + let gen_ = AccelEnrichGenerator { + assumed_delay_ms: 100.0, + ..Default::default() + }; + // A delay grid with a High cell at (2500 rpm, 50 kPa) = 250 ms and a + // Low cell elsewhere. + let x = AxisSpec::new("RPM", "", vec![1000.0, 2000.0, 3000.0, 4000.0]); + let y = AxisSpec::new("MAP", "kPa", vec![0.0, 100.0]); + let mut points = Vec::new(); + for i in 0..10 { + points.push((2500.0, 50.0, 250.0 + i as f64)); + } + points.push((3500.0, 50.0, 900.0)); + let grid = TableGrid::build(x, y, points, ConfidenceRule::default()); + let ctx = GeneratorContext { + delay_table: Some(&grid), + }; + let (events, _) = run(&log, &gen_, &ctx); + for e in events.iter().filter(|e| e.accepted()) { + assert!( + (e.value(5) - 254.5).abs() < 1.0, + "delay used {}", + e.value(5) + ); + assert!(e.note.contains("delay from table")); + assert!((e.value(1) - 0.08).abs() < 0.01); + } + // Move the engine to the Low cell: assumed delay applies. + let mut log2 = log.clone(); + let n = log2.times.len(); + log2.replace("RPM", vec![3500.0; n]); + let (events, _) = run(&log2, &gen_, &ctx); + for e in events.iter().filter(|e| e.accepted()) { + assert_eq!(e.value(5), 100.0); + assert!(e.note.contains("assumed delay")); + } + } + + #[test] + fn native_rate_channel_scale_is_detected() { + let log = tipin_log(50.0, 0.2, 40.0, 0.1, 0.08, 0.3, 0.0, 3); + let computed = time_derivative(&median_filter(&log.column("TPS"), 3), &log.times); + let native: Vec = computed.iter().map(|v| v * 10.0).collect(); + assert!((detect_rate_scale(&native, &computed, 50.0) - 0.1).abs() < 1e-9); + assert_eq!(detect_rate_scale(&computed, &computed, 50.0), 1.0); + assert_eq!(detect_rate_scale(&[0.0; 10], &[0.0; 10], 50.0), 1.0); + + let mut log = log; + log.add("TPS DOT", native); + let mut m = mapping(); + m.set(ChannelRole::TpsRate, Some("TPS DOT".into())); + let gen_ = AccelEnrichGenerator::default(); + let (events, report) = gen_ + .analyze(&log.log, "s", &m, &axes(), &GeneratorContext::default()) + .unwrap(); + assert_eq!(report.accepted, 3, "{}", report.summary()); + assert!( + report.warnings.iter().any(|w| w.contains("×0.1")), + "{:?}", + report.warnings + ); + for e in &events { + assert!((e.axis_value - 200.0).abs() < 60.0); + } + } + + #[test] + fn map_rate_fallback_relabels_axis() { + let mut log = tipin_log(50.0, 0.2, 40.0, 0.1, 0.08, 0.3, 0.0, 2); + // MAP follows TPS: 50 -> 90 kPa over the ramp (200 kPa/s), below the + // default 400 kPa/s trigger, so lower the threshold. + let map: Vec = log.column("TPS").iter().map(|t| 45.0 + t).collect(); + log.replace("MAP", map); + let mut m = mapping(); + m.set(ChannelRole::Tps, None); + let gen_ = AccelEnrichGenerator { + map_rate_threshold: 100.0, + ..Default::default() + }; + let (_, y) = gen_.default_axes(&log.log, &m); + assert_eq!(y.unit, "kPa/s"); + let axes = (axes().0, y); + let (_, report) = gen_ + .analyze(&log.log, "s", &m, &axes, &GeneratorContext::default()) + .unwrap(); + assert_eq!(report.accepted, 2, "{}", report.summary()); + assert!(report.warnings.iter().any(|w| w.contains("MAP rate"))); + } + + #[test] + fn ae_activity_role_switches_kind_and_rejects_mismatch() { + let log = tipin_log(50.0, 0.2, 40.0, 0.1, 0.08, 0.3, 0.0, 3); + let n = log.times.len(); + // Speeduino-style percentage that idles at 100 and is active only + // around the second event. + let ae: Vec = log + .times + .iter() + .map(|&t| { + if (6.0..6.5).contains(&t) { + 130.0 + } else { + 100.0 + } + }) + .collect(); + let mut log = log; + log.add("Accel Enrich", ae); + assert_eq!(n, log.times.len()); + let mut m = mapping(); + m.set(ChannelRole::AeActive, Some("Accel Enrich".into())); + let (events, report) = AccelEnrichGenerator::default() + .analyze(&log.log, "s", &m, &axes(), &GeneratorContext::default()) + .unwrap(); + assert!(report.warnings.iter().any(|w| w.contains("additional"))); + assert_eq!(events[0].reject, Some(RejectReason::AeKindMismatch)); + assert!(events[1].accepted()); + assert!(events[1].note.contains("ECU AE active")); + assert_eq!(events[2].reject, Some(RejectReason::AeKindMismatch)); + } + + #[test] + fn target_channel_is_used_as_reference() { + let log = tipin_log(50.0, 0.2, 40.0, 0.1, 0.08, 0.3, 0.0, 2); + let n = log.times.len(); + let mut log = log; + // Target sits 5 % rich of the baseline: excursion vs target is deeper. + log.add("Target", vec![0.95; n]); + let mut m = mapping(); + m.set(ChannelRole::LambdaTarget, Some("Target".into())); + let (events, _) = AccelEnrichGenerator::default() + .analyze(&log.log, "s", &m, &axes(), &GeneratorContext::default()) + .unwrap(); + for e in events.iter().filter(|e| e.accepted()) { + assert!((e.value(1) - 0.13).abs() < 0.01, "depth {}", e.value(1)); + } + } + + #[test] + fn clutch_cold_and_out_of_axis_gates() { + let base = tipin_log(50.0, 0.2, 40.0, 0.1, 0.08, 0.3, 0.0, 2); + let n = base.times.len(); + let mut log = base.clone(); + log.add( + "Clutch", + base.times + .iter() + .map(|&t| if t > 5.0 { 1.0 } else { 0.0 }) + .collect(), + ); + let mut m = mapping(); + m.set(ChannelRole::Clutch, Some("Clutch".into())); + let (events, _) = AccelEnrichGenerator::default() + .analyze(&log.log, "s", &m, &axes(), &GeneratorContext::default()) + .unwrap(); + assert!(events[0].accepted()); + assert_eq!(events[1].reject, Some(RejectReason::Clutch)); + + let mut log = base.clone(); + log.add("CLT", vec![30.0; n]); + let mut m = mapping(); + m.set(ChannelRole::CoolantTemp, Some("CLT".into())); + let (_, report) = AccelEnrichGenerator::default() + .analyze(&log.log, "s", &m, &axes(), &GeneratorContext::default()) + .unwrap(); + assert_eq!( + report.rejected.get(&RejectReason::ColdEngine).copied(), + Some(2) + ); + + let narrow = (AxisSpec::new("RPM", "", vec![5000.0, 6000.0]), axes().1); + let (_, report) = AccelEnrichGenerator::default() + .analyze( + &base.log, + "s", + &mapping(), + &narrow, + &GeneratorContext::default(), + ) + .unwrap(); + assert_eq!( + report.rejected.get(&RejectReason::OutOfAxis).copied(), + Some(2) + ); + } + + #[test] + fn missing_trigger_channel_is_an_error() { + let log = tipin_log(50.0, 0.2, 40.0, 0.1, 0.08, 0.3, 0.0, 1); + let mut m = mapping(); + m.set(ChannelRole::Tps, None); + m.set(ChannelRole::Map, None); + let err = AccelEnrichGenerator::default() + .analyze(&log.log, "s", &m, &axes(), &GeneratorContext::default()) + .unwrap_err(); + assert!(matches!(err, AnalysisError::MissingChannel(_))); + } + + #[test] + fn config_round_trip() { + let mut gen_ = AccelEnrichGenerator::default(); + let cfg = gen_.get_config(); + for p in gen_.params() { + assert!( + cfg.parameters.contains_key(p.key), + "param {} missing", + p.key + ); + } + let mut cfg = cfg; + cfg.parameters + .insert("assumed_delay_ms".into(), "200".into()); + gen_.set_config(&cfg); + assert_eq!(gen_.assumed_delay_ms, 200.0); + } +} diff --git a/src/analysis/tables/binning.rs b/src/analysis/tables/binning.rs new file mode 100644 index 00000000..16537006 --- /dev/null +++ b/src/analysis/tables/binning.rs @@ -0,0 +1,471 @@ +//! Axis specifications and 2-D binning for generated tuning tables. +//! +//! An [`AxisSpec`] is a list of cell *edges* (N+1 edges give N bins), so +//! uneven spacing like a real ECU table axis is natural. Binning is +//! lower-edge inclusive: a value exactly on an edge lands in the cell that +//! starts there, and a value equal to the last edge is out of range. + +use serde::{Deserialize, Serialize}; + +use super::stats::{mad, median, percentile}; + +/// Maximum bins per axis. Keeps a grid (values + counts + spreads) well +/// under the 512 KiB MCP response guard and keeps the heatmap legible. +pub const MAX_BINS_PER_AXIS: usize = 64; + +/// Bin index of a value in `[min, min + range)` split into `n` equal cells, +/// clamped into range. Shared with the histogram view so both tools agree on +/// cell boundaries. +#[inline] +pub fn uniform_bin(value: f64, min: f64, range: f64, n: usize) -> usize { + if n == 0 { + return 0; + } + let normalized = if range > 0.0 { + ((value - min) / range).clamp(0.0, 1.0) as f32 + } else { + 0.0 + }; + ((normalized * n as f32).floor() as usize).min(n - 1) +} + +/// One table axis. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AxisSpec { + /// Display label, e.g. `RPM`, `MAP`, `TPS rate`. + pub label: String, + /// Unit suffix for headers, e.g. `kPa`, `%/s`. Empty when unitless. + pub unit: String, + /// Strictly increasing cell edges. `len() - 1` bins. + pub edges: Vec, +} + +impl AxisSpec { + /// Build an axis, sorting and de-duplicating the edges and capping the + /// bin count at [`MAX_BINS_PER_AXIS`]. + pub fn new(label: impl Into, unit: impl Into, edges: Vec) -> Self { + let mut edges: Vec = edges.into_iter().filter(|e| e.is_finite()).collect(); + edges.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + edges.dedup(); + edges.truncate(MAX_BINS_PER_AXIS + 1); + Self { + label: label.into(), + unit: unit.into(), + edges, + } + } + + /// Number of bins. + pub fn bins(&self) -> usize { + self.edges.len().saturating_sub(1) + } + + /// Whether the axis has at least one bin. + pub fn is_valid(&self) -> bool { + self.bins() >= 1 + } + + /// Lower-edge-inclusive bin lookup. `None` when out of range or the value + /// is not finite. + pub fn bin_index(&self, value: f64) -> Option { + if !value.is_finite() || self.edges.len() < 2 { + return None; + } + let last = *self.edges.last()?; + if value < self.edges[0] || value >= last { + return None; + } + // partition_point gives the number of edges <= value; subtract one for + // the bin whose lower edge that is. + Some(self.edges.partition_point(|&e| e <= value) - 1) + } + + /// Lower edge of a bin, used as the row/column header value. + pub fn lower_edge(&self, bin: usize) -> f64 { + self.edges[bin] + } + + /// Centre of a bin. + pub fn center(&self, bin: usize) -> f64 { + (self.edges[bin] + self.edges[bin + 1]) / 2.0 + } + + /// Axis label with unit, e.g. `MAP (kPa)`. + pub fn header(&self) -> String { + if self.unit.is_empty() { + self.label.clone() + } else { + format!("{} ({})", self.label, self.unit) + } + } + + /// Edges as a comma-separated list, the editable form shown in the UI. + pub fn edges_text(&self) -> String { + self.edges + .iter() + .map(|e| format_edge(*e)) + .collect::>() + .join(", ") + } + + /// Parse a comma / whitespace separated edge list. Returns `None` when + /// fewer than two distinct finite numbers are given. + pub fn parse_edges(text: &str) -> Option> { + let mut edges: Vec = text + .split(|c: char| c == ',' || c == ';' || c.is_whitespace()) + .filter(|s| !s.is_empty()) + .filter_map(|s| s.parse::().ok()) + .filter(|v| v.is_finite()) + .collect(); + edges.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + edges.dedup(); + if edges.len() < 2 { None } else { Some(edges) } + } + + /// Uniform edges from the 1st to the 99th percentile of the data, snapped + /// outward to multiples of `step`. Falls back to `fallback` when the data + /// has no usable spread. + pub fn from_data( + label: impl Into, + unit: impl Into, + values: &[f64], + step: f64, + fallback: &[f64], + ) -> Self { + let (lo, hi) = match (percentile(values, 1.0), percentile(values, 99.0)) { + (Some(lo), Some(hi)) if hi > lo && step > 0.0 => (lo, hi), + _ => return Self::new(label, unit, fallback.to_vec()), + }; + let lo = (lo / step).floor() * step; + let hi = (hi / step).ceil() * step; + let mut n = ((hi - lo) / step).round() as usize; + let mut step = step; + // Coarsen rather than exceed the bin cap. + while n > MAX_BINS_PER_AXIS { + step *= 2.0; + n = ((hi - lo) / step).ceil() as usize; + } + if n == 0 { + return Self::new(label, unit, fallback.to_vec()); + } + let edges: Vec = (0..=n).map(|i| lo + i as f64 * step).collect(); + Self::new(label, unit, edges) + } +} + +fn format_edge(v: f64) -> String { + if (v - v.round()).abs() < 1e-9 { + format!("{}", v.round() as i64) + } else { + format!("{v:.3}") + .trim_end_matches('0') + .trim_end_matches('.') + .to_string() + } +} + +/// How much a cell's value can be trusted, from its sample count and spread. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Confidence { + /// No samples. + #[default] + Empty, + /// Fewer than `min_samples`, or MAD/|median| above 0.5. + Low, + /// Enough samples with acceptable spread. + Medium, + /// At least `good_samples` with MAD/|median| at or below 0.25. + High, +} + +impl Confidence { + pub fn label(self) -> &'static str { + match self { + Self::Empty => "empty", + Self::Low => "low", + Self::Medium => "medium", + Self::High => "high", + } + } + + /// Derive the tier from a sample list. + pub fn classify( + count: usize, + median: f64, + mad: f64, + min_samples: usize, + good_samples: usize, + ) -> Self { + if count == 0 { + return Self::Empty; + } + // Relative spread; a near-zero median with any spread counts as noisy. + let rel = if median.abs() > 1e-9 { + mad / median.abs() + } else if mad > 1e-9 { + f64::INFINITY + } else { + 0.0 + }; + if count < min_samples || rel > 0.5 { + Self::Low + } else if count >= good_samples && rel <= 0.25 { + Self::High + } else { + Self::Medium + } + } +} + +/// Thresholds for [`Confidence::classify`]. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct ConfidenceRule { + pub min_samples: usize, + pub good_samples: usize, +} + +impl Default for ConfidenceRule { + fn default() -> Self { + Self { + min_samples: 3, + good_samples: 8, + } + } +} + +/// Statistics for one table cell. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct CellStats { + /// Raw per-event values. Medians and MADs cannot be merged incrementally, + /// so the samples are kept and re-derived when a log is removed. + pub samples: Vec, + pub median: f64, + pub mad: f64, + pub count: usize, + pub confidence: Confidence, +} + +impl CellStats { + pub fn from_samples(samples: Vec, rule: ConfidenceRule) -> Self { + let med = median(&samples).unwrap_or(0.0); + let spread = mad(&samples).unwrap_or(0.0); + let count = samples.iter().filter(|v| v.is_finite()).count(); + Self { + confidence: Confidence::classify( + count, + med, + spread, + rule.min_samples, + rule.good_samples, + ), + samples, + median: med, + mad: spread, + count, + } + } + + pub fn is_empty(&self) -> bool { + self.count == 0 + } +} + +/// A binned 2-D table: `cells[row][col]` where rows follow the Y axis and +/// columns the X axis. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TableGrid { + pub x_axis: AxisSpec, + pub y_axis: AxisSpec, + pub cells: Vec>, + pub rule: ConfidenceRule, +} + +impl TableGrid { + /// Bin `(x, y, value)` triples. Out-of-axis points are skipped (callers + /// that need to count them use [`AxisSpec::bin_index`] first). + pub fn build(x_axis: AxisSpec, y_axis: AxisSpec, points: I, rule: ConfidenceRule) -> Self + where + I: IntoIterator, + { + let cols = x_axis.bins(); + let rows = y_axis.bins(); + let mut buckets: Vec>> = vec![vec![Vec::new(); cols]; rows]; + for (x, y, v) in points { + if let (Some(c), Some(r)) = (x_axis.bin_index(x), y_axis.bin_index(y)) + && v.is_finite() + { + buckets[r][c].push(v); + } + } + let cells = buckets + .into_iter() + .map(|row| { + row.into_iter() + .map(|s| CellStats::from_samples(s, rule)) + .collect() + }) + .collect(); + Self { + x_axis, + y_axis, + cells, + rule, + } + } + + pub fn rows(&self) -> usize { + self.cells.len() + } + + pub fn cols(&self) -> usize { + self.cells.first().map_or(0, Vec::len) + } + + pub fn cell(&self, row: usize, col: usize) -> Option<&CellStats> { + self.cells.get(row).and_then(|r| r.get(col)) + } + + /// Number of non-empty cells and number at `High` confidence. + pub fn coverage(&self) -> (usize, usize) { + let mut filled = 0; + let mut high = 0; + for c in self.cells.iter().flatten() { + if !c.is_empty() { + filled += 1; + } + if c.confidence == Confidence::High { + high += 1; + } + } + (filled, high) + } + + /// Min and max of the non-empty cell medians, for colour scaling. + pub fn value_range(&self) -> Option<(f64, f64)> { + let mut lo = f64::INFINITY; + let mut hi = f64::NEG_INFINITY; + for c in self.cells.iter().flatten().filter(|c| !c.is_empty()) { + lo = lo.min(c.median); + hi = hi.max(c.median); + } + if lo.is_finite() && hi.is_finite() { + Some((lo, hi)) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn uniform_bin_matches_floor_semantics() { + assert_eq!(uniform_bin(0.0, 0.0, 10.0, 10), 0); + assert_eq!(uniform_bin(9.99, 0.0, 10.0, 10), 9); + assert_eq!(uniform_bin(10.0, 0.0, 10.0, 10), 9); + assert_eq!(uniform_bin(-5.0, 0.0, 10.0, 10), 0); + assert_eq!(uniform_bin(5.0, 0.0, 0.0, 10), 0); + assert_eq!(uniform_bin(5.0, 0.0, 10.0, 0), 0); + } + + #[test] + fn bin_index_is_lower_edge_inclusive() { + let axis = AxisSpec::new("RPM", "", vec![1000.0, 2000.0, 3000.0]); + assert_eq!(axis.bins(), 2); + assert_eq!(axis.bin_index(1000.0), Some(0)); + assert_eq!(axis.bin_index(1999.9), Some(0)); + assert_eq!(axis.bin_index(2000.0), Some(1)); + assert_eq!(axis.bin_index(3000.0), None); + assert_eq!(axis.bin_index(999.0), None); + assert_eq!(axis.bin_index(f64::NAN), None); + } + + #[test] + fn edges_are_sorted_deduped_and_capped() { + let axis = AxisSpec::new("x", "", vec![3.0, 1.0, 2.0, 2.0, f64::NAN]); + assert_eq!(axis.edges, vec![1.0, 2.0, 3.0]); + let big: Vec = (0..200).map(|i| i as f64).collect(); + let axis = AxisSpec::new("x", "", big); + assert_eq!(axis.bins(), MAX_BINS_PER_AXIS); + } + + #[test] + fn edges_round_trip_text() { + let axis = AxisSpec::new("MAP", "kPa", vec![20.0, 30.5, 40.0]); + assert_eq!(axis.edges_text(), "20, 30.5, 40"); + assert_eq!( + AxisSpec::parse_edges("40, 20 30.5;40"), + Some(vec![20.0, 30.5, 40.0]) + ); + assert_eq!(AxisSpec::parse_edges("40"), None); + assert_eq!(AxisSpec::parse_edges("abc"), None); + assert_eq!(axis.header(), "MAP (kPa)"); + } + + #[test] + fn from_data_snaps_to_step_and_caps_bins() { + let rpm: Vec = (0..1000).map(|i| 1100.0 + i as f64 * 4.0).collect(); + let axis = AxisSpec::from_data("RPM", "", &rpm, 500.0, &[0.0, 8000.0]); + assert_eq!(axis.edges[0], 1000.0); + assert_eq!(*axis.edges.last().unwrap(), 5500.0); + assert!(axis.bins() <= MAX_BINS_PER_AXIS); + let flat = vec![50.0; 10]; + let axis = AxisSpec::from_data("MAP", "kPa", &flat, 10.0, &[0.0, 100.0]); + assert_eq!(axis.edges, vec![0.0, 100.0]); + let fine: Vec = (0..10000).map(|i| i as f64).collect(); + let axis = AxisSpec::from_data("x", "", &fine, 1.0, &[0.0, 1.0]); + assert!(axis.bins() <= MAX_BINS_PER_AXIS); + } + + #[test] + fn confidence_tiers() { + let rule = ConfidenceRule::default(); + assert_eq!( + CellStats::from_samples(vec![], rule).confidence, + Confidence::Empty + ); + assert_eq!( + CellStats::from_samples(vec![100.0, 110.0], rule).confidence, + Confidence::Low + ); + assert_eq!( + CellStats::from_samples(vec![100.0, 110.0, 105.0], rule).confidence, + Confidence::Medium + ); + let tight: Vec = (0..8).map(|i| 100.0 + i as f64).collect(); + assert_eq!( + CellStats::from_samples(tight, rule).confidence, + Confidence::High + ); + let wide: Vec = vec![10.0, 100.0, 200.0, 300.0, 400.0, 500.0, 600.0, 700.0]; + assert_eq!( + CellStats::from_samples(wide, rule).confidence, + Confidence::Low + ); + } + + #[test] + fn grid_build_and_coverage() { + let x = AxisSpec::new("RPM", "", vec![1000.0, 2000.0, 3000.0]); + let y = AxisSpec::new("MAP", "kPa", vec![30.0, 50.0, 70.0]); + let points = vec![ + (1500.0, 40.0, 100.0), + (1500.0, 40.0, 120.0), + (1500.0, 40.0, 110.0), + (2500.0, 60.0, 80.0), + (9999.0, 40.0, 1.0), // out of axis + (1500.0, 40.0, f64::NAN), + ]; + let grid = TableGrid::build(x, y, points, ConfidenceRule::default()); + assert_eq!(grid.rows(), 2); + assert_eq!(grid.cols(), 2); + let c = grid.cell(0, 0).unwrap(); + assert_eq!(c.count, 3); + assert_eq!(c.median, 110.0); + assert_eq!(grid.cell(1, 1).unwrap().count, 1); + assert_eq!(grid.coverage(), (2, 0)); + assert_eq!(grid.value_range(), Some((80.0, 110.0))); + } +} diff --git a/src/analysis/tables/channel_map.rs b/src/analysis/tables/channel_map.rs new file mode 100644 index 00000000..d7784e0b --- /dev/null +++ b/src/analysis/tables/channel_map.rs @@ -0,0 +1,625 @@ +//! Channel roles and auto-suggested channel mapping for the table generators. +//! +//! A generator does not ask for channel *names*; it asks for channel *roles* +//! (engine speed, load, injector pulse width, ...). The user maps a log's +//! channels onto those roles once, and [`suggest_mapping`] pre-fills that +//! mapping by scoring every channel in three tiers: +//! +//! 1. the existing normalization system resolves the name to the role's +//! canonical name (score 100), +//! 2. OpenECU Alliance spec metadata puts the channel in a category/unit +//! consistent with the role (score 60), +//! 3. name heuristics such as `on time`, `pulse width`, `dfco` (score 40), +//! +//! and then vetoes candidates whose data is implausible for the role (an +//! "RPM" channel whose median is 3 is not engine speed). Names containing +//! `overall` / `avg` / `average` lose 10 points so a per-bank sensor wins a +//! tie: averaging sensors with different transport delays smears the very +//! rise time the lambda delay generator measures. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use super::stats::median; +use crate::adapters::ChannelCategory; +use crate::normalize::{get_spec_metadata, normalize_channel_name_with_custom}; +use crate::parsers::types::Log; + +/// Semantic role a mapped channel plays in a generator. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, PartialOrd, Ord)] +pub enum ChannelRole { + Rpm, + /// Manifold absolute pressure (kPa). Load axis for lambda delay and the + /// fallback tip-in trigger for accel enrichment. + Map, + /// Throttle position (%). Load axis alternative and the primary tip-in + /// source for accel enrichment. + Tps, + /// Injector pulse width (ms / us) or duty cycle (%). Steps are relative, + /// so the unit does not matter. + PulseWidth, + /// Wideband lambda or AFR; the unit is auto-detected from the data. + Lambda, + /// Commanded lambda / AFR target. + LambdaTarget, + /// Native throttle rate-of-change channel (Haltech `Throttle Position + /// Derivative`, Speeduino `TPS DOT`). Preferred over a computed derivative + /// because the ECU computes it at tick rate rather than log rate. + TpsRate, + /// Deceleration fuel cut flag. + FuelCut, + /// Closed-loop O2 correction / state. Movement here during an event means + /// the ECU, not the tuner, changed the fuelling. + ClosedLoopState, + CoolantTemp, + /// Clutch switch state. + Clutch, + /// ECU acceleration-enrichment activity (Haltech `Transient Throttle Load + /// Derivative`, rusEFI `Fuel: TPS AE Active`, Speeduino `Accel Enrich`). + AeActive, +} + +impl ChannelRole { + pub fn label(self) -> &'static str { + match self { + Self::Rpm => "Engine speed", + Self::Map => "Manifold pressure", + Self::Tps => "Throttle position", + Self::PulseWidth => "Injector pulse width", + Self::Lambda => "Lambda / AFR", + Self::LambdaTarget => "Lambda / AFR target", + Self::TpsRate => "Throttle rate", + Self::FuelCut => "Fuel cut flag", + Self::ClosedLoopState => "Closed-loop correction", + Self::CoolantTemp => "Coolant temperature", + Self::Clutch => "Clutch state", + Self::AeActive => "ECU accel enrichment", + } + } + + pub fn hint(self) -> &'static str { + match self { + Self::Rpm => "Engine RPM.", + Self::Map => "Manifold absolute pressure in kPa.", + Self::Tps => "Throttle position in percent.", + Self::PulseWidth => { + "Injector on-time (ms or us) or duty cycle (%). Only relative steps are used." + } + Self::Lambda => { + "Wideband lambda (~1.0) or AFR (~14.7). Prefer a single sensor over an averaged channel." + } + Self::LambdaTarget => { + "Commanded lambda or AFR. Optional: excursions are measured against it instead of the pre-event baseline." + } + Self::TpsRate => { + "Native throttle derivative in %/s if the ECU logs one. Optional: computed from throttle position otherwise." + } + Self::FuelCut => { + "Deceleration fuel cut / DFCO flag. Optional: events during fuel cut are rejected." + } + Self::ClosedLoopState => { + "Short-term trim, EGO correction or O2 control state. Optional: events where it moves are rejected." + } + Self::CoolantTemp => "Coolant temperature. Optional: cold-engine events are rejected.", + Self::Clutch => "Clutch switch. Optional: events with the clutch in are rejected.", + Self::AeActive => { + "ECU accel-enrichment activity. Optional: suggestions become 'additional to' the ECU's current value." + } + } + } + + /// Canonical normalized names that identify this role outright. + fn canonical_names(self) -> &'static [&'static str] { + match self { + Self::Rpm => &["RPM"], + Self::Map => &["MAP"], + Self::Tps => &["TPS"], + Self::PulseWidth => &["Pulse Width", "Duty Cycle"], + Self::Lambda => &[ + "AFR", + "AFR Channel 1", + "AFR Channel 2", + "Lambda", + "Lambda 1", + "Lambda 2", + "O2", + ], + Self::LambdaTarget => &["AFR Target", "Lambda Target"], + Self::TpsRate => &["TPS Rate"], + Self::FuelCut => &[], + Self::ClosedLoopState => &[], + Self::CoolantTemp => &["Coolant Temp"], + Self::Clutch => &[], + Self::AeActive => &[], + } + } + + /// Lower-case substrings that identify the role more specifically than + /// [`name_hints`](Self::name_hints); they score 50 so `dfcoActive` beats + /// `DFCO: Timing retard` and `Short Term Fuel Trim` beats `O2 Control + /// Bank 1 Output`. + fn strong_hints(self) -> &'static [&'static str] { + match self { + Self::LambdaTarget => &["target lambda", "lambda target", "afr target", "target afr"], + Self::TpsRate => &["tps dot", "tpsdot", "throttle position derivative"], + Self::FuelCut => &["dfcoactive", "decel cut state", "dfco"], + Self::ClosedLoopState => &["short term fuel trim", "stft", "gego", "total correction"], + Self::Clutch => &["clutch state", "clutch switch"], + Self::AeActive => &[ + "transient throttle load derivative", + "tps ae active", + "accel enrich", + ], + _ => &[], + } + } + + /// Lower-case substrings that suggest this role by name alone. + fn name_hints(self) -> &'static [&'static str] { + match self { + Self::Rpm => &["engine speed", "rpm"], + Self::Map => &["manifold pressure", "map"], + Self::Tps => &["throttle position", "tps", "throttle pos"], + Self::PulseWidth => &[ + "pulse width", + "pulsewidth", + "on time", + "injection time", + "inj pw", + "effective pw", + "actual pw", + "inj duration", + "duty cycle", + "base pw", + ], + Self::Lambda => &["lambda", "wideband", "afr", "air/fuel", "air fuel", "o2"], + Self::LambdaTarget => &["target"], + Self::TpsRate => &[ + "tps dot", + "tpsdot", + "throttle position derivative", + "tps delta", + "tps rate", + ], + Self::FuelCut => &["dfco", "decel cut", "fuel cut", "overrun cut", "decel fuel"], + Self::ClosedLoopState => &[ + "o2 control", + "short term fuel trim", + "stft", + "gego", + "ego correction", + "total correction", + "closed loop", + "lambda correction", + "o2 correction", + ], + Self::CoolantTemp => &["coolant", "clt", "engine temp"], + Self::Clutch => &["clutch"], + Self::AeActive => &[ + "transient throttle load derivative", + "transient throttle enrichment load derivative", + "tps ae active", + "accel enrich", + "acceleration enrichment", + "ae active", + ], + } + } + + /// Lower-case substrings that disqualify a name for this role even when a + /// hint matched (e.g. `Target Lambda` must not become the lambda sensor). + fn name_vetoes(self) -> &'static [&'static str] { + match self { + Self::Rpm => &[ + "target", + "limit", + "error", + "derivative", + "driveshaft", + "rpm/s", + "accel", + "max", + "min", + "idle", + ], + Self::Map => &[ + "derivative", + "target", + "predicted", + "fallback", + "raw", + "mapxrpm", + "sensor seems", + "error", + "max", + "min", + "baro", + "delta", + ], + Self::Tps => &[ + "derivative", + "dot", + "delta", + "target", + "error", + "raw", + "adc", + "accumulator", + "split", + "status", + "cal", + "pedal", + "rate", + "2", + "sub", + "ae", + ], + Self::PulseWidth => &[ + "dead time", + "adder", + "growth", + "staging", + "target", + "duty cycle _", + "peak", + "add fuel", + "pw2", + "pw3", + "pw4", + "highest", + "short pulse", + ], + Self::Lambda => &[ + "target", + "status", + "error", + "correction", + "control", + "raw", + "time since", + "heater", + "trim", + "delay", + "good", + "ready", + "protect", + ], + Self::LambdaTarget => &[ + "error", "airmass", "airflow", "boost", "idle", "rpm", "cam", "angle", "position", + "gear", "ratio", + ], + Self::TpsRate => &["max", "enrichment"], + Self::FuelCut => &["retard", "timing"], + Self::ClosedLoopState => &["long term", "ltft", "target", "error", "boost", "idle"], + Self::CoolantTemp => &["gauge", "raw", "target", "error", "output"], + Self::Clutch => &[], + Self::AeActive => &[ + "max", + "reset", + "below threshold", + "too short", + "fractional", + "peak", + "start load", + "add fuel", + ], + } + } + + /// Spec categories consistent with the role, and whether that alone is + /// enough (it is not for ambiguous categories like `Fuel`). + fn spec_categories(self) -> &'static [ChannelCategory] { + match self { + Self::Rpm => &[ChannelCategory::Engine], + Self::Map => &[ChannelCategory::Pressure], + Self::Tps => &[ChannelCategory::DriverInput, ChannelCategory::Position], + Self::PulseWidth => &[ChannelCategory::Fuel], + Self::Lambda => &[ChannelCategory::Fuel], + Self::LambdaTarget => &[ChannelCategory::Fuel], + Self::TpsRate => &[ChannelCategory::DriverInput], + Self::CoolantTemp => &[ChannelCategory::Temperature], + Self::ClosedLoopState => &[ChannelCategory::Correction], + Self::FuelCut | Self::Clutch | Self::AeActive => &[], + } + } + + /// Whether the median of the channel data is plausible for the role. + /// `None` when the role has no plausibility band. + fn plausible(self, med: f64) -> Option { + match self { + Self::Rpm => Some((300.0..=12_000.0).contains(&med)), + // Haltech logs gauge pressure (negative at vacuum); accept it. + Self::Map => Some((-100.0..=400.0).contains(&med) && med != 0.0), + Self::Tps => Some((0.0..=100.0).contains(&med)), + Self::PulseWidth => Some(med > 0.0 && med < 100_000.0), + Self::Lambda | Self::LambdaTarget => { + Some((0.5..=1.6).contains(&med) || (7.0..=24.0).contains(&med)) + } + Self::CoolantTemp => Some((-40.0..=400.0).contains(&med)), + _ => None, + } + } +} + +/// Which channel supplies the load axis. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] +pub enum LoadKind { + #[default] + Map, + Tps, +} + +impl LoadKind { + pub fn role(self) -> ChannelRole { + match self { + Self::Map => ChannelRole::Map, + Self::Tps => ChannelRole::Tps, + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Map => "MAP", + Self::Tps => "TPS", + } + } + + pub fn unit(self) -> &'static str { + match self { + Self::Map => "kPa", + Self::Tps => "%", + } + } +} + +/// A role a generator wants mapped. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RoleSpec { + pub role: ChannelRole, + pub required: bool, +} + +impl RoleSpec { + pub const fn required(role: ChannelRole) -> Self { + Self { + role, + required: true, + } + } + + pub const fn optional(role: ChannelRole) -> Self { + Self { + role, + required: false, + } + } +} + +/// The user's channel assignment for one run. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct ChannelMapping { + /// Role -> raw channel name as it appears in the log. + pub assignments: HashMap, + pub load_kind: LoadKind, +} + +impl ChannelMapping { + pub fn get(&self, role: ChannelRole) -> Option<&str> { + self.assignments.get(&role).map(String::as_str) + } + + pub fn set(&mut self, role: ChannelRole, name: Option) { + match name { + Some(n) if !n.is_empty() => { + self.assignments.insert(role, n); + } + _ => { + self.assignments.remove(&role); + } + } + } + + pub fn is_mapped(&self, role: ChannelRole) -> bool { + self.assignments.contains_key(&role) + } + + /// Roles from `specs` that are required but unmapped. + pub fn missing_required(&self, specs: &[RoleSpec]) -> Vec { + specs + .iter() + .filter(|s| s.required && !self.is_mapped(s.role)) + .map(|s| s.role) + .collect() + } +} + +/// One scored candidate for a role. +#[derive(Clone, Debug, PartialEq)] +pub struct Candidate { + pub channel: String, + pub score: i32, +} + +/// Result of [`suggest_mapping`]. +#[derive(Clone, Debug, Default)] +pub struct Suggestion { + pub mapping: ChannelMapping, + /// Roles where the top two candidates were within 10 points of each + /// other; the UI flags these for the user to confirm. + pub ambiguous: Vec, + /// All candidates with a positive score, best first, per role. + pub candidates: HashMap>, +} + +fn name_score(role: ChannelRole, raw_name: &str, custom: Option<&HashMap>) -> i32 { + let lower = raw_name.to_lowercase(); + if role.name_vetoes().iter().any(|v| lower.contains(v)) { + return 0; + } + let normalized = normalize_channel_name_with_custom(raw_name, custom); + let mut score = 0; + if role + .canonical_names() + .iter() + .any(|c| c.eq_ignore_ascii_case(&normalized)) + { + // Duty cycle works for step detection but a true pulse width is the + // better channel when both are logged. + score = if normalized.eq_ignore_ascii_case("Duty Cycle") { + 90 + } else { + 100 + }; + } else if role.strong_hints().iter().any(|h| lower.contains(h)) { + score = 50; + } else if let Some(meta) = get_spec_metadata(raw_name) + && role.spec_categories().contains(&meta.category) + && role.name_hints().iter().any(|h| lower.contains(h)) + { + score = 60; + } else if role.name_hints().iter().any(|h| lower.contains(h)) { + score = 40; + } + if score > 0 + && (lower.contains("overall") || lower.contains("avg") || lower.contains("average")) + { + score -= 10; + } + score +} + +/// Score every channel in `log` for each role in `specs` and pick the best. +pub fn suggest_mapping( + log: &Log, + specs: &[RoleSpec], + custom: Option<&HashMap>, +) -> Suggestion { + let mut suggestion = Suggestion::default(); + let names: Vec = log.channels.iter().map(|c| c.name()).collect(); + let mut median_cache: HashMap> = HashMap::new(); + + for spec in specs { + let role = spec.role; + let mut candidates: Vec = Vec::new(); + for (idx, name) in names.iter().enumerate() { + let mut score = name_score(role, name, custom); + if score == 0 { + continue; + } + if role.plausible(0.0).is_some() { + let med = *median_cache + .entry(idx) + .or_insert_with(|| median(&log.get_channel_data(idx))); + match med { + Some(m) if role.plausible(m) == Some(true) => {} + _ => score = 0, + } + } + if score > 0 { + candidates.push(Candidate { + channel: name.clone(), + score, + }); + } + } + candidates.sort_by(|a, b| { + b.score + .cmp(&a.score) + .then_with(|| a.channel.cmp(&b.channel)) + }); + if let Some(best) = candidates.first() { + suggestion + .mapping + .assignments + .insert(role, best.channel.clone()); + if let Some(second) = candidates.get(1) + && best.score - second.score < 10 + { + suggestion.ambiguous.push(role); + } + } + if !candidates.is_empty() { + suggestion.candidates.insert(role, candidates); + } + } + + suggestion.mapping.load_kind = if suggestion.mapping.is_mapped(ChannelRole::Map) { + LoadKind::Map + } else { + LoadKind::Tps + }; + suggestion +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn name_scores_prefer_canonical_and_penalise_averages() { + assert_eq!(name_score(ChannelRole::Rpm, "RPM", None), 100); + assert_eq!(name_score(ChannelRole::Rpm, "Target RPM", None), 0); + assert_eq!(name_score(ChannelRole::Lambda, "Wideband O2 1", None), 100); + assert_eq!( + name_score(ChannelRole::Lambda, "Wideband O2 Overall", None), + 90 + ); + assert_eq!(name_score(ChannelRole::Lambda, "Target Lambda", None), 0); + assert!(name_score(ChannelRole::PulseWidth, "Injector 1 On Time", None) >= 40); + assert_eq!( + name_score(ChannelRole::PulseWidth, "Injection Stage 1 Dead Time", None), + 0 + ); + assert!(name_score(ChannelRole::TpsRate, "TPS DOT", None) >= 40); + assert_eq!(name_score(ChannelRole::FuelCut, "dfcoActive", None), 50); + assert_eq!( + name_score(ChannelRole::FuelCut, "DFCO: Timing retard", None), + 0 + ); + assert_eq!( + name_score(ChannelRole::FuelCut, "Decel Cut State", None), + 50 + ); + assert_eq!( + name_score( + ChannelRole::ClosedLoopState, + "O2 Control Bank 1 Short Term Fuel Trim", + None + ), + 50 + ); + assert_eq!( + name_score( + ChannelRole::ClosedLoopState, + "O2 Control Bank 1 Target", + None + ), + 0 + ); + assert_eq!( + name_score(ChannelRole::Lambda, "lambdaCurrentlyGood", None), + 0 + ); + assert_eq!(name_score(ChannelRole::PulseWidth, "Duty Cycle", None), 90); + assert!(name_score(ChannelRole::AeActive, "Fuel: TPS AE Active", None) >= 40); + assert_eq!( + name_score(ChannelRole::AeActive, "Fuel: TPS AE: reset time", None), + 0 + ); + } + + #[test] + fn mapping_missing_required() { + let mut m = ChannelMapping::default(); + let specs = [ + RoleSpec::required(ChannelRole::Rpm), + RoleSpec::optional(ChannelRole::Clutch), + ]; + assert_eq!(m.missing_required(&specs), vec![ChannelRole::Rpm]); + m.set(ChannelRole::Rpm, Some("RPM".into())); + assert!(m.missing_required(&specs).is_empty()); + m.set(ChannelRole::Rpm, None); + assert!(!m.is_mapped(ChannelRole::Rpm)); + } +} diff --git a/src/analysis/tables/events.rs b/src/analysis/tables/events.rs new file mode 100644 index 00000000..99ccfc06 --- /dev/null +++ b/src/analysis/tables/events.rs @@ -0,0 +1,309 @@ +//! Event-detection primitives shared by the table generators: invalid-sample +//! masking, steadiness gates, threshold crossings with interpolation, and +//! rate-of-change run detection. + +use super::stats::{index_range, median}; + +/// Haltech writes an i32 sentinel family (`-2147483617`, `-2147483637`, ...) +/// for "no reading"; any other exporter that leaks an int32 extreme is caught +/// by the same band. Values this large have no physical meaning in a log. +pub const SENTINEL_MAGNITUDE: f64 = 2_147_483_648.0 - 64.0; + +/// Whether a raw sample is an ECU "no reading" sentinel or non-finite. +#[inline] +pub fn is_invalid_sample(v: f64) -> bool { + !v.is_finite() || v.abs() >= SENTINEL_MAGNITUDE +} + +/// Replace sentinel / non-finite / out-of-band samples with `NaN` so later +/// math (which skips non-finite values) never sees them. `band` is an +/// optional inclusive plausibility range for the role. +pub fn mask_invalid(values: &[f64], band: Option<(f64, f64)>) -> Vec { + values + .iter() + .map(|&v| { + if is_invalid_sample(v) { + return f64::NAN; + } + match band { + Some((lo, hi)) if v < lo || v > hi => f64::NAN, + _ => v, + } + }) + .collect() +} + +/// Fraction of `NaN` samples in a slice (0 for an empty slice). +pub fn invalid_fraction(values: &[f64]) -> f64 { + if values.is_empty() { + return 0.0; + } + values.iter().filter(|v| !v.is_finite()).count() as f64 / values.len() as f64 +} + +/// Peak-to-peak range of the finite samples in `values[range]`. `None` when +/// the range has no finite sample. +pub fn span(values: &[f64], range: std::ops::Range) -> Option { + let mut lo = f64::INFINITY; + let mut hi = f64::NEG_INFINITY; + for &v in &values[range.start.min(values.len())..range.end.min(values.len())] { + if v.is_finite() { + lo = lo.min(v); + hi = hi.max(v); + } + } + if lo.is_finite() { Some(hi - lo) } else { None } +} + +/// Whether the channel stays within a total band of `2 * half_band` over +/// `[t0, t1)`. `None` when no finite sample is in the window. +pub fn is_steady(times: &[f64], values: &[f64], t0: f64, t1: f64, half_band: f64) -> Option { + span(values, index_range(times, t0, t1)).map(|s| s <= 2.0 * half_band) +} + +/// Median of the finite samples in `[t0, t1)`. +pub fn window_median(times: &[f64], values: &[f64], t0: f64, t1: f64) -> Option { + let r = index_range(times, t0, t1); + median(&values[r.start.min(values.len())..r.end.min(values.len())]) +} + +/// Whether any finite sample in `[t0, t1)` satisfies `pred`. +pub fn any_in_window( + times: &[f64], + values: &[f64], + t0: f64, + t1: f64, + pred: impl Fn(f64) -> bool, +) -> bool { + let r = index_range(times, t0, t1); + values[r.start.min(values.len())..r.end.min(values.len())] + .iter() + .any(|&v| v.is_finite() && pred(v)) +} + +/// A threshold crossing located between two update instants. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Crossing { + /// Interpolated crossing time. + pub time: f64, + /// Index of the update instant at which the threshold was first met. + pub index: usize, +} + +/// First point in `instants` (indices into `times` / `values`, increasing) +/// where `signed_dev(i) >= threshold`, with the crossing time linearly +/// interpolated from the previous sensor reading. +/// +/// The previous reading is the last update instant before `i`, but no +/// earlier than `times[i] - update_interval`: a sample-and-hold sensor that +/// reported the same value for a second has still been *sampling* every +/// `update_interval`, so the reading before the crossing one was taken about +/// one interval earlier, not at the last value change. Interpolating from +/// the last change would place every crossing after a flat baseline far too +/// early; interpolating from the adjacent log sample would place it too late +/// for a sensor slower than the log. +/// +/// `seed` is an optional update instant before the first of `instants` +/// that supplies the previous reading for the first candidate. +/// +/// Returns `None` when no instant reaches the threshold. +pub fn find_crossing( + times: &[f64], + instants: &[usize], + threshold: f64, + update_interval: f64, + seed: Option, + signed_dev: impl Fn(usize) -> f64, +) -> Option { + let mut prev: Option<(usize, f64)> = seed + .map(|s| (s, signed_dev(s))) + .filter(|(_, d)| d.is_finite()); + for &i in instants { + let d = signed_dev(i); + if !d.is_finite() { + continue; + } + if d >= threshold { + let time = match prev { + Some((pi, pd)) if d > pd => { + let t_prev = times[pi].max(times[i] - update_interval).min(times[i]); + let frac = ((threshold - pd) / (d - pd)).clamp(0.0, 1.0); + t_prev + frac * (times[i] - t_prev) + } + _ => times[i], + }; + return Some(Crossing { time, index: i }); + } + prev = Some((i, d)); + } + None +} + +/// A contiguous run where a rate signal exceeded a threshold. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RateRun { + /// First index above threshold. + pub start: usize, + /// Last index above threshold (inclusive). + pub end: usize, + /// Index of the peak rate within the run. + pub peak_index: usize, + /// Peak rate value. + pub peak: f64, +} + +/// Find runs of at least `min_samples` consecutive samples with +/// `rate[i] >= threshold`. +pub fn find_rate_runs(rate: &[f64], threshold: f64, min_samples: usize) -> Vec { + let mut runs = Vec::new(); + let mut i = 0; + while i < rate.len() { + if rate[i].is_finite() && rate[i] >= threshold { + let start = i; + let mut peak_index = i; + let mut peak = rate[i]; + while i < rate.len() && rate[i].is_finite() && rate[i] >= threshold { + if rate[i] > peak { + peak = rate[i]; + peak_index = i; + } + i += 1; + } + let end = i - 1; + if end + 1 - start >= min_samples.max(1) { + runs.push(RateRun { + start, + end, + peak_index, + peak, + }); + } + } else { + i += 1; + } + } + runs +} + +/// Merge runs whose starts are within `gap_s` of the previous run's end into +/// one event, keeping the larger peak. Overlapping tip-ins (a double stab of +/// the throttle) cannot be attributed separately. +pub fn merge_runs(times: &[f64], runs: &[RateRun], gap_s: f64) -> Vec { + let mut merged: Vec = Vec::new(); + for run in runs { + if let Some(last) = merged.last_mut() + && times[run.start] - times[last.end] <= gap_s + { + last.end = run.end; + if run.peak > last.peak { + last.peak = run.peak; + last.peak_index = run.peak_index; + } + continue; + } + merged.push(*run); + } + merged +} + +/// Integrate `f(i)` over `[start, end]` with the trapezoid rule on `times`. +pub fn integrate(times: &[f64], start: usize, end: usize, f: impl Fn(usize) -> f64) -> f64 { + if end <= start || end >= times.len() { + return 0.0; + } + let mut area = 0.0; + let mut prev_v = f(start); + for i in (start + 1)..=end { + let v = f(i); + let dt = times[i] - times[i - 1]; + if prev_v.is_finite() && v.is_finite() && dt > 0.0 { + area += 0.5 * (prev_v + v) * dt; + } + if v.is_finite() { + prev_v = v; + } + } + area +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn masking_drops_sentinels_and_out_of_band() { + let v = [1.0, -2147483637.0, f64::NAN, 50.0, -5.0]; + let m = mask_invalid(&v, Some((0.0, 10.0))); + assert_eq!(m[0], 1.0); + assert!(m[1].is_nan()); + assert!(m[2].is_nan()); + assert!(m[3].is_nan()); + assert!(m[4].is_nan()); + assert!((invalid_fraction(&m) - 0.8).abs() < 1e-9); + assert!(!is_invalid_sample(-1e6)); + } + + #[test] + fn steadiness_uses_peak_to_peak() { + let times: Vec = (0..10).map(|i| i as f64 * 0.1).collect(); + let values = [ + 1000.0, 1050.0, 1100.0, 1300.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, + ]; + assert_eq!(is_steady(×, &values, 0.0, 0.3, 200.0), Some(true)); + assert_eq!(is_steady(×, &values, 0.0, 0.5, 100.0), Some(false)); + assert_eq!(is_steady(×, &values, 5.0, 6.0, 100.0), None); + assert_eq!(window_median(×, &values, 0.4, 1.0), Some(1000.0)); + } + + #[test] + fn crossing_interpolates_between_update_instants() { + // Value held for 5 samples per update; deviation rises 0, 0, 0.5, 1.0. + let times: Vec = (0..20).map(|i| i as f64 * 0.01).collect(); + let values: Vec = (0..20).map(|i| [0.0, 0.0, 0.5, 1.0][i / 5]).collect(); + let instants = super::super::stats::update_instants(&values); + // Instants at 0, 10, 15 (index 5 is a hold of 0.0). + assert_eq!(instants, vec![0, 10, 15]); + let c = find_crossing(×, &instants, 0.75, 0.05, None, |i| values[i]).unwrap(); + assert_eq!(c.index, 15); + // Halfway between t=0.10 (0.5) and t=0.15 (1.0). + assert!((c.time - 0.125).abs() < 1e-9); + assert!(find_crossing(×, &instants, 2.0, 0.05, None, |i| values[i]).is_none()); + // A long flat hold before the crossing: the previous reading is + // taken one update interval before, not at the last value change. + let c = find_crossing(×, &instants[1..], 0.25, 0.05, Some(0), |i| values[i]).unwrap(); + assert_eq!(c.index, 10); + assert!((c.time - 0.075).abs() < 1e-9, "{}", c.time); + // Without a seed the first instant cannot be interpolated. + let c = find_crossing(×, &instants[1..], 0.25, 0.05, None, |i| values[i]).unwrap(); + assert!((c.time - 0.10).abs() < 1e-9); + } + + #[test] + fn rate_runs_and_merging() { + let rate = [ + 0.0, 60.0, 80.0, 10.0, 70.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 90.0, 95.0, + ]; + let runs = find_rate_runs(&rate, 50.0, 1); + assert_eq!(runs.len(), 3); + assert_eq!(runs[0].start, 1); + assert_eq!(runs[0].end, 2); + assert_eq!(runs[0].peak, 80.0); + let two = find_rate_runs(&rate, 50.0, 2); + assert_eq!(two.len(), 2); + let times: Vec = (0..rate.len()).map(|i| i as f64 * 0.1).collect(); + let merged = merge_runs(×, &runs, 0.25); + assert_eq!(merged.len(), 2); + assert_eq!(merged[0].start, 1); + assert_eq!(merged[0].end, 4); + assert_eq!(merged[0].peak, 80.0); + assert_eq!(merged[1].peak, 95.0); + } + + #[test] + fn trapezoid_integral() { + let times = [0.0, 1.0, 2.0]; + let v = [0.0, 2.0, 2.0]; + assert!((integrate(×, 0, 2, |i| v[i]) - 3.0).abs() < 1e-9); + assert_eq!(integrate(×, 2, 2, |i| v[i]), 0.0); + } +} diff --git a/src/analysis/tables/export.rs b/src/analysis/tables/export.rs new file mode 100644 index 00000000..f5fa160c --- /dev/null +++ b/src/analysis/tables/export.rs @@ -0,0 +1,363 @@ +//! CSV and clipboard (TSV) rendering of generated tables. +//! +//! The CSV carries the value grid, the sample-count grid and the MAD grid +//! under `#`-prefixed comment headers so downstream judgement is possible; +//! empty cells are written blank, never as `0`. The clipboard form is the +//! bare value grid with axis headers, which is what tuning-software grids +//! accept on paste. + +use super::binning::{Confidence, TableGrid}; +use super::{MeasureSpec, TableAccumulator}; + +/// Output unit for a lambda-delay table. ECUs disagree: MS3 / AEM Infinity +/// take engine cycles, MoTeC M1 / Link / Emerald take ignition events. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum DelayUnit { + #[default] + Milliseconds, + EngineCycles, + IgnitionEvents, +} + +impl DelayUnit { + pub const ALL: [DelayUnit; 3] = [ + DelayUnit::Milliseconds, + DelayUnit::EngineCycles, + DelayUnit::IgnitionEvents, + ]; + + pub fn label(self) -> &'static str { + match self { + Self::Milliseconds => "ms", + Self::EngineCycles => "engine cycles", + Self::IgnitionEvents => "ignition events", + } + } + + /// Convert a millisecond delay at `rpm` for an engine with `cylinders`. + /// One four-stroke engine cycle is two revolutions, i.e. `120000 / rpm` ms. + pub fn convert(self, ms: f64, rpm: f64, cylinders: u32) -> f64 { + match self { + Self::Milliseconds => ms, + Self::EngineCycles => ms * rpm / 120_000.0, + Self::IgnitionEvents => ms * rpm / 120_000.0 * cylinders as f64 / 2.0, + } + } + + pub fn decimals(self) -> usize { + match self { + Self::Milliseconds => 0, + Self::EngineCycles | Self::IgnitionEvents => 2, + } + } +} + +/// How cell values are transformed on the way out. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ExportOptions { + /// Leave `Low` confidence cells blank. + pub exclude_low: bool, + /// Delay unit conversion (lambda delay tables only). + pub delay_unit: DelayUnit, + pub cylinders: u32, +} + +impl Default for ExportOptions { + fn default() -> Self { + Self { + exclude_low: true, + delay_unit: DelayUnit::Milliseconds, + cylinders: 4, + } + } +} + +fn fmt_number(v: f64, decimals: usize) -> String { + if !v.is_finite() { + return String::new(); + } + format!("{v:.decimals$}") +} + +fn fmt_edge(v: f64) -> String { + if (v - v.round()).abs() < 1e-9 { + format!("{}", v.round() as i64) + } else { + format!("{v:.2}") + } +} + +fn header_row(grid: &TableGrid, sep: &str) -> String { + let mut cols = vec![format!( + "{} \\ {}", + grid.y_axis.header(), + grid.x_axis.header() + )]; + cols.extend((0..grid.cols()).map(|c| fmt_edge(grid.x_axis.lower_edge(c)))); + cols.join(sep) +} + +/// Value of one cell after options: `None` renders blank. +fn cell_value( + grid: &TableGrid, + row: usize, + col: usize, + measure: &MeasureSpec, + opts: &ExportOptions, + delay_table: bool, +) -> Option<(f64, usize)> { + let cell = grid.cell(row, col)?; + if cell.is_empty() || (opts.exclude_low && cell.confidence == Confidence::Low) { + return None; + } + if delay_table && measure.unit == "ms" && opts.delay_unit != DelayUnit::Milliseconds { + let rpm = grid.x_axis.center(col); + Some(( + opts.delay_unit.convert(cell.median, rpm, opts.cylinders), + opts.delay_unit.decimals(), + )) + } else { + Some((cell.median, measure.decimals)) + } +} + +fn value_grid( + grid: &TableGrid, + measure: &MeasureSpec, + opts: &ExportOptions, + delay_table: bool, + sep: &str, +) -> String { + let mut out = header_row(grid, sep); + out.push('\n'); + for r in 0..grid.rows() { + let mut cols = vec![fmt_edge(grid.y_axis.lower_edge(r))]; + for c in 0..grid.cols() { + cols.push(match cell_value(grid, r, c, measure, opts, delay_table) { + Some((v, d)) => fmt_number(v, d), + None => String::new(), + }); + } + out.push_str(&cols.join(sep)); + out.push('\n'); + } + out +} + +fn aux_grid(grid: &TableGrid, sep: &str, f: impl Fn(&super::CellStats) -> String) -> String { + let mut out = header_row(grid, sep); + out.push('\n'); + for r in 0..grid.rows() { + let mut cols = vec![fmt_edge(grid.y_axis.lower_edge(r))]; + for c in 0..grid.cols() { + cols.push(grid.cell(r, c).map(&f).unwrap_or_default()); + } + out.push_str(&cols.join(sep)); + out.push('\n'); + } + out +} + +/// Full CSV: value grid, count grid, MAD grid, with `#` comment headers. +pub fn to_csv( + acc: &TableAccumulator, + measure_index: usize, + opts: &ExportOptions, + generated: &str, +) -> String { + let measure = acc + .measures + .get(measure_index) + .copied() + .unwrap_or(MeasureSpec { + key: "value", + label: "Value", + unit: "", + decimals: 2, + }); + let grid = acc.grid(measure_index); + let delay_table = acc.generator == super::GeneratorKind::LambdaDelay; + let unit = if delay_table && measure.unit == "ms" { + opts.delay_unit.label().to_string() + } else { + measure.unit.to_string() + }; + let mut out = String::new(); + out.push_str(&format!( + "# UltraLog {} - {} ({}), generated {}\n", + acc.generator.create().name(), + measure.label, + unit, + generated + )); + let logs: Vec<&str> = acc.logs.iter().map(|l| l.name.as_str()).collect(); + out.push_str(&format!("# Logs: {}\n", logs.join(", "))); + out.push_str(&format!( + "# Rows: {}, Columns: {} (lower cell edges)\n", + grid.y_axis.header(), + grid.x_axis.header() + )); + let (filled, high) = grid.coverage(); + out.push_str(&format!( + "# {} accepted events, {}/{} cells filled, {} high confidence{}\n", + acc.accepted_count(), + filled, + grid.rows() * grid.cols(), + high, + if opts.exclude_low { + ", low-confidence cells left blank" + } else { + "" + } + )); + if delay_table && opts.delay_unit != DelayUnit::Milliseconds { + out.push_str(&format!( + "# Delay converted at the cell's centre RPM for {} cylinders\n", + opts.cylinders + )); + } + out.push_str(&value_grid(&grid, &measure, opts, delay_table, ",")); + out.push_str("# Sample counts\n"); + out.push_str(&aux_grid(&grid, ",", |c| c.count.to_string())); + out.push_str("# Median absolute deviation\n"); + out.push_str(&aux_grid(&grid, ",", |c| { + if c.is_empty() { + String::new() + } else { + fmt_number(c.mad, measure.decimals.max(1)) + } + })); + out.push_str("# Confidence\n"); + out.push_str(&aux_grid(&grid, ",", |c| c.confidence.label().to_string())); + out +} + +/// Bare tab-separated value grid with axis headers, for pasting into a +/// tuning-software table. +pub fn to_clipboard_tsv( + acc: &TableAccumulator, + measure_index: usize, + opts: &ExportOptions, +) -> String { + let measure = acc + .measures + .get(measure_index) + .copied() + .unwrap_or(MeasureSpec { + key: "value", + label: "Value", + unit: "", + decimals: 2, + }); + let grid = acc.grid(measure_index); + let delay_table = acc.generator == super::GeneratorKind::LambdaDelay; + value_grid(&grid, &measure, opts, delay_table, "\t") +} + +#[cfg(test)] +mod tests { + use super::super::{AxisSpec, GeneratorKind, RunReport, TableEvent}; + use super::*; + + fn acc() -> TableAccumulator { + let axes = ( + AxisSpec::new("RPM", "", vec![1000.0, 2000.0, 3000.0]), + AxisSpec::new("MAP", "kPa", vec![0.0, 50.0, 100.0]), + ); + let measures = GeneratorKind::LambdaDelay.create().measures(); + let mut a = TableAccumulator::new(GeneratorKind::LambdaDelay, axes, measures); + let mut events = Vec::new(); + for i in 0..8 { + events.push(TableEvent { + log_id: 1, + log_name: "a.csv".into(), + time: i as f64, + rpm: 1500.0, + axis_value: 25.0, + values: vec![100.0 + i as f64, f64::NAN, 0.02, 10.0], + quality: 1.0, + reject: None, + note: String::new(), + }); + } + events.push(TableEvent { + log_id: 1, + log_name: "a.csv".into(), + time: 20.0, + rpm: 2500.0, + axis_value: 75.0, + values: vec![300.0, f64::NAN, 0.02, 10.0], + quality: 1.0, + reject: None, + note: String::new(), + }); + a.add_log( + 1, + "a.csv", + events.clone(), + RunReport::from_events("a.csv", &events), + ); + a + } + + #[test] + fn delay_unit_conversion() { + // 100 ms at 3000 rpm = 2.5 cycles = 5 ignition events on a 4-cyl. + assert_eq!(DelayUnit::Milliseconds.convert(100.0, 3000.0, 4), 100.0); + assert!((DelayUnit::EngineCycles.convert(100.0, 3000.0, 4) - 2.5).abs() < 1e-9); + assert!((DelayUnit::IgnitionEvents.convert(100.0, 3000.0, 4) - 5.0).abs() < 1e-9); + } + + #[test] + fn csv_has_all_grids_and_blank_low_cells() { + let a = acc(); + let csv = to_csv(&a, 0, &ExportOptions::default(), "2026-09-18"); + assert!( + csv.starts_with( + "# UltraLog Lambda Delay Table - Dead time (ms), generated 2026-09-18\n" + ) + ); + assert!(csv.contains("# Logs: a.csv\n")); + assert!(csv.contains("MAP (kPa) \\ RPM,1000,2000\n")); + // High-confidence cell present, single-sample (Low) cell blank. + assert!(csv.contains("\n0,104,\n"), "{csv}"); + assert!(csv.contains("\n50,,\n"), "{csv}"); + assert!(csv.contains("# Sample counts\n")); + assert!(csv.contains("\n0,8,0\n")); + assert!(csv.contains("\n50,0,1\n")); + assert!(csv.contains("# Median absolute deviation\n")); + assert!(csv.contains("# Confidence\n")); + assert!(csv.contains("high,empty")); + + let all = ExportOptions { + exclude_low: false, + ..Default::default() + }; + let csv = to_csv(&a, 0, &all, "x"); + assert!(csv.contains("\n50,,300\n"), "{csv}"); + } + + #[test] + fn csv_converts_delay_units() { + let a = acc(); + let opts = ExportOptions { + exclude_low: false, + delay_unit: DelayUnit::EngineCycles, + cylinders: 4, + }; + let csv = to_csv(&a, 0, &opts, "x"); + assert!(csv.contains("(engine cycles)")); + // 103.5 ms at the 1500 rpm cell centre = 1.29 cycles. + assert!(csv.contains("\n0,1.29,\n"), "{csv}"); + // Non-ms measures are untouched. + let csv = to_csv(&a, 2, &opts, "x"); + assert!(csv.contains("\n0,0.020,"), "{csv}"); + } + + #[test] + fn clipboard_is_bare_tsv() { + let a = acc(); + let tsv = to_clipboard_tsv(&a, 0, &ExportOptions::default()); + assert_eq!(tsv, "MAP (kPa) \\ RPM\t1000\t2000\n0\t104\t\n50\t\t\n"); + } +} diff --git a/src/analysis/tables/lambda_delay.rs b/src/analysis/tables/lambda_delay.rs new file mode 100644 index 00000000..6d3d4f06 --- /dev/null +++ b/src/analysis/tables/lambda_delay.rs @@ -0,0 +1,1289 @@ +//! Lambda delay table generator (issue #4). +//! +//! Physical model: a step in injector pulse width enriches the charge, and +//! the wideband reads it after exhaust transport plus sensor response. That +//! total, mapped over RPM × load, is what a closed-loop O2 controller wants +//! in its delay table. +//! +//! Algorithm, per log: +//! +//! 1. Mask invalid samples (sentinels, out-of-band) per role. +//! 2. Find pulse-width steps: the first index where the dead-time-adjusted PW +//! `step_window_ms` ahead differs from the pre-window median by at least +//! `min_step_pct`. The step instant is the largest single-sample jump in +//! that window, i.e. the first sample carrying the new value. +//! 3. Gate: steady RPM / load across the window, no other step within +//! `min_event_spacing_ms`, PW above the floor, no fuel cut, no clutch, warm +//! engine, closed-loop correction not moving, enough valid lambda samples. +//! 4. Measure: lambda baseline is the median over the 250 ms before the step; +//! the response threshold is `max(response_k × σ, min_response_delta)` +//! where σ is the robust noise of the sensor's *update instants*; the +//! crossing time is interpolated between update instants. **Dead time** +//! (first crossing) is the primary value; **t63** (63 % of the settled +//! deflection) is a secondary view. +//! 5. Bin by RPM × load at the step instant. Median per cell. +//! +//! The measured value includes roughly half an engine cycle of injection +//! scheduling plus a cycle to the exhaust port; that is what a closed-loop +//! delay table should contain, so it is reported, not subtracted. + +use std::collections::HashMap; + +use super::channel_map::{ChannelMapping, ChannelRole, LoadKind, RoleSpec}; +use super::events::{ + any_in_window, find_crossing, invalid_fraction, is_steady, mask_invalid, span, window_median, +}; +use super::stats::{ + effective_update_interval, index_range, median, median_interval, robust_sigma_diff, + update_instants, +}; +use super::{ + AxisSpec, GeneratorContext, MeasureSpec, RejectReason, RunReport, TableAnalyzer, TableEvent, + TableParam, TableParamKind, mapped_column, param_f64, required_column, +}; +use crate::analysis::afr::{FuelMixtureUnit, STOICH_AFR_GASOLINE, detect_fuel_mixture_unit}; +use crate::analysis::{AnalysisError, AnalyzerConfig, timed_analyze}; +use crate::parsers::types::Log; + +pub const ID: &str = "lambda_delay"; + +/// Slowest log rate the generator will work with (4 Hz). +const MAX_SAMPLE_INTERVAL_S: f64 = 0.25; +/// Lambda update interval above which a resolution warning is emitted. +const SLOW_LAMBDA_UPDATE_S: f64 = 0.05; +/// Baseline window before the step for PW / RPM / load medians. +const PW_BASELINE_S: f64 = 0.3; +/// Baseline window before the step for the lambda median. +const LAMBDA_BASELINE_S: f64 = 0.25; +/// Fraction of masked lambda samples in a window that rejects the event. +const MAX_INVALID_FRACTION: f64 = 0.10; +/// Window before the step for the local noise estimate. +const NOISE_WINDOW_S: f64 = 1.0; +/// Update instants the local noise window needs before it is trusted. +const MIN_LOCAL_NOISE_UPDATES: usize = 8; + +/// Gating profile: strict for trim-bump logs, relaxed for driving logs. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum Profile { + #[default] + Strict, + Relaxed, +} + +impl Profile { + pub const CHOICES: &'static [&'static str] = &["strict", "relaxed"]; + + pub fn as_str(self) -> &'static str { + match self { + Self::Strict => "strict", + Self::Relaxed => "relaxed", + } + } + + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "strict" => Some(Self::Strict), + "relaxed" => Some(Self::Relaxed), + _ => None, + } + } + + /// `(rpm half-band, event spacing ms)` the profile implies. The load band + /// is unchanged: a 15 kPa rise is a tip-in whose lean-first response + /// would measure wall wetting, not sensor delay. + fn gates(self) -> (f64, f64) { + match self { + Self::Strict => (200.0, 600.0), + Self::Relaxed => (400.0, 400.0), + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct LambdaDelayGenerator { + /// Minimum PW step relative to the pre-step median, in percent. + pub min_step_pct: f64, + /// The step must be complete within this many milliseconds. + pub step_window_ms: f64, + /// Another step within this spacing rejects both (attribution guard). + pub min_event_spacing_ms: f64, + /// Response threshold multiplier on the sensor noise sigma. + pub response_k: f64, + /// Response threshold floor in lambda units (converted for AFR channels). + pub min_response_delta: f64, + /// Reject if the sensor has not responded within this window. + pub response_timeout_ms: f64, + /// PW below this (in the channel's own unit) is a fuel-cut region. + pub pw_floor: f64, + /// Injector dead time subtracted before the relative step test. + pub injector_deadtime_ms: f64, + /// RPM half-band for the steadiness gate. + pub steady_rpm_band: f64, + /// MAP half-band (kPa) for the steadiness gate. + pub steady_map_band: f64, + /// TPS half-band (%) for the steadiness gate. + pub steady_tps_band: f64, + /// Closed-loop correction movement (channel units) that rejects an event. + pub closed_loop_band: f64, + /// Minimum coolant temperature when a coolant role is mapped. + pub min_coolant_temp: f64, + pub profile: Profile, +} + +impl Default for LambdaDelayGenerator { + fn default() -> Self { + Self { + min_step_pct: 8.0, + step_window_ms: 150.0, + min_event_spacing_ms: 600.0, + response_k: 3.0, + min_response_delta: 0.005, + response_timeout_ms: 1500.0, + pw_floor: 1.0, + injector_deadtime_ms: 0.0, + steady_rpm_band: 200.0, + steady_map_band: 8.0, + steady_tps_band: 5.0, + closed_loop_band: 1.0, + min_coolant_temp: 60.0, + profile: Profile::Strict, + } + } +} + +impl LambdaDelayGenerator { + /// Apply a profile's gate values. + pub fn apply_profile(&mut self, profile: Profile) { + let (rpm, spacing) = profile.gates(); + self.profile = profile; + self.steady_rpm_band = rpm; + self.min_event_spacing_ms = spacing; + } + + fn measures_list() -> Vec { + vec![ + MeasureSpec { + key: "dead_time_ms", + label: "Dead time", + unit: "ms", + decimals: 0, + }, + MeasureSpec { + key: "t63_ms", + label: "Rise time (t63)", + unit: "ms", + decimals: 0, + }, + MeasureSpec { + key: "response_magnitude", + label: "Response magnitude", + unit: "λ/AFR", + decimals: 3, + }, + MeasureSpec { + key: "step_pct", + label: "PW step", + unit: "%", + decimals: 1, + }, + ] + } +} + +/// A pulse-width step before gating. +#[derive(Clone, Copy, Debug)] +struct StepCandidate { + /// Index of the first sample carrying the new value. + index: usize, + /// Relative step size (signed fraction). + rel: f64, + /// Pre-step PW median (dead-time adjusted). + baseline: f64, +} + +/// Find PW steps. Returns candidates ordered by time, one per step edge. +fn find_pw_steps( + times: &[f64], + pw: &[f64], + min_step: f64, + step_window_s: f64, +) -> Vec { + let n = times.len(); + let mut out: Vec = Vec::new(); + let mut prev_qualifies = false; + for i in 0..n { + let t = times[i]; + let pre = index_range(times, t - PW_BASELINE_S, t); + if pre.len() < 2 { + prev_qualifies = false; + continue; + } + let Some(baseline) = median(&pw[pre]) else { + prev_qualifies = false; + continue; + }; + if baseline.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) { + prev_qualifies = false; + continue; + } + // Last index within the step window. + let k = times + .partition_point(|&x| x <= t + step_window_s) + .saturating_sub(1); + if k <= i || !pw[k].is_finite() { + prev_qualifies = false; + continue; + } + let rel = (pw[k] - baseline) / baseline; + let qualifies = rel.abs() >= min_step; + if qualifies && !prev_qualifies { + // Locate the edge: the largest single-sample jump in [i, k]. + let mut best = i; + let mut best_jump = 0.0; + for j in i..k { + if pw[j].is_finite() && pw[j + 1].is_finite() { + let jump = ((pw[j + 1] - pw[j]) * rel.signum()).max(0.0); + if jump > best_jump { + best_jump = jump; + best = j; + } + } + } + let index = best + 1; + // A run that broke on noise and restarted re-finds the same edge. + if out.last().is_none_or(|c| c.index != index) { + out.push(StepCandidate { + index, + rel, + baseline, + }); + } + } + prev_qualifies = qualifies; + } + out +} + +impl TableAnalyzer for LambdaDelayGenerator { + fn id(&self) -> &'static str { + ID + } + + fn name(&self) -> &'static str { + "Lambda Delay Table" + } + + fn description(&self) -> &'static str { + "Measures the time between injector pulse-width steps and the wideband response, \ + binned by RPM and load, for closed-loop O2 control delay tables." + } + + fn roles(&self) -> Vec { + vec![ + RoleSpec::required(ChannelRole::Rpm), + RoleSpec::optional(ChannelRole::Map), + RoleSpec::optional(ChannelRole::Tps), + RoleSpec::required(ChannelRole::PulseWidth), + RoleSpec::required(ChannelRole::Lambda), + RoleSpec::optional(ChannelRole::FuelCut), + RoleSpec::optional(ChannelRole::ClosedLoopState), + RoleSpec::optional(ChannelRole::CoolantTemp), + RoleSpec::optional(ChannelRole::Clutch), + ] + } + + fn measures(&self) -> Vec { + Self::measures_list() + } + + fn default_axes(&self, log: &Log, mapping: &ChannelMapping) -> (AxisSpec, AxisSpec) { + let rpm = mapped_column(log, mapping, ChannelRole::Rpm) + .ok() + .flatten() + .map(|v| mask_invalid(&v, Some((0.0, 20_000.0)))) + .unwrap_or_default(); + let fallback_rpm: Vec = (1..=16).map(|i| i as f64 * 500.0).collect(); + let x = AxisSpec::from_data("RPM", "", &rpm, 500.0, &fallback_rpm); + let y = match mapping.load_kind { + LoadKind::Map => { + let map = mapped_column(log, mapping, ChannelRole::Map) + .ok() + .flatten() + .map(|v| mask_invalid(&v, Some((-110.0, 600.0)))) + .unwrap_or_default(); + let fallback: Vec = (0..=15).map(|i| i as f64 * 20.0).collect(); + AxisSpec::from_data("MAP", "kPa", &map, 10.0, &fallback) + } + LoadKind::Tps => { + let tps = mapped_column(log, mapping, ChannelRole::Tps) + .ok() + .flatten() + .map(|v| mask_invalid(&v, Some((0.0, 100.0)))) + .unwrap_or_default(); + let fallback: Vec = (0..=10).map(|i| i as f64 * 10.0).collect(); + AxisSpec::from_data("TPS", "%", &tps, 10.0, &fallback) + } + }; + (x, y) + } + + fn analyze( + &self, + log: &Log, + log_name: &str, + mapping: &ChannelMapping, + axes: &(AxisSpec, AxisSpec), + _ctx: &GeneratorContext<'_>, + ) -> Result<(Vec, RunReport), AnalysisError> { + let times = &log.times; + if times.len() < 10 { + return Err(AnalysisError::InsufficientData { + needed: 10, + got: times.len(), + }); + } + let dt = median_interval(times).ok_or_else(|| { + AnalysisError::ComputationError("log has no usable time axis".to_string()) + })?; + if dt > MAX_SAMPLE_INTERVAL_S { + return Err(AnalysisError::InvalidParameter(format!( + "log rate is {:.1} Hz; lambda delay needs at least 4 Hz", + 1.0 / dt + ))); + } + + let rpm = mask_invalid( + &required_column(log, mapping, ChannelRole::Rpm)?, + Some((0.0, 20_000.0)), + ); + let (load, load_band) = match mapping.load_kind { + LoadKind::Map => ( + mask_invalid( + &required_column(log, mapping, ChannelRole::Map)?, + Some((-110.0, 600.0)), + ), + self.steady_map_band, + ), + LoadKind::Tps => ( + mask_invalid( + &required_column(log, mapping, ChannelRole::Tps)?, + Some((0.0, 100.0)), + ), + self.steady_tps_band, + ), + }; + let pw_raw = mask_invalid( + &required_column(log, mapping, ChannelRole::PulseWidth)?, + Some((0.0, 1e6)), + ); + let pw: Vec = pw_raw + .iter() + .map(|v| v - self.injector_deadtime_ms) + .collect(); + let lambda_raw = mask_invalid(&required_column(log, mapping, ChannelRole::Lambda)?, None); + let unit = detect_fuel_mixture_unit( + &lambda_raw + .iter() + .copied() + .filter(|v| v.is_finite()) + .collect::>(), + ); + let lambda_band = match unit { + FuelMixtureUnit::Lambda => (0.4, 2.0), + FuelMixtureUnit::Afr => (5.0, 30.0), + }; + let lambda = mask_invalid(&lambda_raw, Some(lambda_band)); + let fuel_cut = mapped_column(log, mapping, ChannelRole::FuelCut)?; + let closed_loop = mapped_column(log, mapping, ChannelRole::ClosedLoopState)? + .map(|v| mask_invalid(&v, None)); + let coolant = + mapped_column(log, mapping, ChannelRole::CoolantTemp)?.map(|v| mask_invalid(&v, None)); + let clutch = mapped_column(log, mapping, ChannelRole::Clutch)?; + + let mut warnings = Vec::new(); + let update_interval = effective_update_interval(times, &lambda); + match update_interval { + Some(u) if u > SLOW_LAMBDA_UPDATE_S => warnings.push(format!( + "Lambda updates at {:.0} Hz inside a {:.0} Hz log - delay resolution is about ±{:.0} ms", + 1.0 / u, + 1.0 / dt, + u * 500.0 + )), + None => warnings.push("Lambda channel never changes value; every event will be rejected".to_string()), + _ => {} + } + if closed_loop.is_none() { + warnings.push( + "No closed-loop correction role mapped; results assume open loop".to_string(), + ); + } + warnings.push(format!("Lambda channel detected as {}", unit.unit_name())); + + let min_delta = match unit { + FuelMixtureUnit::Lambda => self.min_response_delta, + FuelMixtureUnit::Afr => self.min_response_delta * STOICH_AFR_GASOLINE, + }; + // Whole-log noise estimate; in a driving log most differences are + // real mixture changes, so a quieter local estimate from the second + // before each step wins when there are enough updates for one. + let global_sigma = robust_sigma_diff(&lambda).unwrap_or(0.0); + let instants = update_instants(&lambda); + let step_window_s = self.step_window_ms / 1000.0; + let spacing_s = self.min_event_spacing_ms / 1000.0; + let timeout_s = self.response_timeout_ms / 1000.0; + let rate_factor = update_interval.map_or(0.25, |u| (0.02 / u).clamp(0.25, 1.0)) as f32; + + let (events, elapsed) = timed_analyze(|| { + let candidates = find_pw_steps(times, &pw, self.min_step_pct / 100.0, step_window_s); + let step_times: Vec = candidates.iter().map(|c| times[c.index]).collect(); + let mut events = Vec::with_capacity(candidates.len()); + for (ci, cand) in candidates.iter().enumerate() { + let t_step = times[cand.index]; + let dir = cand.rel.signum(); + let w0 = t_step - PW_BASELINE_S; + let w1 = t_step + timeout_s; + let rpm_at = window_median(times, &rpm, w0, t_step).unwrap_or(f64::NAN); + let load_at = window_median(times, &load, w0, t_step).unwrap_or(f64::NAN); + let mut values = vec![f64::NAN, f64::NAN, f64::NAN, cand.rel * 100.0]; + let mut note = format!("{} step", if dir > 0.0 { "rising" } else { "falling" }); + let event = |reject: Option, + values: Vec, + quality: f32, + note: String| TableEvent { + log_id: 0, + log_name: log_name.to_string(), + time: t_step, + rpm: rpm_at, + axis_value: load_at, + values, + quality, + reject, + note, + }; + + let overlap = step_times + .iter() + .enumerate() + .any(|(j, &t)| j != ci && (t - t_step).abs() < spacing_s); + if overlap { + events.push(event(Some(RejectReason::Overlap), values, 0.0, note)); + continue; + } + if cand.baseline < self.pw_floor { + events.push(event(Some(RejectReason::LowPw), values, 0.0, note)); + continue; + } + if let Some(fc) = &fuel_cut + && any_in_window(times, fc, w0, w1, |v| v > 0.5) + { + events.push(event(Some(RejectReason::FuelCut), values, 0.0, note)); + continue; + } + if let Some(cl) = &clutch + && any_in_window(times, cl, w0, w1, |v| v > 0.5) + { + events.push(event(Some(RejectReason::Clutch), values, 0.0, note)); + continue; + } + if let Some(ct) = &coolant + && window_median(times, ct, w0, w1).is_some_and(|c| c < self.min_coolant_temp) + { + events.push(event(Some(RejectReason::ColdEngine), values, 0.0, note)); + continue; + } + if let Some(cl) = &closed_loop + && span( + cl, + index_range(times, t_step - step_window_s, t_step + step_window_s), + ) + .is_some_and(|s| s > self.closed_loop_band) + { + events.push(event( + Some(RejectReason::ClosedLoopActive), + values, + 0.0, + note, + )); + continue; + } + let steady_rpm = is_steady(times, &rpm, w0, w1, self.steady_rpm_band); + let steady_load = is_steady(times, &load, w0, w1, load_band); + if steady_rpm != Some(true) + || steady_load != Some(true) + || !rpm_at.is_finite() + || !load_at.is_finite() + { + events.push(event(Some(RejectReason::Unsteady), values, 0.0, note)); + continue; + } + let window = index_range(times, w0, w1); + if window.is_empty() + || invalid_fraction(&lambda[window.clone()]) > MAX_INVALID_FRACTION + || span(&lambda, window.clone()).is_none_or(|s| s <= 0.0) + { + events.push(event(Some(RejectReason::InvalidSamples), values, 0.0, note)); + continue; + } + if axes.0.bin_index(rpm_at).is_none() || axes.1.bin_index(load_at).is_none() { + events.push(event(Some(RejectReason::OutOfAxis), values, 0.0, note)); + continue; + } + + // Response measurement. + let pre = index_range(times, t_step - NOISE_WINDOW_S, t_step); + let sigma = match robust_sigma_diff(&lambda[pre.clone()]) { + Some(local) + if update_instants(&lambda[pre]).len() >= MIN_LOCAL_NOISE_UPDATES => + { + local.min(global_sigma) + } + _ => global_sigma, + }; + let threshold = (self.response_k * sigma).max(min_delta); + let Some(base_lambda) = + window_median(times, &lambda, t_step - LAMBDA_BASELINE_S, t_step) + else { + events.push(event(Some(RejectReason::InvalidSamples), values, 0.0, note)); + continue; + }; + // More fuel -> lambda / AFR falls. dev is positive in the + // expected direction. + let dev = |i: usize| (base_lambda - lambda[i]) * dir; + let lo = times.partition_point(|&x| x <= t_step); + let hi = times.partition_point(|&x| x <= w1); + let post: Vec = instants + .iter() + .copied() + .filter(|&i| i >= lo && i < hi) + .collect(); + let seed = instants.iter().copied().rfind(|&i| i < lo); + let interval = update_interval.unwrap_or(dt); + let expected = find_crossing(times, &post, threshold, interval, seed, dev); + let wrong = find_crossing(times, &post, threshold, interval, seed, |i| -dev(i)); + let crossing = match (expected, wrong) { + (Some(e), Some(w)) if w.time < e.time => { + events.push(event(Some(RejectReason::WrongDirection), values, 0.0, note)); + continue; + } + (None, Some(_)) => { + events.push(event(Some(RejectReason::WrongDirection), values, 0.0, note)); + continue; + } + (None, None) => { + events.push(event(Some(RejectReason::NoResponse), values, 0.0, note)); + continue; + } + (Some(e), _) => e, + }; + let dead_time_ms = (crossing.time - t_step) * 1000.0; + let magnitude = post + .iter() + .filter(|&&i| i >= crossing.index) + .map(|&i| dev(i)) + .fold(0.0_f64, f64::max); + let t63 = find_crossing(times, &post, 0.63 * magnitude, interval, seed, dev) + .map(|c| (c.time - t_step) * 1000.0) + .unwrap_or(f64::NAN); + values[0] = dead_time_ms; + values[1] = t63; + values[2] = magnitude; + let quality = ((magnitude / threshold / 3.0).clamp(0.0, 1.0) as f32) * rate_factor; + note.push_str(&format!( + ", threshold {:.4} {}, {} profile", + threshold, + unit.unit_name(), + self.profile.as_str() + )); + events.push(event(None, values, quality, note)); + } + events + }); + + let mut report = RunReport::from_events(log_name, &events); + report.warnings = warnings; + report.computation_time_ms = elapsed; + Ok((events, report)) + } + + fn params(&self) -> Vec { + vec![ + TableParam { + key: "profile", + label: "Gating profile", + tooltip: "Strict for trim-bump logs (RPM ±200, 600 ms spacing); relaxed for driving logs (RPM ±400, 400 ms).", + kind: TableParamKind::Choice(Profile::CHOICES), + }, + TableParam { + key: "min_step_pct", + label: "Min PW step (%)", + tooltip: "Minimum pulse-width change relative to the pre-step median.", + kind: TableParamKind::Float { + min: 1.0, + max: 100.0, + speed: 0.5, + }, + }, + TableParam { + key: "step_window_ms", + label: "Step window (ms)", + tooltip: "The step must complete within this time.", + kind: TableParamKind::Float { + min: 20.0, + max: 1000.0, + speed: 5.0, + }, + }, + TableParam { + key: "min_event_spacing_ms", + label: "Event spacing (ms)", + tooltip: "Steps closer together than this are rejected as overlapping.", + kind: TableParamKind::Float { + min: 100.0, + max: 5000.0, + speed: 10.0, + }, + }, + TableParam { + key: "response_k", + label: "Response k (× noise σ)", + tooltip: "Response threshold as a multiple of the sensor noise.", + kind: TableParamKind::Float { + min: 1.0, + max: 10.0, + speed: 0.1, + }, + }, + TableParam { + key: "min_response_delta", + label: "Min response (λ)", + tooltip: "Floor for the response threshold in lambda units (scaled ×14.7 for AFR channels).", + kind: TableParamKind::Float { + min: 0.001, + max: 0.2, + speed: 0.001, + }, + }, + TableParam { + key: "response_timeout_ms", + label: "Response timeout (ms)", + tooltip: "Reject the event if the sensor has not responded within this time.", + kind: TableParamKind::Float { + min: 200.0, + max: 5000.0, + speed: 10.0, + }, + }, + TableParam { + key: "pw_floor", + label: "PW floor", + tooltip: "Pulse width below this (channel units) is treated as fuel cut.", + kind: TableParamKind::Float { + min: 0.0, + max: 100.0, + speed: 0.1, + }, + }, + TableParam { + key: "injector_deadtime_ms", + label: "Injector dead time (ms)", + tooltip: "Subtracted before the relative step test when the PW channel includes dead time.", + kind: TableParamKind::Float { + min: 0.0, + max: 5.0, + speed: 0.01, + }, + }, + TableParam { + key: "steady_rpm_band", + label: "Steady RPM ±", + tooltip: "RPM half-band the event window must stay within.", + kind: TableParamKind::Float { + min: 25.0, + max: 2000.0, + speed: 5.0, + }, + }, + TableParam { + key: "steady_map_band", + label: "Steady MAP ± (kPa)", + tooltip: "MAP half-band the event window must stay within.", + kind: TableParamKind::Float { + min: 1.0, + max: 100.0, + speed: 0.5, + }, + }, + TableParam { + key: "steady_tps_band", + label: "Steady TPS ± (%)", + tooltip: "TPS half-band the event window must stay within.", + kind: TableParamKind::Float { + min: 1.0, + max: 50.0, + speed: 0.5, + }, + }, + TableParam { + key: "closed_loop_band", + label: "Closed-loop movement", + tooltip: "Correction-channel movement around the step that rejects the event.", + kind: TableParamKind::Float { + min: 0.1, + max: 50.0, + speed: 0.1, + }, + }, + TableParam { + key: "min_coolant_temp", + label: "Min coolant temp", + tooltip: "Events below this coolant temperature are rejected (channel units).", + kind: TableParamKind::Float { + min: -40.0, + max: 400.0, + speed: 1.0, + }, + }, + ] + } + + fn get_config(&self) -> AnalyzerConfig { + let mut p = HashMap::new(); + p.insert("profile".into(), self.profile.as_str().to_string()); + p.insert("min_step_pct".into(), self.min_step_pct.to_string()); + p.insert("step_window_ms".into(), self.step_window_ms.to_string()); + p.insert( + "min_event_spacing_ms".into(), + self.min_event_spacing_ms.to_string(), + ); + p.insert("response_k".into(), self.response_k.to_string()); + p.insert( + "min_response_delta".into(), + self.min_response_delta.to_string(), + ); + p.insert( + "response_timeout_ms".into(), + self.response_timeout_ms.to_string(), + ); + p.insert("pw_floor".into(), self.pw_floor.to_string()); + p.insert( + "injector_deadtime_ms".into(), + self.injector_deadtime_ms.to_string(), + ); + p.insert("steady_rpm_band".into(), self.steady_rpm_band.to_string()); + p.insert("steady_map_band".into(), self.steady_map_band.to_string()); + p.insert("steady_tps_band".into(), self.steady_tps_band.to_string()); + p.insert("closed_loop_band".into(), self.closed_loop_band.to_string()); + p.insert("min_coolant_temp".into(), self.min_coolant_temp.to_string()); + AnalyzerConfig { + id: ID.to_string(), + name: self.name().to_string(), + parameters: p, + } + } + + fn set_config(&mut self, config: &AnalyzerConfig) { + if let Some(p) = config + .parameters + .get("profile") + .and_then(|s| Profile::parse(s)) + && p != self.profile + { + self.apply_profile(p); + } + self.min_step_pct = param_f64(config, "min_step_pct", self.min_step_pct); + self.step_window_ms = param_f64(config, "step_window_ms", self.step_window_ms); + self.min_event_spacing_ms = + param_f64(config, "min_event_spacing_ms", self.min_event_spacing_ms); + self.response_k = param_f64(config, "response_k", self.response_k); + self.min_response_delta = param_f64(config, "min_response_delta", self.min_response_delta); + self.response_timeout_ms = + param_f64(config, "response_timeout_ms", self.response_timeout_ms); + self.pw_floor = param_f64(config, "pw_floor", self.pw_floor); + self.injector_deadtime_ms = + param_f64(config, "injector_deadtime_ms", self.injector_deadtime_ms); + self.steady_rpm_band = param_f64(config, "steady_rpm_band", self.steady_rpm_band); + self.steady_map_band = param_f64(config, "steady_map_band", self.steady_map_band); + self.steady_tps_band = param_f64(config, "steady_tps_band", self.steady_tps_band); + self.closed_loop_band = param_f64(config, "closed_loop_band", self.closed_loop_band); + self.min_coolant_temp = param_f64(config, "min_coolant_temp", self.min_coolant_temp); + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +#[cfg(test)] +mod tests { + use super::super::synthetic::{SyntheticLog, Xorshift}; + use super::*; + + fn mapping() -> ChannelMapping { + let mut m = ChannelMapping::default(); + m.set(ChannelRole::Rpm, Some("RPM".into())); + m.set(ChannelRole::Map, Some("MAP".into())); + m.set(ChannelRole::PulseWidth, Some("PW".into())); + m.set(ChannelRole::Lambda, Some("Lambda".into())); + m.load_kind = LoadKind::Map; + m + } + + fn axes() -> (AxisSpec, AxisSpec) { + ( + AxisSpec::new("RPM", "", vec![1000.0, 2000.0, 3000.0, 4000.0]), + AxisSpec::new("MAP", "kPa", vec![20.0, 60.0, 100.0]), + ) + } + + /// A log with steady RPM/MAP whose PW toggles between two levels every + /// 3 s (alternating rising / falling 10 % steps), each followed by a + /// first-order lambda move to the new level after `delay_s`. The lambda + /// response depth is `depth` (0.05 for a 10 % PW step at stoich). + fn step_log_depth( + rate_hz: f64, + update_hz: f64, + delay_s: f64, + tau_s: f64, + sigma: f64, + steps: usize, + depth: f64, + ) -> SyntheticLog { + let duration = 3.0 * steps as f64 + 2.0; + let mut log = SyntheticLog::new(rate_hz, duration); + let n = log.times.len(); + let mut rng = Xorshift::new(42); + let mut pw = vec![2.0; n]; + let mut lambda_true = vec![1.0; n]; + let level = |s: usize| if s.is_multiple_of(2) { 2.2 } else { 2.0 }; + let lambda_level = |s: usize| { + if s.is_multiple_of(2) { + 1.0 - depth + } else { + 1.0 + } + }; + for i in 0..n { + let t = log.times[i]; + // Most recent step at or before t. + let s = ((t - 2.0) / 3.0).floor(); + if s < 0.0 { + continue; + } + let s = (s as usize).min(steps - 1); + let t0 = 2.0 + 3.0 * s as f64; + pw[i] = level(s); + let prev = if s == 0 { 1.0 } else { lambda_level(s - 1) }; + let x = t - t0 - delay_s; + lambda_true[i] = if x < 0.0 { + prev + } else { + prev + (lambda_level(s) - prev) * (1.0 - (-x / tau_s).exp()) + }; + } + let lambda = log.sample_and_hold(&lambda_true, update_hz, sigma, &mut rng); + log.add("RPM", vec![2500.0; n]); + log.add("MAP", vec![50.0; n]); + log.add("PW", pw); + log.add("Lambda", lambda); + log + } + + fn step_log( + rate_hz: f64, + update_hz: f64, + delay_s: f64, + tau_s: f64, + sigma: f64, + steps: usize, + ) -> SyntheticLog { + step_log_depth(rate_hz, update_hz, delay_s, tau_s, sigma, steps, 0.05) + } + + fn run(log: &SyntheticLog, gen_: &LambdaDelayGenerator) -> (Vec, RunReport) { + gen_.analyze( + &log.log, + "synthetic", + &mapping(), + &axes(), + &GeneratorContext::default(), + ) + .expect("analysis runs") + } + + #[test] + fn recovers_known_delay_across_rates_and_noise() { + let gen_ = LambdaDelayGenerator::default(); + for &delay in &[0.06, 0.12, 0.25, 0.5] { + for &tau in &[0.03, 0.08] { + for &rate in &[10.0, 20.0, 50.0, 100.0] { + for &(update, sigma) in &[ + (rate, 0.0), + (rate, 0.005), + (10.0_f64.min(rate), 0.005), + (rate, 0.02), + ] { + // A 3 sigma threshold needs a response well above + // the noise; real widebands sit near 0.005 lambda. + let depth = if sigma > 0.01 { 0.15 } else { 0.05 }; + let log = step_log_depth(rate, update, delay, tau, sigma, 6, depth); + let (events, report) = run(&log, &gen_); + assert!( + report.accepted >= 4, + "rate {rate} update {update} sigma {sigma} delay {delay} tau {tau}: {}", + report.summary() + ); + let interval = 1.0 / update.min(rate); + // The dead time is the first crossing of the noise + // threshold, so on a first-order response it sits a + // little after the true onset (bias) and jitters with + // the noise over the local slope. + let thr = (3.0 * sigma).max(0.005); + let bias = tau * (1.0 / (1.0 - thr / depth)).ln(); + let jitter = 3.0 * sigma * tau / (depth * (1.0 - thr / depth)); + let tol = (0.5 * interval).max(0.01) + bias + jitter; + for e in events.iter().filter(|e| e.accepted()) { + let err = (e.value(0) / 1000.0 - delay).abs(); + assert!( + err <= tol, + "rate {rate} update {update} sigma {sigma} delay {delay} tau {tau}: measured {} ms, expected {} ms (tol {} ms)", + e.value(0), + delay * 1000.0, + tol * 1000.0 + ); + assert!(e.value(1).is_nan() || e.value(1) >= e.value(0)); + assert!(e.value(2) > 0.0); + } + } + } + } + } + } + + #[test] + fn afr_input_matches_lambda_input() { + let gen_ = LambdaDelayGenerator::default(); + let log = step_log(50.0, 50.0, 0.12, 0.05, 0.003, 4); + let (lambda_events, _) = run(&log, &gen_); + let mut afr_log = log.clone(); + afr_log.scale("Lambda", 14.7); + let (afr_events, _) = run(&afr_log, &gen_); + assert_eq!(lambda_events.len(), afr_events.len()); + for (a, b) in lambda_events.iter().zip(afr_events.iter()) { + assert_eq!(a.reject, b.reject); + if a.accepted() { + assert!( + (a.value(0) - b.value(0)).abs() < 1.0, + "{} vs {}", + a.value(0), + b.value(0) + ); + } + } + } + + #[test] + fn refuses_logs_slower_than_4hz() { + let log = step_log(3.0, 3.0, 0.12, 0.05, 0.0, 2); + let err = LambdaDelayGenerator::default() + .analyze( + &log.log, + "slow", + &mapping(), + &axes(), + &GeneratorContext::default(), + ) + .unwrap_err(); + assert!(matches!(err, AnalysisError::InvalidParameter(_))); + } + + #[test] + fn slow_lambda_update_emits_warning() { + let log = step_log(100.0, 10.0, 0.12, 0.05, 0.0, 2); + let (_, report) = run(&log, &LambdaDelayGenerator::default()); + assert!( + report.warnings.iter().any(|w| w.contains("10 Hz")), + "{:?}", + report.warnings + ); + } + + #[test] + fn rpm_ramp_is_unsteady() { + let mut log = step_log(50.0, 50.0, 0.12, 0.05, 0.0, 3); + let n = log.times.len(); + let ramp: Vec = (0..n).map(|i| 1500.0 + i as f64 * 10.0).collect(); + log.replace("RPM", ramp); + let (_, report) = run(&log, &LambdaDelayGenerator::default()); + assert_eq!(report.accepted, 0); + assert!( + report + .rejected + .get(&RejectReason::Unsteady) + .copied() + .unwrap_or(0) + >= 3, + "{}", + report.summary() + ); + } + + #[test] + fn flat_lambda_is_invalid_and_no_response_is_reported() { + let mut log = step_log(50.0, 50.0, 0.12, 0.05, 0.0, 3); + let n = log.times.len(); + log.replace("Lambda", vec![1.0; n]); + let (_, report) = run(&log, &LambdaDelayGenerator::default()); + assert_eq!(report.accepted, 0); + assert_eq!( + report.rejected.get(&RejectReason::InvalidSamples).copied(), + Some(3), + "{}", + report.summary() + ); + + // Sensor that moves (noise) but never responds to the step. + let mut log = step_log(50.0, 50.0, 0.12, 0.05, 0.0, 3); + let mut rng = Xorshift::new(7); + let noisy: Vec = (0..n).map(|_| 1.0 + rng.normal() * 0.001).collect(); + log.replace("Lambda", noisy); + let (_, report) = run(&log, &LambdaDelayGenerator::default()); + assert_eq!(report.accepted, 0); + assert!( + report.rejected.contains_key(&RejectReason::NoResponse), + "{}", + report.summary() + ); + } + + #[test] + fn sentinels_reject_as_invalid_samples() { + let mut log = step_log(50.0, 50.0, 0.12, 0.05, 0.003, 3); + let mut lambda = log.column("Lambda"); + // Blank out 30 % of the samples around the second step with Haltech's sentinel. + for (i, v) in lambda.iter_mut().enumerate() { + let t = log.times[i]; + if (4.5..6.5).contains(&t) && i % 3 == 0 { + *v = -2147483637.0; + } + } + log.replace("Lambda", lambda); + let (events, _) = run(&log, &LambdaDelayGenerator::default()); + let second = events.iter().find(|e| (e.time - 5.0).abs() < 0.1).unwrap(); + assert_eq!(second.reject, Some(RejectReason::InvalidSamples)); + assert!(events.iter().filter(|e| e.accepted()).count() >= 2); + } + + #[test] + fn reversed_direction_is_rejected() { + // Lambda goes *lean* on a rising PW step: wrong direction. + let mut log = step_log(50.0, 50.0, 0.12, 0.05, 0.0, 2); + let lambda: Vec = log.column("Lambda").iter().map(|v| 2.0 - v).collect(); + log.replace("Lambda", lambda); + let (_, report) = run(&log, &LambdaDelayGenerator::default()); + assert_eq!(report.accepted, 0); + assert_eq!( + report.rejected.get(&RejectReason::WrongDirection).copied(), + Some(2), + "{}", + report.summary() + ); + } + + #[test] + fn dead_time_adjusted_step_passes_only_with_deadtime_param() { + // 2.0 ms PW with 1.0 ms dead time: a 7 % bump in the effective 1.0 ms + // part reads as 3.5 % on the raw channel. + let mut log = step_log(50.0, 50.0, 0.12, 0.05, 0.0, 3); + let pw: Vec = log + .column("PW") + .iter() + .map(|v| 1.0 + (v - 2.0) * 0.7 + 1.0) + .collect(); + log.replace("PW", pw); + let (_, report) = run(&log, &LambdaDelayGenerator::default()); + assert_eq!(report.candidates, 0); + let mut gen_ = LambdaDelayGenerator::default(); + let mut cfg = gen_.get_config(); + cfg.parameters + .insert("injector_deadtime_ms".into(), "1.0".into()); + gen_.set_config(&cfg); + let (_, report) = run(&log, &gen_); + assert!(report.accepted >= 2, "{}", report.summary()); + } + + #[test] + fn overlapping_steps_reject_and_relaxed_profile_widens_spacing() { + let mut log = step_log(50.0, 50.0, 0.12, 0.05, 0.0, 2); + // Add a second step 0.5 s after the first. + let mut pw = log.column("PW"); + for (i, v) in pw.iter_mut().enumerate() { + let t = log.times[i]; + if (2.5..5.0).contains(&t) { + *v = 2.6; + } + } + log.replace("PW", pw); + let (_, strict) = run(&log, &LambdaDelayGenerator::default()); + assert!( + strict.rejected.contains_key(&RejectReason::Overlap), + "{}", + strict.summary() + ); + let mut gen_ = LambdaDelayGenerator::default(); + gen_.apply_profile(Profile::Relaxed); + assert_eq!(gen_.min_event_spacing_ms, 400.0); + assert_eq!(gen_.steady_rpm_band, 400.0); + let (_, relaxed) = run(&log, &gen_); + assert!( + relaxed + .rejected + .get(&RejectReason::Overlap) + .copied() + .unwrap_or(0) + < strict.rejected[&RejectReason::Overlap] + ); + } + + #[test] + fn optional_gates_reject() { + let base = step_log(50.0, 50.0, 0.12, 0.05, 0.0, 3); + let n = base.times.len(); + let flag: Vec = (0..n) + .map(|i| { + if (4.5..6.0).contains(&base.times[i]) { + 1.0 + } else { + 0.0 + } + }) + .collect(); + + let mut log = base.clone(); + log.add("DFCO", flag.clone()); + let mut m = mapping(); + m.set(ChannelRole::FuelCut, Some("DFCO".into())); + let (events, _) = LambdaDelayGenerator::default() + .analyze(&log.log, "s", &m, &axes(), &GeneratorContext::default()) + .unwrap(); + assert_eq!(events[1].reject, Some(RejectReason::FuelCut)); + assert!(events[0].accepted()); + + let mut log = base.clone(); + log.add("Clutch", flag.clone()); + let mut m = mapping(); + m.set(ChannelRole::Clutch, Some("Clutch".into())); + let (events, _) = LambdaDelayGenerator::default() + .analyze(&log.log, "s", &m, &axes(), &GeneratorContext::default()) + .unwrap(); + assert_eq!(events[1].reject, Some(RejectReason::Clutch)); + + let mut log = base.clone(); + log.add("CLT", vec![40.0; n]); + let mut m = mapping(); + m.set(ChannelRole::CoolantTemp, Some("CLT".into())); + let (_, report) = LambdaDelayGenerator::default() + .analyze(&log.log, "s", &m, &axes(), &GeneratorContext::default()) + .unwrap(); + assert_eq!( + report.rejected.get(&RejectReason::ColdEngine).copied(), + Some(3) + ); + + let mut log = base.clone(); + let stft: Vec = flag.iter().map(|f| f * 5.0).collect(); + log.add("STFT", stft); + let mut m = mapping(); + m.set(ChannelRole::ClosedLoopState, Some("STFT".into())); + let (events, report) = LambdaDelayGenerator::default() + .analyze(&log.log, "s", &m, &axes(), &GeneratorContext::default()) + .unwrap(); + assert!(!report.warnings.iter().any(|w| w.contains("open loop"))); + // The trim steps 0.5 s before the 5.0 s event, outside the ±150 ms + // window, so that event still passes; a move at the step rejects it. + assert!(events[1].accepted(), "{:?}", events[1].reject); + let mut log = base.clone(); + let stft: Vec = (0..n) + .map(|i| if base.times[i] >= 5.05 { 5.0 } else { 0.0 }) + .collect(); + log.add("STFT", stft); + let (events, _) = LambdaDelayGenerator::default() + .analyze(&log.log, "s", &m, &axes(), &GeneratorContext::default()) + .unwrap(); + assert_eq!(events[1].reject, Some(RejectReason::ClosedLoopActive)); + } + + #[test] + fn out_of_axis_and_low_pw() { + let log = step_log(50.0, 50.0, 0.12, 0.05, 0.0, 2); + let narrow = ( + AxisSpec::new("RPM", "", vec![3000.0, 4000.0]), + AxisSpec::new("MAP", "kPa", vec![20.0, 100.0]), + ); + let (_, report) = LambdaDelayGenerator::default() + .analyze( + &log.log, + "s", + &mapping(), + &narrow, + &GeneratorContext::default(), + ) + .unwrap(); + assert_eq!( + report.rejected.get(&RejectReason::OutOfAxis).copied(), + Some(2) + ); + + let gen_ = LambdaDelayGenerator { + pw_floor: 5.0, + ..Default::default() + }; + let (_, report) = run(&log, &gen_); + assert_eq!(report.rejected.get(&RejectReason::LowPw).copied(), Some(2)); + } + + #[test] + fn ragged_log_is_an_error_not_a_panic() { + let mut log = step_log(20.0, 20.0, 0.12, 0.05, 0.0, 2); + log.log.data[3].pop(); + let err = LambdaDelayGenerator::default() + .analyze( + &log.log, + "ragged", + &mapping(), + &axes(), + &GeneratorContext::default(), + ) + .unwrap_err(); + assert!(matches!(err, AnalysisError::ComputationError(_)), "{err}"); + } + + #[test] + fn config_round_trip() { + let mut gen_ = LambdaDelayGenerator::default(); + let mut cfg = gen_.get_config(); + cfg.parameters.insert("min_step_pct".into(), "12".into()); + cfg.parameters.insert("profile".into(), "relaxed".into()); + cfg.parameters.insert("response_k".into(), "garbage".into()); + gen_.set_config(&cfg); + assert_eq!(gen_.min_step_pct, 12.0); + assert_eq!(gen_.profile, Profile::Relaxed); + assert_eq!(gen_.response_k, 3.0); + assert_eq!(gen_.get_config().parameters["profile"], "relaxed"); + for p in gen_.params() { + assert!( + cfg.parameters.contains_key(p.key), + "param {} missing from config", + p.key + ); + } + } + + #[test] + fn default_axes_follow_data() { + let log = step_log(20.0, 20.0, 0.12, 0.05, 0.0, 1); + let (x, y) = LambdaDelayGenerator::default().default_axes(&log.log, &mapping()); + assert!(x.is_valid() && y.is_valid()); + assert_eq!(x.label, "RPM"); + assert_eq!(y.unit, "kPa"); + } +} diff --git a/src/analysis/tables/mod.rs b/src/analysis/tables/mod.rs new file mode 100644 index 00000000..3b2b84e4 --- /dev/null +++ b/src/analysis/tables/mod.rs @@ -0,0 +1,510 @@ +//! Table generators: mine events out of one or more logs into a 2-D tuning +//! table (RPM × load, RPM × throttle rate, ...). +//! +//! The existing [`Analyzer`](super::Analyzer) trait returns one value per log +//! timestamp, which a table does not fit, so generators implement the sibling +//! [`TableAnalyzer`] trait. Every generator produces a list of +//! [`TableEvent`]s, accepted or rejected with a [`RejectReason`], plus a +//! [`RunReport`]; a [`TableAccumulator`] folds the events of several logs +//! into one grid and re-bins on demand for whichever measure the user wants +//! to look at. +//! +//! Cell values are medians with MAD spread and a [`Confidence`] tier. Empty +//! and low-confidence cells are never interpolated: a fabricated number in a +//! tuning table is worse than a gap. + +pub mod accel_enrich; +pub mod binning; +pub mod channel_map; +pub mod events; +pub mod export; +pub mod lambda_delay; +pub mod stats; +pub mod synthetic; + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +pub use binning::{AxisSpec, CellStats, Confidence, ConfidenceRule, TableGrid}; +pub use channel_map::{ + ChannelMapping, ChannelRole, LoadKind, RoleSpec, Suggestion, suggest_mapping, +}; + +use super::{AnalysisError, AnalyzerConfig}; +use crate::parsers::types::Log; + +/// Why a detected event was not used. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub enum RejectReason { + /// RPM or load moved too much across the measurement window. + Unsteady, + /// Another event started too close for the responses to be attributed. + Overlap, + /// The sensor never moved past the response threshold in time. + NoResponse, + /// The sensor moved past the threshold the wrong way first. + WrongDirection, + /// Fuel cut was active around the event. + FuelCut, + /// Pulse width below the floor (decel / fuel-cut region). + LowPw, + /// The closed-loop correction moved during the event. + ClosedLoopActive, + /// Coolant below the minimum temperature. + ColdEngine, + /// Clutch was in. + Clutch, + /// Too many masked / sentinel samples, or a flat sensor. + InvalidSamples, + /// The event's RPM / load fell outside the table axes. + OutOfAxis, + /// RPM collapsed or jumped mid-window (gear change). + GearShift, + /// The ECU's accel-enrichment activity did not match the table kind. + AeKindMismatch, +} + +impl RejectReason { + pub fn label(self) -> &'static str { + match self { + Self::Unsteady => "unsteady", + Self::Overlap => "overlap", + Self::NoResponse => "no response", + Self::WrongDirection => "wrong direction", + Self::FuelCut => "fuel cut", + Self::LowPw => "low pulse width", + Self::ClosedLoopActive => "closed loop active", + Self::ColdEngine => "cold engine", + Self::Clutch => "clutch", + Self::InvalidSamples => "invalid samples", + Self::OutOfAxis => "out of axis", + Self::GearShift => "gear shift", + Self::AeKindMismatch => "AE kind mismatch", + } + } +} + +/// A measure a generator records per event; index-aligned with +/// [`TableEvent::values`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MeasureSpec { + pub key: &'static str, + pub label: &'static str, + pub unit: &'static str, + pub decimals: usize, +} + +/// One detected event, accepted or rejected. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TableEvent { + /// Per-load nonce of the log the event came from (never a file index, + /// which shifts when tabs close, and never a bare file name). + pub log_id: u64, + pub log_name: String, + /// Event start in log time (seconds). + pub time: f64, + /// RPM at the event (X axis). + pub rpm: f64, + /// Y-axis value: load for lambda delay, peak throttle rate for accel + /// enrichment. + pub axis_value: f64, + /// Measured values, aligned with the generator's [`MeasureSpec`] list. + /// `NaN` where a measure could not be taken. + pub values: Vec, + /// 0..1 per-event quality (informational; cells use plain medians). + pub quality: f32, + pub reject: Option, + /// Free-form diagnostics for the inspector (e.g. which delay was used). + pub note: String, +} + +impl TableEvent { + pub fn accepted(&self) -> bool { + self.reject.is_none() + } + + pub fn value(&self, measure: usize) -> f64 { + self.values.get(measure).copied().unwrap_or(f64::NAN) + } +} + +/// Outcome of running a generator over one log. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct RunReport { + pub log_name: String, + /// Events detected before gating. + pub candidates: usize, + pub accepted: usize, + pub rejected: BTreeMap, + pub warnings: Vec, + pub computation_time_ms: u64, +} + +impl RunReport { + pub fn total_rejected(&self) -> usize { + self.rejected.values().sum() + } + + /// `"41 events found · 39 rejected: 30 unsteady, 9 no response"`. + pub fn summary(&self) -> String { + let mut s = format!("{} events found", self.candidates); + let rejected = self.total_rejected(); + if rejected > 0 { + let mut parts: Vec<(usize, RejectReason)> = + self.rejected.iter().map(|(r, n)| (*n, *r)).collect(); + parts.sort_by_key(|p| std::cmp::Reverse(p.0)); + let detail: Vec = parts + .iter() + .map(|(n, r)| format!("{} {}", n, r.label())) + .collect(); + s.push_str(&format!(" · {} rejected: {}", rejected, detail.join(", "))); + } else if self.candidates > 0 { + s.push_str(" · none rejected"); + } + s + } + + pub fn from_events(log_name: &str, events: &[TableEvent]) -> Self { + let mut report = Self { + log_name: log_name.to_string(), + candidates: events.len(), + ..Default::default() + }; + for e in events { + match e.reject { + None => report.accepted += 1, + Some(r) => *report.rejected.entry(r).or_insert(0) += 1, + } + } + report + } +} + +/// A user-tunable parameter, for the UI's parameter grid. +#[derive(Clone, Debug, PartialEq)] +pub struct TableParam { + pub key: &'static str, + pub label: &'static str, + pub tooltip: &'static str, + pub kind: TableParamKind, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum TableParamKind { + Float { min: f64, max: f64, speed: f64 }, + Integer { min: i64, max: i64 }, + Choice(&'static [&'static str]), +} + +/// Extra inputs a generator may use, beyond the log itself. +#[derive(Clone, Copy, Debug, Default)] +pub struct GeneratorContext<'a> { + /// A lambda-delay grid (RPM × load, ms) from the same session. The accel + /// enrichment generator shifts its AFR window by the matching cell. + pub delay_table: Option<&'a TableGrid>, +} + +/// A generator that mines events from a log into table events. +pub trait TableAnalyzer: Send + Sync { + fn id(&self) -> &'static str; + fn name(&self) -> &'static str; + fn description(&self) -> &'static str; + + /// Channel roles this generator uses (required and optional). + fn roles(&self) -> Vec; + + /// Measures recorded per event. Index 0 is the primary table value. + fn measures(&self) -> Vec; + + /// Data-driven default axes: `(x = RPM, y)`. + fn default_axes(&self, log: &Log, mapping: &ChannelMapping) -> (AxisSpec, AxisSpec); + + /// Detect and measure events in one log. + fn analyze( + &self, + log: &Log, + log_name: &str, + mapping: &ChannelMapping, + axes: &(AxisSpec, AxisSpec), + ctx: &GeneratorContext<'_>, + ) -> Result<(Vec, RunReport), AnalysisError>; + + fn params(&self) -> Vec; + fn get_config(&self) -> AnalyzerConfig; + fn set_config(&mut self, config: &AnalyzerConfig); + fn clone_box(&self) -> Box; +} + +impl Clone for Box { + fn clone(&self) -> Self { + self.clone_box() + } +} + +/// The generators that ship with UltraLog. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +pub enum GeneratorKind { + #[default] + LambdaDelay, + AccelEnrich, +} + +impl GeneratorKind { + pub const ALL: [GeneratorKind; 2] = [GeneratorKind::LambdaDelay, GeneratorKind::AccelEnrich]; + + pub fn id(self) -> &'static str { + match self { + Self::LambdaDelay => lambda_delay::ID, + Self::AccelEnrich => accel_enrich::ID, + } + } + + pub fn from_id(id: &str) -> Option { + Self::ALL.into_iter().find(|k| k.id() == id) + } + + pub fn create(self) -> Box { + match self { + Self::LambdaDelay => Box::new(lambda_delay::LambdaDelayGenerator::default()), + Self::AccelEnrich => Box::new(accel_enrich::AccelEnrichGenerator::default()), + } + } +} + +/// One log folded into an accumulator. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AccumulatedLog { + pub id: u64, + pub name: String, + pub report: RunReport, +} + +/// Events from one or more logs, re-binnable into a grid per measure. +#[derive(Clone, Debug, PartialEq)] +pub struct TableAccumulator { + pub generator: GeneratorKind, + pub axes: (AxisSpec, AxisSpec), + pub measures: Vec, + pub events: Vec, + pub logs: Vec, + pub rule: ConfidenceRule, +} + +impl TableAccumulator { + pub fn new( + generator: GeneratorKind, + axes: (AxisSpec, AxisSpec), + measures: Vec, + ) -> Self { + Self { + generator, + axes, + measures, + events: Vec::new(), + logs: Vec::new(), + rule: ConfidenceRule::default(), + } + } + + /// Fold one log's events in. A log with the same id replaces its earlier + /// contribution. + pub fn add_log(&mut self, id: u64, name: &str, events: Vec, report: RunReport) { + self.remove_log(id); + self.events.extend(events); + self.logs.push(AccumulatedLog { + id, + name: name.to_string(), + report, + }); + } + + pub fn remove_log(&mut self, id: u64) { + self.events.retain(|e| e.log_id != id); + self.logs.retain(|l| l.id != id); + } + + pub fn contains_log(&self, id: u64) -> bool { + self.logs.iter().any(|l| l.id == id) + } + + pub fn reset(&mut self) { + self.events.clear(); + self.logs.clear(); + } + + pub fn is_empty(&self) -> bool { + self.events.is_empty() + } + + pub fn accepted(&self) -> impl Iterator { + self.events.iter().filter(|e| e.accepted()) + } + + pub fn accepted_count(&self) -> usize { + self.accepted().count() + } + + /// Bin the accepted events for one measure. + pub fn grid(&self, measure: usize) -> TableGrid { + TableGrid::build( + self.axes.0.clone(), + self.axes.1.clone(), + self.accepted() + .map(|e| (e.rpm, e.axis_value, e.value(measure))), + self.rule, + ) + } + + /// Accepted events that fall in a given cell. + pub fn events_in_cell(&self, row: usize, col: usize) -> Vec<&TableEvent> { + self.accepted() + .filter(|e| { + self.axes.0.bin_index(e.rpm) == Some(col) + && self.axes.1.bin_index(e.axis_value) == Some(row) + }) + .collect() + } +} + +/// Fetch a mapped channel's column, checking it is aligned with `times`. +/// +/// `Log::get_channel_data` is a `filter_map` that drops rows missing the +/// column, so a ragged log yields a column *shorter* than `times`; any +/// index-based math on that pair would be misaligned, so it is refused here. +pub(crate) fn mapped_column( + log: &Log, + mapping: &ChannelMapping, + role: ChannelRole, +) -> Result>, AnalysisError> { + let Some(name) = mapping.get(role) else { + return Ok(None); + }; + let idx = log + .channels + .iter() + .position(|c| c.name() == name) + .ok_or_else(|| AnalysisError::MissingChannel(name.to_string()))?; + let data = log.get_channel_data(idx); + if data.len() != log.times.len() { + return Err(AnalysisError::ComputationError(format!( + "channel '{}' has {} samples but the log has {} timestamps (ragged log)", + name, + data.len(), + log.times.len() + ))); + } + Ok(Some(data)) +} + +pub(crate) fn required_column( + log: &Log, + mapping: &ChannelMapping, + role: ChannelRole, +) -> Result, AnalysisError> { + mapped_column(log, mapping, role)? + .ok_or_else(|| AnalysisError::MissingChannel(role.label().to_string())) +} + +/// Parse a float parameter, keeping the current value on a bad string. +pub(crate) fn param_f64(config: &AnalyzerConfig, key: &str, current: f64) -> f64 { + config + .parameters + .get(key) + .and_then(|v| v.trim().parse::().ok()) + .filter(|v| v.is_finite()) + .unwrap_or(current) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn event(log_id: u64, rpm: f64, load: f64, v: f64, reject: Option) -> TableEvent { + TableEvent { + log_id, + log_name: format!("log{log_id}"), + time: 0.0, + rpm, + axis_value: load, + values: vec![v], + quality: 1.0, + reject, + note: String::new(), + } + } + + fn axes() -> (AxisSpec, AxisSpec) { + ( + AxisSpec::new("RPM", "", vec![1000.0, 2000.0, 3000.0]), + AxisSpec::new("MAP", "kPa", vec![0.0, 50.0, 100.0]), + ) + } + + #[test] + fn report_summary_orders_by_count() { + let events = vec![ + event(1, 1500.0, 25.0, 100.0, None), + event(1, 1500.0, 25.0, 100.0, Some(RejectReason::Unsteady)), + event(1, 1500.0, 25.0, 100.0, Some(RejectReason::Unsteady)), + event(1, 1500.0, 25.0, 100.0, Some(RejectReason::NoResponse)), + ]; + let r = RunReport::from_events("a", &events); + assert_eq!(r.candidates, 4); + assert_eq!(r.accepted, 1); + assert_eq!( + r.summary(), + "4 events found · 3 rejected: 2 unsteady, 1 no response" + ); + let none = RunReport::from_events("a", &events[..1]); + assert_eq!(none.summary(), "1 events found · none rejected"); + } + + #[test] + fn accumulate_two_logs_then_remove_one_equals_single_log() { + let measures = vec![MeasureSpec { + key: "v", + label: "v", + unit: "", + decimals: 0, + }]; + let mut acc = TableAccumulator::new(GeneratorKind::LambdaDelay, axes(), measures.clone()); + let a = vec![ + event(1, 1500.0, 25.0, 100.0, None), + event(1, 1500.0, 25.0, 120.0, None), + ]; + let b = vec![ + event(2, 1500.0, 25.0, 500.0, None), + event(2, 2500.0, 75.0, 50.0, Some(RejectReason::Overlap)), + ]; + acc.add_log(1, "a", a.clone(), RunReport::from_events("a", &a)); + acc.add_log(2, "b", b.clone(), RunReport::from_events("b", &b)); + assert_eq!(acc.accepted_count(), 3); + assert_eq!(acc.grid(0).cell(0, 0).unwrap().median, 120.0); + assert_eq!(acc.events_in_cell(0, 0).len(), 3); + + let mut single = TableAccumulator::new(GeneratorKind::LambdaDelay, axes(), measures); + single.add_log(1, "a", a.clone(), RunReport::from_events("a", &a)); + acc.remove_log(2); + assert_eq!(acc, single); + assert!(!acc.contains_log(2)); + + // Re-adding the same id replaces rather than duplicates. + acc.add_log(1, "a", a.clone(), RunReport::from_events("a", &a)); + assert_eq!(acc.accepted_count(), 2); + acc.reset(); + assert!(acc.is_empty()); + } + + #[test] + fn generator_kind_round_trip() { + for k in GeneratorKind::ALL { + assert_eq!(GeneratorKind::from_id(k.id()), Some(k)); + let g = k.create(); + assert_eq!(g.id(), k.id()); + assert!(!g.measures().is_empty()); + assert!(g.roles().iter().any(|r| r.required)); + } + assert_eq!(GeneratorKind::from_id("nope"), None); + } +} diff --git a/src/analysis/tables/stats.rs b/src/analysis/tables/stats.rs new file mode 100644 index 00000000..76d20e2e --- /dev/null +++ b/src/analysis/tables/stats.rs @@ -0,0 +1,198 @@ +//! Robust statistics and sample-timing helpers shared by the table generators. +//! +//! Everything here is order-statistic based (median / MAD) rather than +//! mean / standard deviation: a handful of mis-attributed events in a tuning +//! table cell must not drag the cell value, and a single sentinel sample must +//! not blow up a noise estimate. + +/// Median of a slice. Non-finite values are ignored. Returns `None` when no +/// finite value is present. +pub fn median(values: &[f64]) -> Option { + let mut sorted: Vec = values.iter().copied().filter(|v| v.is_finite()).collect(); + if sorted.is_empty() { + return None; + } + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let n = sorted.len(); + Some(if n.is_multiple_of(2) { + (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0 + } else { + sorted[n / 2] + }) +} + +/// Median absolute deviation around the median. `None` when the slice has no +/// finite value. +pub fn mad(values: &[f64]) -> Option { + let m = median(values)?; + let deviations: Vec = values + .iter() + .copied() + .filter(|v| v.is_finite()) + .map(|v| (v - m).abs()) + .collect(); + median(&deviations) +} + +/// Percentile in `[0, 100]` by nearest-rank on the sorted finite values. +pub fn percentile(values: &[f64], pct: f64) -> Option { + let mut sorted: Vec = values.iter().copied().filter(|v| v.is_finite()).collect(); + if sorted.is_empty() { + return None; + } + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let pct = pct.clamp(0.0, 100.0); + let rank = ((pct / 100.0) * (sorted.len() as f64 - 1.0)).round() as usize; + Some(sorted[rank.min(sorted.len() - 1)]) +} + +/// Median spacing between consecutive timestamps. `None` for fewer than two +/// samples or when every spacing is zero. +pub fn median_interval(times: &[f64]) -> Option { + if times.len() < 2 { + return None; + } + let deltas: Vec = times.windows(2).map(|w| w[1] - w[0]).collect(); + let m = median(&deltas)?; + if m > 0.0 { Some(m) } else { None } +} + +/// Indices at which `values` changes to a new finite value, starting with the +/// first finite sample. This is the sample-and-hold "update instant" list: a +/// CAN wideband logged at 10 Hz inside a 100 Hz log only changes value every +/// tenth sample, and any threshold-crossing or noise math that looked at raw +/// samples would be looking at nine copies of the same reading. +pub fn update_instants(values: &[f64]) -> Vec { + let mut out = Vec::new(); + let mut last: Option = None; + for (i, &v) in values.iter().enumerate() { + if !v.is_finite() { + continue; + } + match last { + Some(prev) if prev == v => {} + _ => { + out.push(i); + last = Some(v); + } + } + } + out +} + +/// Median spacing between distinct-value updates of a channel, i.e. the rate +/// the sensor is actually delivering new readings at, independent of the log +/// rate. `None` when the channel never changes. +pub fn effective_update_interval(times: &[f64], values: &[f64]) -> Option { + let instants = update_instants(values); + if instants.len() < 2 { + return None; + } + let deltas: Vec = instants + .windows(2) + .map(|w| times[w[1]] - times[w[0]]) + .collect(); + let m = median(&deltas)?; + if m > 0.0 { Some(m) } else { None } +} + +/// Robust noise estimate of a sample-and-hold signal. +/// +/// Takes the first differences between consecutive *update instants* (never +/// between raw samples, which are mostly exact repeats), and scales the MAD of +/// those differences to a Gaussian sigma: `1.4826 * MAD / sqrt(2)`. The +/// `sqrt(2)` corrects for differencing two independent samples. +/// +/// Returns `None` when there are fewer than three updates. +pub fn robust_sigma_diff(values: &[f64]) -> Option { + let instants = update_instants(values); + if instants.len() < 3 { + return None; + } + let diffs: Vec = instants + .windows(2) + .map(|w| values[w[1]] - values[w[0]]) + .collect(); + mad(&diffs).map(|m| 1.4826 * m / std::f64::consts::SQRT_2) +} + +/// Index of the first sample with `times[i] >= t` (binary search; `times` +/// must be non-decreasing). +pub fn index_at_or_after(times: &[f64], t: f64) -> usize { + times.partition_point(|&x| x < t) +} + +/// Half-open index range `[start, end)` covering `t0 <= times[i] < t1`. +pub fn index_range(times: &[f64], t0: f64, t1: f64) -> std::ops::Range { + let start = index_at_or_after(times, t0); + let end = index_at_or_after(times, t1); + start..end.max(start) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn median_and_mad_basic() { + assert_eq!(median(&[3.0, 1.0, 2.0]), Some(2.0)); + assert_eq!(median(&[4.0, 1.0, 2.0, 3.0]), Some(2.5)); + assert_eq!(median(&[]), None); + assert_eq!(median(&[f64::NAN, 5.0]), Some(5.0)); + assert_eq!(mad(&[1.0, 2.0, 3.0, 4.0, 100.0]), Some(1.0)); + } + + #[test] + fn percentile_bounds() { + let v: Vec = (0..=100).map(|i| i as f64).collect(); + assert_eq!(percentile(&v, 0.0), Some(0.0)); + assert_eq!(percentile(&v, 100.0), Some(100.0)); + assert_eq!(percentile(&v, 50.0), Some(50.0)); + assert_eq!(percentile(&[], 50.0), None); + } + + #[test] + fn update_instants_skip_holds() { + let v = [1.0, 1.0, 1.0, 2.0, 2.0, f64::NAN, 3.0, 3.0]; + assert_eq!(update_instants(&v), vec![0, 3, 6]); + } + + #[test] + fn effective_rate_is_independent_of_log_rate() { + // 100 Hz log, sensor updating every 10 samples. + let times: Vec = (0..200).map(|i| i as f64 * 0.01).collect(); + let values: Vec = (0..200).map(|i| (i / 10) as f64).collect(); + let dt = median_interval(×).unwrap(); + let u = effective_update_interval(×, &values).unwrap(); + assert!((dt - 0.01).abs() < 1e-9); + assert!((u - 0.1).abs() < 1e-9); + } + + #[test] + fn robust_sigma_ignores_holds() { + // Alternating +/-0.01 steps at every update; raw samples hold 5x. + // 101 groups give 100 differences, half +0.01 and half -0.01, so + // their median is 0 and the MAD is 0.01. + let mut values = Vec::new(); + for i in 0..101 { + let v = if i % 2 == 0 { 1.0 } else { 1.01 }; + for _ in 0..5 { + values.push(v); + } + } + let sigma = robust_sigma_diff(&values).unwrap(); + // MAD of |diff| = 0.01 -> 1.4826 * 0.01 / sqrt(2) + assert!((sigma - 1.4826 * 0.01 / std::f64::consts::SQRT_2).abs() < 1e-9); + assert_eq!(robust_sigma_diff(&[1.0; 50]), None); + } + + #[test] + fn index_helpers() { + let times = [0.0, 0.1, 0.2, 0.3, 0.4]; + assert_eq!(index_at_or_after(×, 0.15), 2); + assert_eq!(index_at_or_after(×, 0.2), 2); + assert_eq!(index_at_or_after(×, 9.0), 5); + assert_eq!(index_range(×, 0.1, 0.3), 1..3); + assert_eq!(index_range(×, 0.35, 0.2), 4..4); + } +} diff --git a/src/analysis/tables/synthetic.rs b/src/analysis/tables/synthetic.rs new file mode 100644 index 00000000..5f75c5fa --- /dev/null +++ b/src/analysis/tables/synthetic.rs @@ -0,0 +1,170 @@ +//! Synthetic log construction for table-generator tests. +//! +//! Builds a [`Log`] with named channels and known ground truth (delays, +//! excursion depths) so the generators can be tested for measurement +//! correctness rather than just for "runs without panicking". The +//! pseudo-random source is a small xorshift + Box-Muller pair so the crate +//! does not need a `rand` dev-dependency. + +use crate::parsers::speeduino::SpeeduinoChannel; +use crate::parsers::types::{Channel, Log, Value}; + +/// Deterministic xorshift64* generator. +#[derive(Clone, Debug)] +pub struct Xorshift { + state: u64, +} + +impl Xorshift { + pub fn new(seed: u64) -> Self { + Self { + state: seed.max(1) ^ 0x9E37_79B9_7F4A_7C15, + } + } + + pub fn next_u64(&mut self) -> u64 { + let mut x = self.state; + x ^= x >> 12; + x ^= x << 25; + x ^= x >> 27; + self.state = x; + x.wrapping_mul(0x2545_F491_4F6C_DD1D) + } + + /// Uniform in `(0, 1)`. + pub fn uniform(&mut self) -> f64 { + ((self.next_u64() >> 11) as f64 + 1.0) / ((1u64 << 53) as f64 + 2.0) + } + + /// Standard normal via Box-Muller. + pub fn normal(&mut self) -> f64 { + let u1 = self.uniform(); + let u2 = self.uniform(); + (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() + } +} + +/// A log under construction with a uniform time base. +#[derive(Clone, Debug)] +pub struct SyntheticLog { + pub times: Vec, + pub log: Log, +} + +impl SyntheticLog { + /// Uniform time base at `rate_hz` for `duration_s` seconds. + pub fn new(rate_hz: f64, duration_s: f64) -> Self { + let n = (duration_s * rate_hz).round() as usize; + let times: Vec = (0..n).map(|i| i as f64 / rate_hz).collect(); + let log = Log { + times: times.clone(), + data: vec![Vec::new(); n], + ..Default::default() + }; + Self { times, log } + } + + /// Append a channel. `values.len()` must equal the number of records. + pub fn add(&mut self, name: &str, values: Vec) { + assert_eq!(values.len(), self.times.len(), "channel {name} length"); + self.log.channels.push(Channel::Speeduino(SpeeduinoChannel { + name: name.to_string(), + unit: String::new(), + scale: 1.0, + transform: 0.0, + field_type: 0, + })); + for (row, v) in self.log.data.iter_mut().zip(values) { + row.push(Value::Float(v)); + } + } + + pub fn index_of(&self, name: &str) -> usize { + self.log + .channels + .iter() + .position(|c| c.name() == name) + .unwrap_or_else(|| panic!("no channel {name}")) + } + + pub fn column(&self, name: &str) -> Vec { + self.log.get_channel_data(self.index_of(name)) + } + + pub fn replace(&mut self, name: &str, values: Vec) { + let idx = self.index_of(name); + assert_eq!(values.len(), self.times.len()); + for (row, v) in self.log.data.iter_mut().zip(values) { + row[idx] = Value::Float(v); + } + } + + pub fn scale(&mut self, name: &str, factor: f64) { + let scaled: Vec = self.column(name).iter().map(|v| v * factor).collect(); + self.replace(name, scaled); + } + + /// Resample `truth` as a sample-and-hold sensor updating at `update_hz` + /// with Gaussian noise `sigma` added at each update. + pub fn sample_and_hold( + &self, + truth: &[f64], + update_hz: f64, + sigma: f64, + rng: &mut Xorshift, + ) -> Vec { + let interval = 1.0 / update_hz; + let mut out = Vec::with_capacity(truth.len()); + let mut next_update = 0.0; + let mut held = truth[0]; + for (i, &t) in self.times.iter().enumerate() { + if t + 1e-9 >= next_update { + held = truth[i] + sigma * rng.normal(); + next_update += interval; + } + out.push(held); + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rng_is_deterministic_and_roughly_normal() { + let mut a = Xorshift::new(1); + let mut b = Xorshift::new(1); + assert_eq!(a.next_u64(), b.next_u64()); + let mut rng = Xorshift::new(3); + let samples: Vec = (0..20_000).map(|_| rng.normal()).collect(); + let mean = samples.iter().sum::() / samples.len() as f64; + let var = samples.iter().map(|s| (s - mean).powi(2)).sum::() / samples.len() as f64; + assert!(mean.abs() < 0.03, "mean {mean}"); + assert!((var - 1.0).abs() < 0.05, "var {var}"); + } + + #[test] + fn sample_and_hold_holds_between_updates() { + let log = SyntheticLog::new(100.0, 1.0); + let truth: Vec = (0..100).map(|i| i as f64).collect(); + let held = log.sample_and_hold(&truth, 10.0, 0.0, &mut Xorshift::new(1)); + assert_eq!(held[0], 0.0); + assert_eq!(held[9], 0.0); + assert_eq!(held[10], 10.0); + assert_eq!(held[19], 10.0); + } + + #[test] + fn channels_round_trip() { + let mut log = SyntheticLog::new(10.0, 1.0); + log.add("A", vec![1.0; 10]); + log.add("B", (0..10).map(|i| i as f64).collect()); + assert_eq!(log.column("B")[3], 3.0); + log.scale("B", 2.0); + assert_eq!(log.column("B")[3], 6.0); + assert_eq!(log.log.channels.len(), 2); + assert_eq!(log.log.data.len(), 10); + } +} diff --git a/src/app.rs b/src/app.rs index 7866112d..81fe571b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -32,6 +32,7 @@ use crate::state::{ MIN_PLOT_HEIGHT, PlotArea, ScatterHistogramCache, ScatterPlotConfig, ScatterPlotState, SelectedChannel, Tab, TileProviderId, ToastType, }; +use crate::ui::table_generator::TableGeneratorState; use crate::units::UnitPreferences; use crate::updater::{DownloadResult, UpdateCheckResult, UpdateState}; @@ -177,6 +178,8 @@ pub struct UltraLogApp { pub(crate) show_analysis_panel: bool, /// Selected category in analysis panel (None = show all) pub(crate) analysis_selected_category: Option, + /// Table generator window state (lambda delay / accel enrichment tables) + pub(crate) table_generator: TableGeneratorState, // === Track Map / Data Panel Preferences === // Live copies of persisted preferences, synced back into UserSettings // by eframe::App::save (see the Settings Persistence Contract in @@ -268,6 +271,7 @@ impl Default for UltraLogApp { analysis_results: HashMap::new(), show_analysis_panel: false, analysis_selected_category: None, + table_generator: TableGeneratorState::default(), tile_provider: TileProviderId::default(), tile_cache_max_mb: 256, tiles_enabled: false, @@ -2286,6 +2290,7 @@ impl eframe::App for UltraLogApp { self.render_computed_channels_manager(ctx); self.render_formula_editor(ctx); self.render_analysis_panel(ctx); + self.render_table_generator(ctx); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { diff --git a/src/normalize.rs b/src/normalize.rs index 61b5bcee..32e2dfd2 100644 --- a/src/normalize.rs +++ b/src/normalize.rs @@ -226,6 +226,34 @@ static NORMALIZATION_MAP: LazyLock>> = "NJ_GPW_AVE", "PW", "pw", + // Injector on-time channels used by the lambda delay table + // generator (issue #4). Haltech logs on-time per injector, + // Link "Injection Effective PW", rusEFI/Speeduino the last + // pulse width, MegaSquirt "Base PW". + "Injector 1 On Time", + "Injector On Time", + "Injection Stage 1 Average Injection Time", + "Injection Effective PW", + "Injection Actual PW", + "Fuel: Last inj pulse width", + "Base PW", + "INJ Duration(ms)", + ], + ); + + // Throttle rate of change (%/s). Native ECU derivative channels the + // accel enrichment table generator (issue #3) prefers over a + // computed derivative: Speeduino/MegaSquirt "TPS DOT", Haltech + // "Throttle Position Derivative", ME "TPS Delta". + map.insert( + "TPS Rate", + vec![ + "TPS DOT", + "TPSdot", + "Throttle Position Derivative", + "TPS Delta", + "TPS Rate", + "tpsDot", ], ); diff --git a/src/state.rs b/src/state.rs index 0569abc6..f9a6ce1c 100644 --- a/src/state.rs +++ b/src/state.rs @@ -74,6 +74,9 @@ pub const COLORBLIND_COLORS: &[[u8; 3]] = &[ // Core Types // ============================================================================ +/// Source of [`LoadedFile::load_id`] nonces. +static NEXT_LOAD_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + /// Represents a loaded log file with its parsed data #[derive(Clone)] pub struct LoadedFile { @@ -88,6 +91,12 @@ pub struct LoadedFile { /// Cached flag for each channel: true if channel has non-zero data /// Computed once on load for UI performance pub channels_with_data: Vec, + /// Per-load nonce, unique for the lifetime of the process. Session + /// state that must survive tabs closing (the table generators' + /// accumulated events) keys on this rather than on the file index, + /// which shifts when an earlier file is removed, or on the bare file + /// name, which every rusEFI install shares (`Log1.mlg`). + pub load_id: u64, /// Lazy column-major view of `log.data` as `Vec>`. Built on first /// access so the chart hot path can borrow `&[f64]` for a channel instead /// of re-collecting an owned `Vec` from the row-major store on every @@ -112,6 +121,7 @@ impl LoadedFile { ecu_type, log, channels_with_data, + load_id: NEXT_LOAD_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), channel_columns: OnceLock::new(), } } diff --git a/src/ui/histogram.rs b/src/ui/histogram.rs index 9a9c46dd..9f402a4d 100644 --- a/src/ui/histogram.rs +++ b/src/ui/histogram.rs @@ -52,18 +52,18 @@ const CURSOR_CROSSHAIR_COLOR: egui::Color32 = egui::Color32::from_rgb(128, 128, /// Maximum length for axis labels before truncation const MAX_AXIS_LABEL_LENGTH: usize = 20; -/// Calculate which bin a normalized value (0.0 to 1.0) falls into -/// Uses floor-based calculation for consistent cell boundaries +/// Calculate which bin a normalized value (0.0 to 1.0) falls into. +/// Delegates to the table generators' binning helper so both tools agree on +/// cell boundaries. #[inline] fn calculate_bin(normalized: f32, grid_size: usize) -> usize { - ((normalized * grid_size as f32).floor() as usize).min(grid_size - 1) + crate::analysis::tables::binning::uniform_bin(normalized as f64, 0.0, 1.0, grid_size) } -/// Calculate which bin a data value falls into given the data range +/// Calculate which bin a data value falls into given the data range. #[inline] fn calculate_data_bin(value: f64, min: f64, range: f64, grid_size: usize) -> usize { - let normalized = ((value - min) / range) as f32; - calculate_bin(normalized.clamp(0.0, 1.0), grid_size) + crate::analysis::tables::binning::uniform_bin(value, min, range, grid_size) } /// Truncate a string to max length with ellipsis @@ -104,7 +104,7 @@ fn contrast_ratio(color1: egui::Color32, color2: egui::Color32) -> f64 { /// 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 { +pub(crate) 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); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 2b8b9e7b..2d9f65a2 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -53,6 +53,7 @@ pub mod normalization_editor; pub mod scatter_plot; pub mod sidebar; pub mod tab_bar; +pub mod table_generator; pub mod timeline; pub mod toast; pub mod tool_switcher; diff --git a/src/ui/table_generator.rs b/src/ui/table_generator.rs new file mode 100644 index 00000000..f872480e --- /dev/null +++ b/src/ui/table_generator.rs @@ -0,0 +1,1103 @@ +//! Table generator window: lambda delay (#4) and acceleration enrichment (#3) +//! tables mined from the loaded logs. +//! +//! Three states, like the analysis panel: +//! 1. **Setup** - channel roles (auto-suggested, ⚠ on ambiguity), axes, +//! parameters, and *Run on current file*. +//! 2. **Results** - Viridis heatmap with value text and a count badge coloured +//! by confidence; hover for the cell tooltip; click for the event inspector +//! with jump-to-time; toolbar for adding / removing logs, measure, export. +//! 3. **Empty** - the run report with its rejection breakdown, so threshold +//! tuning is guided rather than guesswork. + +use std::collections::HashMap; + +use eframe::egui; +use rust_i18n::t; + +use crate::analysis::tables::binning::Confidence; +use crate::analysis::tables::channel_map::Candidate; +use crate::analysis::tables::export::{DelayUnit, ExportOptions, to_clipboard_tsv, to_csv}; +use crate::analysis::tables::{ + AxisSpec, ChannelMapping, ChannelRole, GeneratorContext, GeneratorKind, LoadKind, RunReport, + TableAccumulator, TableAnalyzer, TableParamKind, suggest_mapping, +}; +use crate::analytics; +use crate::app::UltraLogApp; +use crate::colormap::{Colormap, sample}; +use crate::ui::histogram::get_aaa_text_color; + +/// Per-generator setup that is re-derived per log. +#[derive(Clone)] +struct MappingState { + /// Load nonce of the file the mapping was suggested for. + load_id: u64, + mapping: ChannelMapping, + ambiguous: Vec, + candidates: HashMap>, + axes: (AxisSpec, AxisSpec), + x_text: String, + y_text: String, +} + +/// All table-generator window state, held on `UltraLogApp`. +pub struct TableGeneratorState { + pub open: bool, + pub kind: GeneratorKind, + generators: HashMap>, + mappings: HashMap, + pub accumulators: HashMap, + last_report: HashMap, + last_error: Option, + measure: HashMap, + selected_cell: Option<(usize, usize)>, + export: ExportOptions, + show_setup: bool, + show_warnings: bool, +} + +impl Default for TableGeneratorState { + fn default() -> Self { + let generators = GeneratorKind::ALL + .into_iter() + .map(|k| (k, k.create())) + .collect(); + Self { + open: false, + kind: GeneratorKind::default(), + generators, + mappings: HashMap::new(), + accumulators: HashMap::new(), + last_report: HashMap::new(), + last_error: None, + measure: HashMap::new(), + selected_cell: None, + export: ExportOptions::default(), + show_setup: true, + show_warnings: false, + } + } +} + +impl TableGeneratorState { + /// Open the window on a generator. + pub fn open_for(&mut self, kind: GeneratorKind) { + self.open = true; + self.kind = kind; + self.selected_cell = None; + } + + /// Status line for the tools panel, e.g. `2 logs, 14 events`. + pub fn status(&self, kind: GeneratorKind) -> Option { + let acc = self.accumulators.get(&kind)?; + if acc.logs.is_empty() { + return None; + } + Some( + t!( + "table_gen.status", + logs = acc.logs.len(), + events = acc.accepted_count() + ) + .to_string(), + ) + } + + fn generator(&self) -> &dyn TableAnalyzer { + self.generators[&self.kind].as_ref() + } + + fn measure_index(&self) -> usize { + self.measure.get(&self.kind).copied().unwrap_or(0) + } +} + +const CONFIDENCE_COLORS: [(Confidence, egui::Color32); 3] = [ + (Confidence::Low, egui::Color32::from_rgb(220, 80, 80)), + (Confidence::Medium, egui::Color32::from_rgb(230, 170, 50)), + (Confidence::High, egui::Color32::from_rgb(100, 200, 100)), +]; + +fn confidence_color(c: Confidence) -> egui::Color32 { + CONFIDENCE_COLORS + .iter() + .find(|(k, _)| *k == c) + .map(|(_, col)| *col) + .unwrap_or(egui::Color32::GRAY) +} + +fn fmt_value(v: f64, decimals: usize) -> String { + if v.is_finite() { + format!("{v:.decimals$}") + } else { + "–".to_string() + } +} + +impl UltraLogApp { + /// Render the table generator window. + pub fn render_table_generator(&mut self, ctx: &egui::Context) { + if !self.table_generator.open { + return; + } + let mut open = true; + egui::Window::new(t!("table_gen.window_title")) + .open(&mut open) + .resizable(true) + .default_width(760.0) + .default_height(640.0) + .order(egui::Order::Foreground) + .show(ctx, |ui| { + self.render_table_generator_body(ui); + }); + if !open { + self.table_generator.open = false; + } + } + + fn render_table_generator_body(&mut self, ui: &mut egui::Ui) { + // Generator switcher. + ui.horizontal(|ui| { + for kind in GeneratorKind::ALL { + let name = self.table_generator.generators[&kind].name(); + let selected = self.table_generator.kind == kind; + if ui.selectable_label(selected, name).clicked() && !selected { + self.table_generator.kind = kind; + self.table_generator.selected_cell = None; + self.table_generator.last_error = None; + } + } + }); + ui.label( + egui::RichText::new(self.table_generator.generator().description()) + .small() + .color(egui::Color32::GRAY), + ); + ui.add_space(4.0); + + let file_index = self.selected_file.filter(|&i| i < self.files.len()); + if file_index.is_none() + && self + .table_generator + .accumulators + .get(&self.table_generator.kind) + .is_none_or(|a| a.is_empty()) + { + ui.vertical_centered(|ui| { + ui.add_space(30.0); + ui.label( + egui::RichText::new(t!("analysis.no_file_loaded")) + .color(egui::Color32::GRAY) + .size(16.0), + ); + ui.label( + egui::RichText::new(t!("analysis.load_file_help")) + .color(egui::Color32::GRAY) + .small(), + ); + ui.add_space(30.0); + }); + return; + } + + if let Some(fi) = file_index { + self.ensure_table_mapping(fi); + } + + egui::ScrollArea::vertical().show(ui, |ui| { + if let Some(fi) = file_index { + let kind = self.table_generator.kind; + let header = t!( + "table_gen.setup_header", + file = self.files[fi].name.as_str() + ); + let open_default = self + .table_generator + .accumulators + .get(&kind) + .is_none_or(|a| a.is_empty()); + egui::CollapsingHeader::new(egui::RichText::new(header.as_ref()).strong()) + .default_open(open_default) + .open(if self.table_generator.show_setup { + None + } else { + Some(false) + }) + .show(ui, |ui| { + self.table_generator.show_setup = true; + self.render_table_setup(ui, fi); + }); + } + ui.add_space(6.0); + self.render_table_results(ui); + }); + } + + /// Make sure a mapping exists for the current generator and file, + /// re-suggesting when the file changed. + fn ensure_table_mapping(&mut self, file_index: usize) { + let kind = self.table_generator.kind; + let load_id = self.files[file_index].load_id; + if self + .table_generator + .mappings + .get(&kind) + .is_some_and(|m| m.load_id == load_id) + { + return; + } + self.suggest_table_mapping(file_index, true); + } + + fn suggest_table_mapping(&mut self, file_index: usize, keep_overrides: bool) { + let kind = self.table_generator.kind; + let file = &self.files[file_index]; + let generator = &self.table_generator.generators[&kind]; + let suggestion = suggest_mapping( + &file.log, + &generator.roles(), + Some(&self.custom_normalizations), + ); + let mut mapping = suggestion.mapping; + if keep_overrides && let Some(prev) = self.table_generator.mappings.get(&kind) { + // Keep explicit choices that still exist in this log. + let names: Vec = file.log.channels.iter().map(|c| c.name()).collect(); + for (role, name) in &prev.mapping.assignments { + if names.iter().any(|n| n == name) { + mapping.assignments.insert(*role, name.clone()); + } + } + mapping.load_kind = prev.mapping.load_kind; + } + let axes = generator.default_axes(&file.log, &mapping); + let state = MappingState { + load_id: file.load_id, + x_text: axes.0.edges_text(), + y_text: axes.1.edges_text(), + mapping, + ambiguous: suggestion.ambiguous, + candidates: suggestion.candidates, + axes, + }; + self.table_generator.mappings.insert(kind, state); + } + + fn render_table_setup(&mut self, ui: &mut egui::Ui, file_index: usize) { + let kind = self.table_generator.kind; + let channel_names: Vec = self.files[file_index] + .log + .channels + .iter() + .map(|c| c.name()) + .collect(); + let roles = self.table_generator.generators[&kind].roles(); + let Some(state) = self.table_generator.mappings.get_mut(&kind) else { + return; + }; + + // --- Channels ----------------------------------------------------- + ui.horizontal(|ui| { + ui.label(egui::RichText::new(t!("table_gen.channels")).strong()); + if ui.small_button(t!("table_gen.auto_detect")).clicked() { + // Handled below (needs &mut self). + ui.ctx() + .data_mut(|d| d.insert_temp(egui::Id::new("table_gen_redetect"), true)); + } + }); + egui::Grid::new(format!("table_gen_roles_{}", kind.id())) + .num_columns(3) + .spacing([8.0, 4.0]) + .show(ui, |ui| { + for spec in &roles { + let label = if spec.required { + format!("{} *", spec.role.label()) + } else { + spec.role.label().to_string() + }; + ui.label(label).on_hover_text(spec.role.hint()); + let current = state.mapping.get(spec.role).map(str::to_string); + let shown = current + .clone() + .unwrap_or_else(|| t!("table_gen.none").to_string()); + let mut changed: Option> = None; + egui::ComboBox::from_id_salt(format!( + "table_gen_role_{}_{:?}", + kind.id(), + spec.role + )) + .width(260.0) + .selected_text(shown) + .show_ui(ui, |ui| { + if ui + .selectable_label(current.is_none(), t!("table_gen.none")) + .clicked() + { + changed = Some(None); + } + // Suggested candidates first, then everything else. + if let Some(cands) = state.candidates.get(&spec.role) { + for c in cands { + let text = format!("★ {}", c.channel); + if ui + .selectable_label(current.as_deref() == Some(&c.channel), text) + .clicked() + { + changed = Some(Some(c.channel.clone())); + } + } + ui.separator(); + } + for name in &channel_names { + if ui + .selectable_label(current.as_deref() == Some(name.as_str()), name) + .clicked() + { + changed = Some(Some(name.clone())); + } + } + }); + if let Some(new) = changed { + state.mapping.set(spec.role, new); + state.ambiguous.retain(|r| *r != spec.role); + } + if state.ambiguous.contains(&spec.role) { + ui.label( + egui::RichText::new("⚠").color(egui::Color32::from_rgb(230, 170, 50)), + ) + .on_hover_text(t!("table_gen.ambiguous_hint")); + } else if spec.required && !state.mapping.is_mapped(spec.role) { + ui.label( + egui::RichText::new("!").color(egui::Color32::from_rgb(220, 80, 80)), + ) + .on_hover_text(t!("table_gen.required_hint")); + } else { + ui.label(""); + } + ui.end_row(); + } + }); + + if kind == GeneratorKind::LambdaDelay { + ui.horizontal(|ui| { + ui.label(t!("table_gen.load_axis")); + let mut lk = state.mapping.load_kind; + ui.selectable_value(&mut lk, LoadKind::Map, LoadKind::Map.label()); + ui.selectable_value(&mut lk, LoadKind::Tps, LoadKind::Tps.label()); + if lk != state.mapping.load_kind { + state.mapping.load_kind = lk; + ui.ctx() + .data_mut(|d| d.insert_temp(egui::Id::new("table_gen_reaxis"), true)); + } + }); + } + + // --- Axes --------------------------------------------------------- + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(t!("table_gen.axes")).strong()); + if ui.small_button(t!("table_gen.reset_axes")).clicked() { + ui.ctx() + .data_mut(|d| d.insert_temp(egui::Id::new("table_gen_reaxis"), true)); + } + }); + egui::Grid::new(format!("table_gen_axes_{}", kind.id())) + .num_columns(3) + .spacing([8.0, 4.0]) + .show(ui, |ui| { + for (label, text, axis) in [ + (state.axes.0.header(), &mut state.x_text, &mut state.axes.0), + (state.axes.1.header(), &mut state.y_text, &mut state.axes.1), + ] { + ui.label(label); + let resp = ui.add(egui::TextEdit::singleline(text).desired_width(420.0)); + if resp.changed() + && let Some(edges) = AxisSpec::parse_edges(text.as_str()) + { + *axis = AxisSpec::new(axis.label.clone(), axis.unit.clone(), edges); + } + let valid = AxisSpec::parse_edges(text.as_str()).is_some(); + ui.label(if valid { + egui::RichText::new(t!("table_gen.bins", n = axis.bins())) + .small() + .color(egui::Color32::GRAY) + } else { + egui::RichText::new(t!("table_gen.invalid_axis")) + .small() + .color(egui::Color32::from_rgb(220, 80, 80)) + }); + ui.end_row(); + } + }); + + // --- Parameters --------------------------------------------------- + ui.add_space(6.0); + let generator = self + .table_generator + .generators + .get_mut(&kind) + .expect("generator registered"); + egui::CollapsingHeader::new(egui::RichText::new(t!("table_gen.parameters")).strong()) + .default_open(false) + .show(ui, |ui| { + let mut config = generator.get_config(); + let mut changed = false; + egui::Grid::new(format!("table_gen_params_{}", kind.id())) + .num_columns(2) + .spacing([8.0, 4.0]) + .show(ui, |ui| { + for p in generator.params() { + ui.label(p.label).on_hover_text(p.tooltip); + match p.kind { + TableParamKind::Float { min, max, speed } => { + let mut v: f64 = config + .parameters + .get(p.key) + .and_then(|s| s.parse().ok()) + .unwrap_or(min); + if ui + .add( + egui::DragValue::new(&mut v) + .range(min..=max) + .speed(speed), + ) + .changed() + { + config.parameters.insert(p.key.to_string(), v.to_string()); + changed = true; + } + } + TableParamKind::Integer { min, max } => { + let mut v: i64 = config + .parameters + .get(p.key) + .and_then(|s| s.parse().ok()) + .unwrap_or(min); + if ui + .add(egui::DragValue::new(&mut v).range(min..=max)) + .changed() + { + config.parameters.insert(p.key.to_string(), v.to_string()); + changed = true; + } + } + TableParamKind::Choice(choices) => { + let current = + config.parameters.get(p.key).cloned().unwrap_or_default(); + egui::ComboBox::from_id_salt(format!( + "table_gen_param_{}_{}", + kind.id(), + p.key + )) + .selected_text(current.clone()) + .show_ui(ui, |ui| { + for c in choices { + if ui.selectable_label(current == *c, *c).clicked() { + config + .parameters + .insert(p.key.to_string(), c.to_string()); + changed = true; + } + } + }); + } + } + ui.end_row(); + } + }); + if changed { + generator.set_config(&config); + } + }); + + // --- Run ---------------------------------------------------------- + ui.add_space(8.0); + let missing = self + .table_generator + .mappings + .get(&kind) + .map(|m| m.mapping.missing_required(&roles)) + .unwrap_or_default(); + let axes_ok = self + .table_generator + .mappings + .get(&kind) + .is_some_and(|m| m.axes.0.is_valid() && m.axes.1.is_valid()); + let already = self + .table_generator + .accumulators + .get(&kind) + .is_some_and(|a| a.contains_log(self.files[file_index].load_id)); + let label = if already { + t!("table_gen.rerun") + } else if self + .table_generator + .accumulators + .get(&kind) + .is_some_and(|a| !a.logs.is_empty()) + { + t!("table_gen.add_file") + } else { + t!("table_gen.run") + }; + ui.horizontal(|ui| { + let button = + egui::Button::new(egui::RichText::new(label.as_ref()).color(egui::Color32::WHITE)) + .fill(egui::Color32::from_rgb(113, 120, 78)); + if ui + .add_enabled(missing.is_empty() && axes_ok, button) + .clicked() + { + self.run_table_generator(file_index); + } + if !missing.is_empty() { + let names: Vec<&str> = missing.iter().map(|r| r.label()).collect(); + ui.label( + egui::RichText::new(t!("table_gen.missing_roles", roles = names.join(", "))) + .small() + .color(egui::Color32::from_rgb(220, 80, 80)), + ); + } + }); + if let Some(err) = &self.table_generator.last_error { + ui.label(egui::RichText::new(err).color(egui::Color32::from_rgb(220, 80, 80))); + } + + // Deferred actions that need `&mut self` outside the borrows above. + let redetect = ui + .ctx() + .data_mut(|d| d.remove_temp::(egui::Id::new("table_gen_redetect"))) + .unwrap_or(false); + let reaxis = ui + .ctx() + .data_mut(|d| d.remove_temp::(egui::Id::new("table_gen_reaxis"))) + .unwrap_or(false); + if redetect { + self.suggest_table_mapping(file_index, false); + } else if reaxis && let Some(state) = self.table_generator.mappings.get_mut(&kind) { + let generator = &self.table_generator.generators[&kind]; + state.axes = generator.default_axes(&self.files[file_index].log, &state.mapping); + state.x_text = state.axes.0.edges_text(); + state.y_text = state.axes.1.edges_text(); + } + } + + /// Run the current generator on `file_index` and fold the events into + /// the accumulator. Axes are frozen when the accumulator is created; a + /// later file with different axes replaces the table. + fn run_table_generator(&mut self, file_index: usize) { + let kind = self.table_generator.kind; + let Some(state) = self.table_generator.mappings.get(&kind).cloned() else { + return; + }; + let file = &self.files[file_index]; + let delay_grid = if kind == GeneratorKind::AccelEnrich { + self.table_generator + .accumulators + .get(&GeneratorKind::LambdaDelay) + .filter(|a| !a.is_empty()) + .map(|a| a.grid(0)) + } else { + None + }; + let ctx = GeneratorContext { + delay_table: delay_grid.as_ref(), + }; + let generator = &self.table_generator.generators[&kind]; + let axes_match = self + .table_generator + .accumulators + .get(&kind) + .is_some_and(|a| a.axes == state.axes && !a.logs.is_empty()); + let result = generator.analyze(&file.log, &file.name, &state.mapping, &state.axes, &ctx); + match result { + Ok((mut events, report)) => { + for e in &mut events { + e.log_id = file.load_id; + } + let acc = if axes_match { + self.table_generator + .accumulators + .get_mut(&kind) + .expect("checked") + } else { + self.table_generator.accumulators.insert( + kind, + TableAccumulator::new(kind, state.axes.clone(), generator.measures()), + ); + self.table_generator + .accumulators + .get_mut(&kind) + .expect("just inserted") + }; + let summary = report.summary(); + acc.add_log(file.load_id, &file.name, events, report.clone()); + self.table_generator.last_report.insert(kind, report); + self.table_generator.last_error = None; + self.table_generator.selected_cell = None; + self.table_generator.show_setup = false; + self.show_toast(&summary); + } + Err(e) => { + self.table_generator.last_error = Some(e.to_string()); + self.show_toast_error(&e.to_string()); + } + } + } + + fn render_table_results(&mut self, ui: &mut egui::Ui) { + let kind = self.table_generator.kind; + let Some(acc) = self.table_generator.accumulators.get(&kind) else { + return; + }; + if acc.logs.is_empty() { + return; + } + let measures = acc.measures.clone(); + let measure_idx = self + .table_generator + .measure_index() + .min(measures.len().saturating_sub(1)); + let grid = acc.grid(measure_idx); + let logs: Vec<(u64, String)> = acc.logs.iter().map(|l| (l.id, l.name.clone())).collect(); + let accepted = acc.accepted_count(); + let reports: Vec = acc.logs.iter().map(|l| l.report.clone()).collect(); + + ui.separator(); + // --- Toolbar -------------------------------------------------------- + let mut remove_log: Option = None; + let mut reset = false; + let mut export_csv = false; + let mut copy = false; + ui.horizontal_wrapped(|ui| { + ui.label(egui::RichText::new(t!("table_gen.results")).strong()); + egui::ComboBox::from_id_salt(format!("table_gen_measure_{}", kind.id())) + .selected_text(format!( + "{} ({})", + measures[measure_idx].label, measures[measure_idx].unit + )) + .show_ui(ui, |ui| { + for (i, m) in measures.iter().enumerate() { + if ui + .selectable_label(i == measure_idx, format!("{} ({})", m.label, m.unit)) + .clicked() + { + self.table_generator.measure.insert(kind, i); + } + } + }); + if ui.button(t!("table_gen.export_csv")).clicked() { + export_csv = true; + } + if ui + .button(t!("table_gen.copy")) + .on_hover_text(t!("table_gen.copy_hint")) + .clicked() + { + copy = true; + } + ui.checkbox( + &mut self.table_generator.export.exclude_low, + t!("table_gen.exclude_low"), + ) + .on_hover_text(t!("table_gen.exclude_low_hint")); + if kind == GeneratorKind::LambdaDelay { + egui::ComboBox::from_id_salt("table_gen_delay_unit") + .selected_text(self.table_generator.export.delay_unit.label()) + .show_ui(ui, |ui| { + for u in DelayUnit::ALL { + ui.selectable_value( + &mut self.table_generator.export.delay_unit, + u, + u.label(), + ); + } + }); + if self.table_generator.export.delay_unit == DelayUnit::IgnitionEvents { + ui.label(t!("table_gen.cylinders")); + ui.add( + egui::DragValue::new(&mut self.table_generator.export.cylinders) + .range(1..=16), + ); + } + } + egui::ComboBox::from_id_salt(format!("table_gen_remove_{}", kind.id())) + .selected_text(t!("table_gen.remove_log")) + .show_ui(ui, |ui| { + for (id, name) in &logs { + if ui.selectable_label(false, name).clicked() { + remove_log = Some(*id); + } + } + }); + if ui.button(t!("table_gen.reset")).clicked() { + reset = true; + } + }); + + // --- Coverage / report ------------------------------------------- + let (filled, high) = grid.coverage(); + ui.label( + egui::RichText::new(t!( + "table_gen.coverage", + events = accepted, + logs = logs.len(), + filled = filled, + total = grid.rows() * grid.cols(), + high = high + )) + .small() + .color(egui::Color32::GRAY), + ); + for r in &reports { + ui.label(egui::RichText::new(format!("{}: {}", r.log_name, r.summary())).small()); + } + let warnings: Vec = reports + .iter() + .flat_map(|r| r.warnings.iter().cloned()) + .collect(); + if !warnings.is_empty() { + let header = t!("table_gen.notes", n = warnings.len()); + egui::CollapsingHeader::new(egui::RichText::new(header.as_ref()).small()) + .default_open(self.table_generator.show_warnings) + .show(ui, |ui| { + for w in &warnings { + ui.label( + egui::RichText::new(format!("• {w}")) + .small() + .color(egui::Color32::GRAY), + ); + } + }); + } + + // --- Heatmap -------------------------------------------------------- + ui.add_space(4.0); + let decimals = measures[measure_idx].decimals; + if accepted == 0 { + ui.label( + egui::RichText::new(t!("table_gen.no_events")) + .color(egui::Color32::from_rgb(230, 170, 50)), + ); + } else { + self.render_table_heatmap(ui, &grid, decimals); + } + + // --- Inspector ------------------------------------------------------ + if let Some((row, col)) = self.table_generator.selected_cell + && let Some(cell) = grid.cell(row, col) + { + ui.add_space(6.0); + let acc = &self.table_generator.accumulators[&kind]; + let events: Vec<_> = acc.events_in_cell(row, col).into_iter().cloned().collect(); + ui.label( + egui::RichText::new(t!( + "table_gen.cell_title", + x = format!( + "{} {}", + grid.x_axis.label, + fmt_value(grid.x_axis.lower_edge(col), 0) + ), + y = format!( + "{} {}", + grid.y_axis.label, + fmt_value(grid.y_axis.lower_edge(row), 0) + ), + n = cell.count, + median = fmt_value(cell.median, decimals), + mad = fmt_value(cell.mad, decimals.max(1)), + confidence = cell.confidence.label() + )) + .strong(), + ); + let mut jump: Option<(u64, f64)> = None; + egui::ScrollArea::vertical() + .max_height(160.0) + .id_salt("table_gen_inspector") + .show(ui, |ui| { + egui::Grid::new("table_gen_inspector_grid") + .striped(true) + .num_columns(5) + .show(ui, |ui| { + ui.label( + egui::RichText::new(t!("table_gen.col_time")) + .small() + .strong(), + ); + ui.label( + egui::RichText::new(t!("table_gen.col_log")) + .small() + .strong(), + ); + ui.label( + egui::RichText::new(measures[measure_idx].label) + .small() + .strong(), + ); + ui.label( + egui::RichText::new(t!("table_gen.col_note")) + .small() + .strong(), + ); + ui.label(""); + ui.end_row(); + for e in &events { + ui.label(egui::RichText::new(format!("{:.2} s", e.time)).small()); + ui.label(egui::RichText::new(&e.log_name).small()); + ui.label( + egui::RichText::new(fmt_value(e.value(measure_idx), decimals)) + .small(), + ); + ui.label( + egui::RichText::new(&e.note) + .small() + .color(egui::Color32::GRAY), + ); + let loaded = self.files.iter().any(|f| f.load_id == e.log_id); + if ui + .add_enabled( + loaded, + egui::Button::new(t!("table_gen.jump")).small(), + ) + .clicked() + { + jump = Some((e.log_id, e.time)); + } + ui.end_row(); + } + }); + }); + if let Some((log_id, time)) = jump { + self.jump_to_table_event(log_id, time); + } + } + + // --- Deferred actions --------------------------------------------- + if let Some(id) = remove_log + && let Some(acc) = self.table_generator.accumulators.get_mut(&kind) + { + acc.remove_log(id); + self.table_generator.selected_cell = None; + } + if reset { + if let Some(acc) = self.table_generator.accumulators.get_mut(&kind) { + acc.reset(); + } + self.table_generator.last_report.remove(&kind); + self.table_generator.selected_cell = None; + self.table_generator.show_setup = true; + } + if export_csv { + self.export_table_csv(); + } + if copy { + let acc = &self.table_generator.accumulators[&kind]; + let tsv = to_clipboard_tsv(acc, measure_idx, &self.table_generator.export); + match arboard::Clipboard::new().and_then(|mut c| c.set_text(tsv)) { + Ok(()) => self.show_toast_success(&t!("table_gen.copied")), + Err(e) => self.show_toast_error(&format!("{}: {e}", t!("table_gen.copy_failed"))), + } + } + } + + fn render_table_heatmap( + &mut self, + ui: &mut egui::Ui, + grid: &crate::analysis::tables::TableGrid, + decimals: usize, + ) { + let rows = grid.rows(); + let cols = grid.cols(); + if rows == 0 || cols == 0 { + return; + } + let label_w = 64.0; + let label_h = 22.0; + let avail = ui.available_width().max(200.0); + let cell_w = ((avail - label_w) / cols as f32).clamp(28.0, 110.0); + let cell_h = (cell_w * 0.55).clamp(20.0, 40.0); + let size = egui::vec2( + label_w + cell_w * cols as f32, + label_h + cell_h * rows as f32 + 18.0, + ); + let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click()); + let painter = ui.painter_at(rect); + let font = egui::FontId::proportional((cell_h * 0.42).clamp(9.0, 13.0)); + let small = egui::FontId::proportional(9.0); + let text_color = ui.visuals().text_color(); + let (lo, hi) = grid.value_range().unwrap_or((0.0, 1.0)); + let range = if (hi - lo).abs() < f64::EPSILON { + 1.0 + } else { + hi - lo + }; + let origin = rect.min + egui::vec2(label_w, label_h); + let hover = response.hover_pos(); + let mut hovered: Option<(usize, usize)> = None; + + // Column headers (X axis lower edges) and axis title. + for c in 0..cols { + let x = origin.x + cell_w * (c as f32 + 0.5); + painter.text( + egui::pos2(x, rect.min.y + label_h * 0.5), + egui::Align2::CENTER_CENTER, + fmt_value(grid.x_axis.lower_edge(c), 0), + small.clone(), + text_color, + ); + } + painter.text( + egui::pos2(origin.x + cell_w * cols as f32 * 0.5, rect.max.y - 8.0), + egui::Align2::CENTER_CENTER, + grid.x_axis.header(), + small.clone(), + text_color, + ); + painter.text( + egui::pos2(rect.min.x + 2.0, rect.min.y + label_h * 0.5), + egui::Align2::LEFT_CENTER, + grid.y_axis.header(), + small.clone(), + text_color, + ); + + // Rows are drawn top-down with the highest Y bin at the top, the way + // ECU tables are laid out. + for r in 0..rows { + let draw_row = rows - 1 - r; + let y = origin.y + cell_h * draw_row as f32; + painter.text( + egui::pos2(rect.min.x + label_w - 6.0, y + cell_h * 0.5), + egui::Align2::RIGHT_CENTER, + fmt_value(grid.y_axis.lower_edge(r), 0), + small.clone(), + text_color, + ); + for c in 0..cols { + let cell_rect = egui::Rect::from_min_size( + egui::pos2(origin.x + cell_w * c as f32, y), + egui::vec2(cell_w, cell_h), + ); + let Some(cell) = grid.cell(r, c) else { + continue; + }; + if let Some(h) = hover + && cell_rect.contains(h) + { + hovered = Some((r, c)); + } + if cell.is_empty() { + painter.rect_filled( + cell_rect.shrink(0.5), + 2.0, + egui::Color32::from_rgb(36, 36, 36), + ); + continue; + } + let t = ((cell.median - lo) / range) as f32; + let fill = sample(Colormap::Viridis, t); + painter.rect_filled(cell_rect.shrink(0.5), 2.0, fill); + let fg = get_aaa_text_color(fill); + let value_text = fmt_value(cell.median, decimals); + let text = if cell.confidence == Confidence::Low { + egui::RichText::new(value_text).italics() + } else { + egui::RichText::new(value_text) + }; + painter.text( + cell_rect.center(), + egui::Align2::CENTER_CENTER, + text.text(), + font.clone(), + fg, + ); + // Count badge, coloured by confidence. + let badge = egui::Rect::from_min_size( + cell_rect.right_top() + egui::vec2(-14.0, 1.0), + egui::vec2(13.0, 9.0), + ); + painter.rect_filled(badge, 2.0, confidence_color(cell.confidence)); + painter.text( + badge.center(), + egui::Align2::CENTER_CENTER, + cell.count.to_string(), + egui::FontId::proportional(7.5), + egui::Color32::BLACK, + ); + if self.table_generator.selected_cell == Some((r, c)) { + painter.rect_stroke( + cell_rect.shrink(1.0), + 2.0, + egui::Stroke::new(2.0, egui::Color32::WHITE), + egui::StrokeKind::Inside, + ); + } + } + } + + if let Some((r, c)) = hovered { + if response.clicked() { + self.table_generator.selected_cell = + if self.table_generator.selected_cell == Some((r, c)) { + None + } else { + Some((r, c)) + }; + } + if let Some(cell) = grid.cell(r, c) { + let text = if cell.is_empty() { + t!("table_gen.empty_cell").to_string() + } else { + t!( + "table_gen.cell_tooltip", + median = fmt_value(cell.median, decimals), + mad = fmt_value(cell.mad, decimals.max(1)), + n = cell.count, + confidence = cell.confidence.label() + ) + .to_string() + }; + response.clone().on_hover_text(text); + } + } + } + + /// Activate the tab showing the event's log and jump the chart to its time. + fn jump_to_table_event(&mut self, log_id: u64, time: f64) { + let Some(file_index) = self.files.iter().position(|f| f.load_id == log_id) else { + self.show_toast_warning(&t!("table_gen.log_unloaded")); + return; + }; + if let Some(tab_idx) = self.tabs.iter().position(|t| t.file_index == file_index) { + self.active_tab = Some(tab_idx); + self.selected_file = Some(file_index); + self.set_jump_to_time(Some(time)); + } + } + + fn export_table_csv(&mut self) { + let kind = self.table_generator.kind; + let Some(acc) = self.table_generator.accumulators.get(&kind) else { + return; + }; + let measure_idx = self.table_generator.measure_index(); + let generated = chrono::Local::now().format("%Y-%m-%d %H:%M").to_string(); + let csv = to_csv(acc, measure_idx, &self.table_generator.export, &generated); + let Some(path) = rfd::FileDialog::new() + .add_filter("CSV", &["csv"]) + .set_file_name(format!("ultralog_{}.csv", kind.id())) + .save_file() + else { + return; + }; + match std::fs::write(&path, csv) { + Ok(()) => { + analytics::track_export(kind.id()); + self.show_toast_success(&t!( + "table_gen.exported", + path = path.display().to_string() + )); + } + Err(e) => self.show_toast_error(&format!("{}: {e}", t!("table_gen.export_failed"))), + } + } +} diff --git a/src/ui/tools_panel.rs b/src/ui/tools_panel.rs index 3c3b91a5..8265385d 100644 --- a/src/ui/tools_panel.rs +++ b/src/ui/tools_panel.rs @@ -3,7 +3,9 @@ //! Provides quick access to analysis and export functionality inline in the side panel. use eframe::egui; +use rust_i18n::t; +use crate::analysis::tables::GeneratorKind; use crate::app::UltraLogApp; use crate::state::ActiveTool; @@ -20,6 +22,13 @@ impl UltraLogApp { ui.separator(); ui.add_space(8.0); + // Table Generators Section + self.render_tools_table_section(ui); + + ui.add_space(12.0); + ui.separator(); + ui.add_space(8.0); + // Computed Channels Section self.render_tools_computed_section(ui); @@ -110,6 +119,55 @@ impl UltraLogApp { }); } + /// Render the table generators section (lambda delay / accel enrichment) + fn render_tools_table_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(format!("📊 {}", t!("table_gen.section_title"))) + .size(font_14) + .strong(), + ) + .default_open(true) + .show(ui, |ui| { + ui.label( + egui::RichText::new(t!("table_gen.section_help")) + .size(font_12) + .color(egui::Color32::GRAY), + ); + ui.add_space(8.0); + + let has_file = self.selected_file.is_some() && !self.files.is_empty(); + for kind in GeneratorKind::ALL { + let name = kind.create().name(); + let status = self.table_generator.status(kind); + let enabled = has_file || status.is_some(); + ui.horizontal(|ui| { + let btn = egui::Button::new(egui::RichText::new(name).size(font_12)); + if ui.add_enabled(enabled, btn).clicked() { + self.table_generator.open_for(kind); + } + if let Some(status) = status { + ui.label( + egui::RichText::new(status) + .size(font_12) + .color(egui::Color32::from_rgb(150, 200, 150)), + ); + } + }); + } + if !has_file { + ui.label( + egui::RichText::new(t!("table_gen.load_file_hint")) + .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); diff --git a/tests/core/mod.rs b/tests/core/mod.rs index 12a2aa26..b7fa5ef3 100644 --- a/tests/core/mod.rs +++ b/tests/core/mod.rs @@ -16,4 +16,5 @@ pub mod mcp_tests; pub mod normalize_tests; pub mod settings_tests; pub mod state_tests; +pub mod table_generator_tests; pub mod units_tests; diff --git a/tests/core/table_generator_tests.rs b/tests/core/table_generator_tests.rs new file mode 100644 index 00000000..d376b689 --- /dev/null +++ b/tests/core/table_generator_tests.rs @@ -0,0 +1,528 @@ +//! Table generator tests against real example logs. +//! +//! Synthetic ground-truth tests live next to the generators in +//! `src/analysis/tables/`. These tests assert plausibility envelopes and +//! auto-suggestion behaviour on the shipped fixtures: +//! +//! - MegaSquirt `2026-04-12_12.49.36.mlg` (15 Hz, 668 s, engine running): +//! `PW`, `Lambda`, `TPS DOT`, `Accel Enrich` - the primary real-log fixture +//! for both generators and the low-rate warning path. +//! - Haltech `2025-07-18_0215pm_Log1118.csv` (50 Hz throttle-blip log): +//! auto-suggestion regression, sentinel masking, rejection breakdown, and +//! tip-in detection against the ECU's own transient-throttle channel. +//! - rusEFI `rusefilog.mlg` (100 Hz, no wideband): tip-in detection against +//! `Fuel: TPS AE Active`, and the flat-lambda warning. + +use std::collections::HashMap; + +use ultralog::analysis::tables::events::find_rate_runs; +use ultralog::analysis::tables::export::{ExportOptions, to_clipboard_tsv, to_csv}; +use ultralog::analysis::tables::lambda_delay::{LambdaDelayGenerator, Profile}; +use ultralog::analysis::tables::{ + ChannelRole, GeneratorContext, GeneratorKind, RejectReason, TableAccumulator, TableAnalyzer, + suggest_mapping, +}; +use ultralog::parsers::types::Log; +use ultralog::parsers::{Haltech, Parseable, Speeduino}; + +use crate::common::{example_file_exists, example_files, read_example_binary, read_example_file}; + +const MEGASQUIRT_MLG: &str = "exampleLogs/megasquirt/2026-04-12_12.49.36.mlg"; + +fn megasquirt() -> Log { + Speeduino::parse_binary(&read_example_binary(MEGASQUIRT_MLG)).expect("MegaSquirt MLG parses") +} + +fn haltech_small() -> Log { + Haltech + .parse(&read_example_file(example_files::HALTECH_SMALL)) + .expect("Haltech CSV parses") +} + +fn rusefi() -> Log { + Speeduino::parse_binary(&read_example_binary(example_files::RUSEFI_MLG)) + .expect("rusEFI MLG parses") +} + +fn column(log: &Log, name: &str) -> Vec { + let idx = log + .channels + .iter() + .position(|c| c.name() == name) + .unwrap_or_else(|| panic!("channel {name} missing")); + log.get_channel_data(idx) +} + +fn mapped(map: &HashMap, role: ChannelRole) -> &str { + map.get(&role).map(String::as_str).unwrap_or("(none)") +} + +// --------------------------------------------------------------------------- +// Auto-suggestion +// --------------------------------------------------------------------------- + +#[test] +fn haltech_auto_suggestion_picks_the_documented_channels() { + let log = haltech_small(); + let g = GeneratorKind::LambdaDelay.create(); + let s = suggest_mapping(&log, &g.roles(), None); + let m = &s.mapping.assignments; + assert_eq!(mapped(m, ChannelRole::Rpm), "RPM"); + assert_eq!(mapped(m, ChannelRole::Map), "Manifold Pressure"); + assert_eq!(mapped(m, ChannelRole::Tps), "Throttle Position"); + assert_eq!(mapped(m, ChannelRole::PulseWidth), "Injector 1 On Time"); + // A single sensor beats the averaged channel. + assert_eq!(mapped(m, ChannelRole::Lambda), "Wideband O2 1"); + assert_eq!(mapped(m, ChannelRole::FuelCut), "Decel Cut State"); + assert_eq!( + mapped(m, ChannelRole::ClosedLoopState), + "O2 Control Bank 1 Short Term Fuel Trim" + ); + assert_eq!(mapped(m, ChannelRole::CoolantTemp), "Coolant Temperature"); + assert!(mapped(m, ChannelRole::Clutch).starts_with("Clutch")); + // `Clutch State` vs `Clutch Switch Input State` is a genuine tie. + assert!( + s.ambiguous.contains(&ChannelRole::Clutch), + "{:?}", + s.ambiguous + ); + assert!(!s.ambiguous.contains(&ChannelRole::Lambda)); + + let g = GeneratorKind::AccelEnrich.create(); + let s = suggest_mapping(&log, &g.roles(), None); + let m = &s.mapping.assignments; + assert_eq!( + mapped(m, ChannelRole::TpsRate), + "Throttle Position Derivative" + ); + assert_eq!(mapped(m, ChannelRole::LambdaTarget), "Target Lambda"); + assert_eq!( + mapped(m, ChannelRole::AeActive), + "Transient Throttle Load Derivative" + ); +} + +#[test] +fn megasquirt_auto_suggestion() { + let log = megasquirt(); + let g = GeneratorKind::LambdaDelay.create(); + let s = suggest_mapping(&log, &g.roles(), None); + let m = &s.mapping.assignments; + assert_eq!(mapped(m, ChannelRole::Rpm), "RPM"); + assert_eq!(mapped(m, ChannelRole::Map), "MAP"); + assert_eq!(mapped(m, ChannelRole::PulseWidth), "PW"); + assert!(matches!(mapped(m, ChannelRole::Lambda), "AFR" | "Lambda")); + // AFR and Lambda are both logged; the user is asked to confirm. + assert!(s.ambiguous.contains(&ChannelRole::Lambda)); + assert_eq!(mapped(m, ChannelRole::FuelCut), "DFCO"); + assert_eq!(mapped(m, ChannelRole::ClosedLoopState), "Gego"); + + let g = GeneratorKind::AccelEnrich.create(); + let s = suggest_mapping(&log, &g.roles(), None); + let m = &s.mapping.assignments; + assert_eq!(mapped(m, ChannelRole::TpsRate), "TPS DOT"); + assert_eq!(mapped(m, ChannelRole::LambdaTarget), "Lambda Target"); + assert_eq!(mapped(m, ChannelRole::AeActive), "Accel Enrich"); +} + +#[test] +fn rusefi_auto_suggestion_vetoes_flat_lambda_and_flag_channels() { + let log = rusefi(); + let g = GeneratorKind::AccelEnrich.create(); + let s = suggest_mapping(&log, &g.roles(), None); + let m = &s.mapping.assignments; + assert_eq!(mapped(m, ChannelRole::Rpm), "RPM"); + assert_eq!(mapped(m, ChannelRole::Map), "MAP"); + assert_eq!(mapped(m, ChannelRole::Tps), "TPS"); + assert_eq!(mapped(m, ChannelRole::AeActive), "Fuel: TPS AE Active"); + assert_eq!(mapped(m, ChannelRole::CoolantTemp), "CLT"); + // `Lambda` is all zeros in this log and `lambdaCurrentlyGood` is a flag: + // neither may be picked as the wideband. + assert_ne!(mapped(m, ChannelRole::Lambda), "lambdaCurrentlyGood"); + assert_ne!(mapped(m, ChannelRole::Lambda), "Lambda"); + let g = GeneratorKind::LambdaDelay.create(); + let s = suggest_mapping(&log, &g.roles(), None); + let m = &s.mapping.assignments; + assert_eq!( + mapped(m, ChannelRole::PulseWidth), + "Fuel: Last inj pulse width" + ); + assert_eq!(mapped(m, ChannelRole::FuelCut), "dfcoActive"); +} + +// --------------------------------------------------------------------------- +// Lambda delay +// --------------------------------------------------------------------------- + +#[test] +fn megasquirt_lambda_delay_driving_log_runs_and_warns() { + let log = megasquirt(); + let g = LambdaDelayGenerator::default(); + let s = suggest_mapping(&log, &g.roles(), None); + let axes = g.default_axes(&log, &s.mapping); + assert!(axes.0.is_valid() && axes.1.is_valid()); + let (events, report) = g + .analyze( + &log, + "megasquirt", + &s.mapping, + &axes, + &GeneratorContext::default(), + ) + .expect("runs"); + assert_eq!(events.len(), report.candidates); + assert!(report.candidates > 50, "{}", report.summary()); + // A driving log is mostly unsteady / overlapping: the rejection + // breakdown is the product here. + assert!( + report.rejected.contains_key(&RejectReason::Unsteady), + "{}", + report.summary() + ); + assert!( + report.rejected.contains_key(&RejectReason::Overlap), + "{}", + report.summary() + ); + assert!( + report.warnings.iter().any(|w| w.contains("15 Hz")), + "low-rate warning expected: {:?}", + report.warnings + ); + assert!(report.warnings.iter().any(|w| w.contains("AFR"))); + for e in events.iter().filter(|e| e.accepted()) { + let ms = e.value(0); + assert!( + (20.0..=1500.0).contains(&ms), + "dead time {ms} ms out of envelope" + ); + assert!(e.rpm > 500.0 && e.rpm < 7000.0); + } + // The relaxed profile never rejects more than strict. + let mut relaxed = LambdaDelayGenerator::default(); + relaxed.apply_profile(Profile::Relaxed); + let (_, relaxed_report) = relaxed + .analyze( + &log, + "megasquirt", + &s.mapping, + &axes, + &GeneratorContext::default(), + ) + .expect("runs"); + assert!(relaxed_report.accepted >= report.accepted); +} + +#[test] +fn haltech_blip_log_is_all_rejected_with_a_breakdown() { + let log = haltech_small(); + let g = LambdaDelayGenerator::default(); + let s = suggest_mapping(&log, &g.roles(), None); + let axes = g.default_axes(&log, &s.mapping); + let (events, report) = g + .analyze( + &log, + "haltech", + &s.mapping, + &axes, + &GeneratorContext::default(), + ) + .expect("runs"); + assert!(report.candidates >= 10, "{}", report.summary()); + // RPM 630 -> 3500 in 17 s with constant PW steps: strict gating rejects + // at least 90 %. + assert!( + report.accepted * 10 <= report.candidates, + "strict should reject >= 90 %: {}", + report.summary() + ); + assert!(report.total_rejected() > 0); + assert!(report.summary().contains("rejected")); + // Every event, accepted or not, stays in the list. + assert_eq!(events.len(), report.candidates); + // The Haltech sentinel rows are masked, not treated as readings: the + // wideband column carries -2147483.637 samples and nothing panicked or + // produced an absurd delay. + let wb = column(&log, "Wideband O2 1"); + assert!( + wb.iter().any(|v| *v < -1e6), + "fixture should contain sentinels" + ); + for e in events.iter().filter(|e| e.accepted()) { + assert!(e.value(0).is_finite() && e.value(0) < 2000.0); + } +} + +#[test] +fn rusefi_flat_lambda_warns_and_rejects() { + let log = rusefi(); + let g = LambdaDelayGenerator::default(); + let mut s = suggest_mapping(&log, &g.roles(), None); + // Force the (all-zero) Lambda channel to exercise the flat-sensor path. + s.mapping.set(ChannelRole::Lambda, Some("Lambda".into())); + let axes = g.default_axes(&log, &s.mapping); + let (_, report) = g + .analyze( + &log, + "rusefi", + &s.mapping, + &axes, + &GeneratorContext::default(), + ) + .expect("runs"); + assert_eq!(report.accepted, 0); + assert!( + report.warnings.iter().any(|w| w.contains("never changes")), + "{:?}", + report.warnings + ); +} + +// --------------------------------------------------------------------------- +// Acceleration enrichment +// --------------------------------------------------------------------------- + +#[test] +fn megasquirt_accel_enrich_events_bin_and_export() { + let log = megasquirt(); + let g = GeneratorKind::AccelEnrich.create(); + let s = suggest_mapping(&log, &g.roles(), None); + let axes = g.default_axes(&log, &s.mapping); + assert_eq!(axes.1.unit, "%/s"); + let (events, report) = g + .analyze( + &log, + "megasquirt", + &s.mapping, + &axes, + &GeneratorContext::default(), + ) + .expect("runs"); + assert!(report.accepted >= 20, "{}", report.summary()); + assert!( + report + .warnings + .iter() + .any(|w| w.contains("native throttle rate")) + ); + assert!(report.warnings.iter().any(|w| w.contains("additional"))); + for e in events.iter().filter(|e| e.accepted()) { + assert!( + e.value(0).abs() <= 50.0, + "correction {} outside clamp", + e.value(0) + ); + assert!( + e.axis_value >= 50.0, + "peak rate {} below trigger", + e.axis_value + ); + assert!(e.value(2) >= 0.0); + assert_eq!( + e.value(5), + 120.0, + "assumed delay used without a delay table" + ); + assert!(e.note.contains("ECU AE active")); + } + // Events that the ECU did not enrich are rejected as a kind mismatch + // rather than mixed into an "additional" table. + assert!( + report.rejected.contains_key(&RejectReason::AeKindMismatch), + "{}", + report.summary() + ); + // Tip-ins coincide with the ECU's own enrichment: every accepted event + // has Accel Enrich > 100 % within its window. + let ae = column(&log, "Accel Enrich"); + for e in events.iter().filter(|e| e.accepted()) { + let hit = log + .times + .iter() + .zip(&ae) + .any(|(&t, &v)| t >= e.time && t <= e.time + 2.2 && v > 100.5); + assert!(hit, "event at {} s has no ECU AE activity", e.time); + } + + let mut acc = TableAccumulator::new(GeneratorKind::AccelEnrich, axes, g.measures()); + acc.add_log(7, "megasquirt", events, report); + let grid = acc.grid(0); + let (filled, _) = grid.coverage(); + assert!(filled >= 5, "coverage {filled}"); + let csv = to_csv(&acc, 0, &ExportOptions::default(), "test"); + assert!(csv.contains("# UltraLog Acceleration Enrichment Table - Suggested correction (%)")); + assert!(csv.contains("# Sample counts")); + let tsv = to_clipboard_tsv(&acc, 0, &ExportOptions::default()); + assert!(tsv.starts_with("TPS rate (%/s) \\ RPM\t")); + assert_eq!(tsv.lines().count(), grid.rows() + 1); +} + +#[test] +fn megasquirt_lambda_delay_table_feeds_accel_enrich() { + // Composition: a lambda-delay grid from the same session shifts the AE + // window. With a synthetic High-confidence grid every accepted event + // records "delay from table". + let log = megasquirt(); + let g = GeneratorKind::AccelEnrich.create(); + let s = suggest_mapping(&log, &g.roles(), None); + let axes = g.default_axes(&log, &s.mapping); + let ld = GeneratorKind::LambdaDelay.create(); + let ld_axes = ld.default_axes(&log, &suggest_mapping(&log, &ld.roles(), None).mapping); + let mut points = Vec::new(); + for c in 0..ld_axes.0.bins() { + for r in 0..ld_axes.1.bins() { + for i in 0..8 { + points.push((ld_axes.0.center(c), ld_axes.1.center(r), 200.0 + i as f64)); + } + } + } + let grid = ultralog::analysis::tables::TableGrid::build( + ld_axes.0.clone(), + ld_axes.1.clone(), + points, + Default::default(), + ); + let ctx = GeneratorContext { + delay_table: Some(&grid), + }; + let (events, report) = g + .analyze(&log, "megasquirt", &s.mapping, &axes, &ctx) + .expect("runs"); + assert!(report.accepted > 0, "{}", report.summary()); + assert!( + !report + .warnings + .iter() + .any(|w| w.contains("No lambda delay table")) + ); + for e in events.iter().filter(|e| e.accepted()) { + assert!( + (e.value(5) - 203.5).abs() < 1.0, + "delay used {}", + e.value(5) + ); + assert!(e.note.contains("delay from table")); + } +} + +#[test] +fn haltech_tip_in_detection_matches_the_ecu_transient_channel() { + // The generator's tip-in detector on the native derivative channel must + // overlap the rows where Haltech's own transient-throttle load + // derivative is non-zero, and never fire where it stays at zero for a + // whole second around the event. + let log = haltech_small(); + let rate = column(&log, "Throttle Position Derivative"); + let ecu = column(&log, "Transient Throttle Load Derivative"); + let runs = find_rate_runs(&rate, 50.0, 2); + assert!( + runs.len() >= 3, + "expected several tip-ins, got {}", + runs.len() + ); + let mut covered = 0; + for run in &runs { + let lo = run.start.saturating_sub(10); + let hi = (run.end + 25).min(ecu.len() - 1); + if ecu[lo..=hi].iter().any(|v| *v > 0.0) { + covered += 1; + } + } + assert!( + covered * 10 >= runs.len() * 8, + "only {covered}/{} detected tip-ins overlap ECU AE activity", + runs.len() + ); + // Rows where the ECU applied enrichment: at least 80 % lie within a + // detected run (allowing the ECU's decay tail after the ramp). + let active_rows: Vec = ecu + .iter() + .enumerate() + .filter(|(_, v)| **v > 0.0) + .map(|(i, _)| i) + .collect(); + assert!(!active_rows.is_empty()); + let inside = active_rows + .iter() + .filter(|&&i| runs.iter().any(|r| i + 5 >= r.start && i <= r.end + 40)) + .count(); + assert!( + inside * 10 >= active_rows.len() * 8, + "{inside}/{} ECU-active rows inside detected tip-ins", + active_rows.len() + ); + + // The full generator runs; the free-revving blip log yields events with + // the ECU AE role mapped and a rejection breakdown, never a panic. + let g = GeneratorKind::AccelEnrich.create(); + let s = suggest_mapping(&log, &g.roles(), None); + let axes = g.default_axes(&log, &s.mapping); + let (events, report) = g + .analyze( + &log, + "haltech", + &s.mapping, + &axes, + &GeneratorContext::default(), + ) + .expect("runs"); + assert!(report.candidates >= 3, "{}", report.summary()); + assert_eq!(events.len(), report.candidates); + assert!(report.warnings.iter().any(|w| w.contains("additional"))); +} + +#[test] +fn rusefi_tip_ins_coincide_with_tps_ae_active() { + let log = rusefi(); + let tps = column(&log, "TPS"); + let flag = column(&log, "Fuel: TPS AE Active"); + let rate = ultralog::analysis::statistics::time_derivative( + &ultralog::analysis::filters::median_filter(&tps, 3), + &log.times, + ); + let runs = find_rate_runs(&rate, 50.0, 2); + assert!(!runs.is_empty()); + let active: Vec = flag + .iter() + .enumerate() + .filter(|(_, v)| **v > 0.5) + .map(|(i, _)| i) + .collect(); + assert!(!active.is_empty(), "fixture should have AE activity"); + // Every ECU-active row sits inside or just after a detected tip-in. + let inside = active + .iter() + .filter(|&&i| runs.iter().any(|r| i + 5 >= r.start && i <= r.end + 30)) + .count(); + assert!(inside * 10 >= active.len() * 8, "{inside}/{}", active.len()); +} + +#[test] +fn large_haltech_log_runs_quickly_when_present() { + if !example_file_exists(example_files::HALTECH_LARGE) { + return; + } + let log = Haltech + .parse(&read_example_file(example_files::HALTECH_LARGE)) + .expect("large Haltech log parses"); + for kind in GeneratorKind::ALL { + let g = kind.create(); + let s = suggest_mapping(&log, &g.roles(), None); + let axes = g.default_axes(&log, &s.mapping); + let (_, report) = g + .analyze( + &log, + "large", + &s.mapping, + &axes, + &GeneratorContext::default(), + ) + .expect("runs"); + assert!( + report.computation_time_ms < 5_000, + "{} took {} ms", + g.name(), + report.computation_time_ms + ); + } +} From e0f69561a41cb7d921e255006e706dc299cc03a0 Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Fri, 18 Sep 2026 16:19:23 -0400 Subject: [PATCH 2/6] refactor(tables): promote lambda delay and accel enrichment to top-level tools - ActiveTool gains LambdaDelay and AccelEnrich, plus ALL (switcher, View menu and Cmd+1..5 order) and generator_kind() for the shared match sites - table_generator.rs splits the floating window into a Tool Properties setup panel and a central results view; state stays app-level so the multi-log accumulators survive tab and tool switches - set_active_tool() is the single tool-switch entry point (analytics, selection reset); Jump lands on Log Viewer at the event's time - tools panel section becomes two tool buttons with status lines --- i18n/ar.yaml | 10 +- i18n/bn.yaml | 10 +- i18n/de.yaml | 10 +- i18n/en.yaml | 10 +- i18n/es.yaml | 10 +- i18n/fr.yaml | 10 +- i18n/hi.yaml | 10 +- i18n/id.yaml | 10 +- i18n/it.yaml | 10 +- i18n/ja.yaml | 10 +- i18n/pt-BR.yaml | 10 +- i18n/pt-PT.yaml | 10 +- i18n/ru.yaml | 10 +- i18n/ur.yaml | 10 +- i18n/zh-CN.yaml | 10 +- src/app.rs | 48 +++-- src/ipc/handler.rs | 2 + src/state.rs | 37 ++++ src/ui/table_generator.rs | 366 ++++++++++++++++++-------------- src/ui/tool_properties_panel.rs | 3 + src/ui/tool_switcher.rs | 15 +- src/ui/tools_panel.rs | 23 +- tests/core/state_tests.rs | 29 +++ 23 files changed, 463 insertions(+), 210 deletions(-) diff --git a/i18n/ar.yaml b/i18n/ar.yaml index 2bd7b153..ac4bd74b 100644 --- a/i18n/ar.yaml +++ b/i18n/ar.yaml @@ -9,12 +9,16 @@ menu: export_png: "تصدير كـ PNG..." export_pdf: "تصدير كـ PDF..." export_histogram_pdf: "تصدير المدرج التكراري كـ PDF..." + export_table_png: "تصدير الجدول كـ PNG..." + export_table_pdf: "تصدير الجدول كـ PDF..." view: "عرض" show_grid: "إظهار الشبكة" tool_mode: "وضع الأداة" log_viewer: "عارض السجل" scatter_plots: "المخططات المبعثرة" histogram: "المدرج التكراري" + lambda_delay: "تأخير لامدا" + accel_enrich: "إثراء التسارع" side_panel: "اللوحة الجانبية" files: "الملفات" channels: "القنوات" @@ -170,6 +174,8 @@ toast: export_failed: "فشل التصدير: %{error}" histogram_exported_png: "تم تصدير المدرج التكراري كـ PNG" histogram_exported_pdf: "تم تصدير المدرج التكراري كـ PDF" + table_exported_png: "تم تصدير الجدول كـ PNG (الخلايا فقط؛ استخدم CSV للقيم)" + table_exported_pdf: "تم تصدير الجدول كـ PDF" scatter_exported_png: "تم تصدير المخطط المبعثر كـ PNG" scatter_exported_pdf: "تم تصدير المخطط المبعثر كـ PDF" failed_to_save: "فشل الحفظ: %{error}" @@ -354,7 +360,7 @@ table_gen: section_help: "استخراج الأحداث المسجلة في جداول تأخير لامدا وإثراء التسارع." load_file_hint: "حمّل ملفًا لإنشاء جداول" status: "%{logs} سجل(ات)، %{events} أحداث" - window_title: "مولدات الجداول" + configure_hint: "عيّن القنوات في لوحة خصائص الأداة ثم انقر تشغيل." setup_header: "الإعداد - %{file}" channels: "القنوات" auto_detect: "الكشف التلقائي" @@ -401,6 +407,8 @@ tools: log_viewer: "عارض السجل" scatter_plots: "المخططات المبعثرة" histogram: "المدرج التكراري" + lambda_delay: "تأخير لامدا" + accel_enrich: "إثراء التسارع" # Activity bar (src/ui/activity_bar.rs) activity: diff --git a/i18n/bn.yaml b/i18n/bn.yaml index 49584f33..24af5e90 100644 --- a/i18n/bn.yaml +++ b/i18n/bn.yaml @@ -9,12 +9,16 @@ menu: export_png: "PNG হিসেবে রপ্তানি..." export_pdf: "PDF হিসেবে রপ্তানি..." export_histogram_pdf: "হিস্টোগ্রাম PDF হিসেবে রপ্তানি..." + export_table_png: "টেবিল PNG হিসেবে রপ্তানি..." + export_table_pdf: "টেবিল PDF হিসেবে রপ্তানি..." view: "দৃশ্য" show_grid: "গ্রিড দেখান" tool_mode: "টুল মোড" log_viewer: "লগ ভিউয়ার" scatter_plots: "স্ক্যাটার প্লট" histogram: "হিস্টোগ্রাম" + lambda_delay: "ল্যাম্বডা বিলম্ব" + accel_enrich: "ত্বরণ সমৃদ্ধকরণ" side_panel: "সাইড প্যানেল" files: "ফাইলসমূহ" channels: "চ্যানেলসমূহ" @@ -170,6 +174,8 @@ toast: export_failed: "রপ্তানি ব্যর্থ: %{error}" histogram_exported_png: "হিস্টোগ্রাম PNG হিসেবে রপ্তানি হয়েছে" histogram_exported_pdf: "হিস্টোগ্রাম PDF হিসেবে রপ্তানি হয়েছে" + table_exported_png: "টেবিল PNG হিসেবে রপ্তানি হয়েছে (শুধু সেল; মানের জন্য CSV)" + table_exported_pdf: "টেবিল PDF হিসেবে রপ্তানি হয়েছে" scatter_exported_png: "স্ক্যাটার প্লট PNG হিসেবে রপ্তানি হয়েছে" scatter_exported_pdf: "স্ক্যাটার প্লট PDF হিসেবে রপ্তানি হয়েছে" failed_to_save: "সংরক্ষণ ব্যর্থ: %{error}" @@ -354,7 +360,7 @@ table_gen: section_help: "লগ করা ইভেন্টগুলি ল্যাম্বডা বিলম্ব এবং ত্বরণ সমৃদ্ধকরণ টেবিলে মাইন করুন।" load_file_hint: "টেবিল তৈরি করতে একটি ফাইল লোড করুন" status: "%{logs} লগ(গুলি), %{events} ইভেন্ট" - window_title: "টেবিল জেনারেটর" + configure_hint: "টুল প্রপার্টিজ প্যানেলে চ্যানেল ম্যাপ করুন, তারপর রান ক্লিক করুন।" setup_header: "সেটআপ - %{file}" channels: "চ্যানেল" auto_detect: "স্বয়ংক্রিয় সনাক্ত" @@ -401,6 +407,8 @@ tools: log_viewer: "লগ ভিউয়ার" scatter_plots: "স্ক্যাটার প্লট" histogram: "হিস্টোগ্রাম" + lambda_delay: "ল্যাম্বডা বিলম্ব" + accel_enrich: "ত্বরণ সমৃদ্ধকরণ" # Activity bar (src/ui/activity_bar.rs) activity: diff --git a/i18n/de.yaml b/i18n/de.yaml index 7f4156f6..3e45c472 100644 --- a/i18n/de.yaml +++ b/i18n/de.yaml @@ -9,12 +9,16 @@ menu: export_png: "Als PNG exportieren..." export_pdf: "Als PDF exportieren..." export_histogram_pdf: "Histogramm als PDF exportieren..." + export_table_png: "Tabelle als PNG exportieren..." + export_table_pdf: "Tabelle als PDF exportieren..." view: "Ansicht" show_grid: "Gitter anzeigen" tool_mode: "Werkzeugmodus" log_viewer: "Log-Betrachter" scatter_plots: "Streudiagramme" histogram: "Histogramm" + lambda_delay: "Lambda-Verzögerung" + accel_enrich: "Beschleunigungsanreicherung" side_panel: "Seitenleiste" files: "Dateien" channels: "Kanäle" @@ -170,6 +174,8 @@ toast: export_failed: "Export fehlgeschlagen: %{error}" histogram_exported_png: "Histogramm als PNG exportiert" histogram_exported_pdf: "Histogramm als PDF exportiert" + table_exported_png: "Tabelle als PNG exportiert (nur Zellen; CSV für Werte)" + table_exported_pdf: "Tabelle als PDF exportiert" scatter_exported_png: "Streudiagramm als PNG exportiert" scatter_exported_pdf: "Streudiagramm als PDF exportiert" failed_to_save: "Speichern fehlgeschlagen: %{error}" @@ -354,7 +360,7 @@ table_gen: section_help: "Protokollierte Ereignisse in Lambda-Verzögerungs- und Anreicherungstabellen abbauen." load_file_hint: "Laden Sie eine Datei, um Tabellen zu generieren" status: "%{logs} Log(s), %{events} Ereignisse" - window_title: "Tabellengeneratoren" + configure_hint: "Kanäle im Werkzeugeigenschaften-Panel zuordnen, dann Ausführen klicken." setup_header: "Setup - %{file}" channels: "Kanäle" auto_detect: "Automatisch erkennen" @@ -401,6 +407,8 @@ tools: log_viewer: "Log-Betrachter" scatter_plots: "Streudiagramme" histogram: "Histogramm" + lambda_delay: "Lambda-Verzögerung" + accel_enrich: "Beschleunigungsanreicherung" # Activity bar (src/ui/activity_bar.rs) activity: diff --git a/i18n/en.yaml b/i18n/en.yaml index 33c27315..7d58d1ed 100644 --- a/i18n/en.yaml +++ b/i18n/en.yaml @@ -9,12 +9,16 @@ menu: export_png: "Export as PNG..." export_pdf: "Export as PDF..." export_histogram_pdf: "Export Histogram as PDF..." + export_table_png: "Export Table as PNG..." + export_table_pdf: "Export Table as PDF..." view: "View" show_grid: "Show Grid" tool_mode: "Tool Mode" log_viewer: "Log Viewer" scatter_plots: "Scatter Plots" histogram: "Histogram" + lambda_delay: "Lambda Delay" + accel_enrich: "Accel Enrichment" side_panel: "Side Panel" files: "Files" channels: "Channels" @@ -170,6 +174,8 @@ toast: export_failed: "Export failed: %{error}" histogram_exported_png: "Histogram exported as PNG" histogram_exported_pdf: "Histogram exported as PDF" + table_exported_png: "Table exported as PNG (cells only; use CSV for values)" + table_exported_pdf: "Table exported as PDF" scatter_exported_png: "Scatter plot exported as PNG" scatter_exported_pdf: "Scatter plot exported as PDF" failed_to_save: "Failed to save: %{error}" @@ -354,7 +360,7 @@ table_gen: section_help: "Mine logged events into lambda delay and acceleration enrichment tables." load_file_hint: "Load a file to generate tables" status: "%{logs} log(s), %{events} events" - window_title: "Table Generators" + configure_hint: "Map the channels in the Tool Properties panel, then click Run." setup_header: "Setup - %{file}" channels: "Channels" auto_detect: "Auto-detect" @@ -401,6 +407,8 @@ tools: log_viewer: "Log Viewer" scatter_plots: "Scatter Plots" histogram: "Histogram" + lambda_delay: "Lambda Delay" + accel_enrich: "Accel Enrichment" # Activity bar (src/ui/activity_bar.rs) activity: diff --git a/i18n/es.yaml b/i18n/es.yaml index f1e1e6a4..9029a464 100644 --- a/i18n/es.yaml +++ b/i18n/es.yaml @@ -9,12 +9,16 @@ menu: export_png: "Exportar como PNG..." export_pdf: "Exportar como PDF..." export_histogram_pdf: "Exportar Histograma como PDF..." + export_table_png: "Exportar Tabla como PNG..." + export_table_pdf: "Exportar Tabla como PDF..." view: "Vista" show_grid: "Mostrar cuadrícula" tool_mode: "Modo de Herramienta" log_viewer: "Visor de Log" scatter_plots: "Graficos de Dispersion" histogram: "Histograma" + lambda_delay: "Retardo Lambda" + accel_enrich: "Enriquecimiento de Aceleración" side_panel: "Panel Lateral" files: "Archivos" channels: "Canales" @@ -170,6 +174,8 @@ toast: export_failed: "Error de exportacion: %{error}" histogram_exported_png: "Histograma exportado como PNG" histogram_exported_pdf: "Histograma exportado como PDF" + table_exported_png: "Tabla exportada como PNG (solo celdas; use CSV para valores)" + table_exported_pdf: "Tabla exportada como PDF" scatter_exported_png: "Grafico de dispersion exportado como PNG" scatter_exported_pdf: "Grafico de dispersion exportado como PDF" failed_to_save: "Error al guardar: %{error}" @@ -355,7 +361,7 @@ table_gen: section_help: "Minar eventos registrados en tablas de retraso lambda y enriquecimiento de aceleración." load_file_hint: "Cargue un archivo para generar tablas" status: "%{logs} registro(s), %{events} eventos" - window_title: "Generadores de Tablas" + configure_hint: "Asigne los canales en el panel Propiedades de Herramienta y pulse Ejecutar." setup_header: "Configuración - %{file}" channels: "Canales" auto_detect: "Detección automática" @@ -402,6 +408,8 @@ tools: log_viewer: "Visor de Log" scatter_plots: "Graficos de Dispersion" histogram: "Histograma" + lambda_delay: "Retardo Lambda" + accel_enrich: "Enriquecimiento de Aceleración" # Barra de actividad (src/ui/activity_bar.rs) activity: diff --git a/i18n/fr.yaml b/i18n/fr.yaml index 96251454..59446eba 100644 --- a/i18n/fr.yaml +++ b/i18n/fr.yaml @@ -9,12 +9,16 @@ menu: export_png: "Exporter en PNG..." export_pdf: "Exporter en PDF..." export_histogram_pdf: "Exporter l'histogramme en PDF..." + export_table_png: "Exporter le tableau en PNG..." + export_table_pdf: "Exporter le tableau en PDF..." view: "Affichage" show_grid: "Afficher la grille" tool_mode: "Mode outil" log_viewer: "Visionneuse de journaux" scatter_plots: "Nuages de points" histogram: "Histogramme" + lambda_delay: "Délai Lambda" + accel_enrich: "Enrichissement d'Accélération" side_panel: "Panneau lateral" files: "Fichiers" channels: "Canaux" @@ -170,6 +174,8 @@ toast: export_failed: "Echec de l'exportation : %{error}" histogram_exported_png: "Histogramme exporte en PNG" histogram_exported_pdf: "Histogramme exporte en PDF" + table_exported_png: "Tableau exporté en PNG (cellules seulement ; CSV pour les valeurs)" + table_exported_pdf: "Tableau exporté en PDF" scatter_exported_png: "Nuage de points exporte en PNG" scatter_exported_pdf: "Nuage de points exporte en PDF" failed_to_save: "Echec de l'enregistrement : %{error}" @@ -354,7 +360,7 @@ table_gen: section_help: "Exploiter les événements enregistrés dans les tableaux d'enrichissement de retard lambda et d'accélération." load_file_hint: "Chargez un fichier pour générer des tableaux" status: "%{logs} journal(s), %{events} événements" - window_title: "Générateurs de Tableau" + configure_hint: "Associez les canaux dans le panneau Propriétés de l'outil, puis cliquez sur Exécuter." setup_header: "Configuration - %{file}" channels: "Canaux" auto_detect: "Détection automatique" @@ -401,6 +407,8 @@ tools: log_viewer: "Visionneuse de journaux" scatter_plots: "Nuages de points" histogram: "Histogramme" + lambda_delay: "Délai Lambda" + accel_enrich: "Enrichissement d'Accélération" # Activity bar (src/ui/activity_bar.rs) activity: diff --git a/i18n/hi.yaml b/i18n/hi.yaml index 644074ac..4c1bb8ab 100644 --- a/i18n/hi.yaml +++ b/i18n/hi.yaml @@ -9,12 +9,16 @@ menu: export_png: "PNG के रूप में निर्यात करें..." export_pdf: "PDF के रूप में निर्यात करें..." export_histogram_pdf: "हिस्टोग्राम PDF के रूप में निर्यात करें..." + export_table_png: "तालिका PNG के रूप में निर्यात करें..." + export_table_pdf: "तालिका PDF के रूप में निर्यात करें..." view: "दृश्य" show_grid: "ग्रिड दिखाएं" tool_mode: "टूल मोड" log_viewer: "लॉग व्यूअर" scatter_plots: "स्कैटर प्लॉट" histogram: "हिस्टोग्राम" + lambda_delay: "लैम्ब्डा विलंब" + accel_enrich: "त्वरण संवर्धन" side_panel: "साइड पैनल" files: "फ़ाइलें" channels: "चैनल" @@ -170,6 +174,8 @@ toast: export_failed: "निर्यात विफल: %{error}" histogram_exported_png: "हिस्टोग्राम PNG के रूप में निर्यात किया गया" histogram_exported_pdf: "हिस्टोग्राम PDF के रूप में निर्यात किया गया" + table_exported_png: "तालिका PNG के रूप में निर्यात हुई (केवल सेल; मानों के लिए CSV)" + table_exported_pdf: "तालिका PDF के रूप में निर्यात हुई" scatter_exported_png: "स्कैटर प्लॉट PNG के रूप में निर्यात किया गया" scatter_exported_pdf: "स्कैटर प्लॉट PDF के रूप में निर्यात किया गया" failed_to_save: "सहेजने में विफल: %{error}" @@ -354,7 +360,7 @@ table_gen: section_help: "लॉग की गई घटनाओं को लैम्ब्डा विलंब और त्वरण संवर्धन तालिकाओं में खनन करें।" load_file_hint: "तालिकाएँ बनाने के लिए एक फ़ाइल लोड करें" status: "%{logs} लॉग, %{events} घटनाएँ" - window_title: "टेबल जेनरेटर" + configure_hint: "टूल प्रॉपर्टीज़ पैनल में चैनल मैप करें, फिर रन क्लिक करें।" setup_header: "सेटअप - %{file}" channels: "चैनल" auto_detect: "स्वचालित पहचान" @@ -401,6 +407,8 @@ tools: log_viewer: "लॉग व्यूअर" scatter_plots: "स्कैटर प्लॉट" histogram: "हिस्टोग्राम" + lambda_delay: "लैम्ब्डा विलंब" + accel_enrich: "त्वरण संवर्धन" # Activity bar (src/ui/activity_bar.rs) activity: diff --git a/i18n/id.yaml b/i18n/id.yaml index a2547e85..7e0d5dc0 100644 --- a/i18n/id.yaml +++ b/i18n/id.yaml @@ -9,12 +9,16 @@ menu: export_png: "Ekspor sebagai PNG..." export_pdf: "Ekspor sebagai PDF..." export_histogram_pdf: "Ekspor Histogram sebagai PDF..." + export_table_png: "Ekspor Tabel sebagai PNG..." + export_table_pdf: "Ekspor Tabel sebagai PDF..." view: "Tampilan" show_grid: "Tampilkan Kisi" tool_mode: "Mode Alat" log_viewer: "Penampil Log" scatter_plots: "Diagram Sebar" histogram: "Histogram" + lambda_delay: "Tunda Lambda" + accel_enrich: "Pengayaan Akselerasi" side_panel: "Panel Samping" files: "Berkas" channels: "Kanal" @@ -170,6 +174,8 @@ toast: export_failed: "Ekspor gagal: %{error}" histogram_exported_png: "Histogram diekspor sebagai PNG" histogram_exported_pdf: "Histogram diekspor sebagai PDF" + table_exported_png: "Tabel diekspor sebagai PNG (sel saja; gunakan CSV untuk nilai)" + table_exported_pdf: "Tabel diekspor sebagai PDF" scatter_exported_png: "Diagram sebar diekspor sebagai PNG" scatter_exported_pdf: "Diagram sebar diekspor sebagai PDF" failed_to_save: "Gagal menyimpan: %{error}" @@ -354,7 +360,7 @@ table_gen: section_help: "Menambang peristiwa yang dicatat menjadi tabel pengayaan penundaan lambda dan akselerasi." load_file_hint: "Muat file untuk membuat tabel" status: "%{logs} log, %{events} peristiwa" - window_title: "Pembuat Tabel" + configure_hint: "Petakan kanal di panel Properti Alat, lalu klik Jalankan." setup_header: "Pengaturan - %{file}" channels: "Saluran" auto_detect: "Deteksi Otomatis" @@ -401,6 +407,8 @@ tools: log_viewer: "Penampil Log" scatter_plots: "Diagram Sebar" histogram: "Histogram" + lambda_delay: "Tunda Lambda" + accel_enrich: "Pengayaan Akselerasi" # Activity bar (src/ui/activity_bar.rs) activity: diff --git a/i18n/it.yaml b/i18n/it.yaml index c0357c5b..b1fba6ee 100644 --- a/i18n/it.yaml +++ b/i18n/it.yaml @@ -9,12 +9,16 @@ menu: export_png: "Esporta come PNG..." export_pdf: "Esporta come PDF..." export_histogram_pdf: "Esporta Istogramma come PDF..." + export_table_png: "Esporta Tabella come PNG..." + export_table_pdf: "Esporta Tabella come PDF..." view: "Visualizza" show_grid: "Mostra griglia" tool_mode: "Modalita' Strumento" log_viewer: "Visualizzatore Log" scatter_plots: "Grafici a Dispersione" histogram: "Istogramma" + lambda_delay: "Ritardo Lambda" + accel_enrich: "Arricchimento Accelerazione" side_panel: "Pannello Laterale" files: "File" channels: "Canali" @@ -170,6 +174,8 @@ toast: export_failed: "Esportazione fallita: %{error}" histogram_exported_png: "Istogramma esportato come PNG" histogram_exported_pdf: "Istogramma esportato come PDF" + table_exported_png: "Tabella esportata come PNG (solo celle; CSV per i valori)" + table_exported_pdf: "Tabella esportata come PDF" scatter_exported_png: "Grafico a dispersione esportato come PNG" scatter_exported_pdf: "Grafico a dispersione esportato come PDF" failed_to_save: "Salvataggio fallito: %{error}" @@ -354,7 +360,7 @@ table_gen: section_help: "Estrarre gli eventi registrati in tabelle di arricchimento del ritardo lambda e dell'accelerazione." load_file_hint: "Carica un file per generare tabelle" status: "%{logs} registro(i), %{events} eventi" - window_title: "Generatori di Tabelle" + configure_hint: "Mappa i canali nel pannello Proprietà Strumento, poi premi Esegui." setup_header: "Configurazione - %{file}" channels: "Canali" auto_detect: "Rilevamento automatico" @@ -401,6 +407,8 @@ tools: log_viewer: "Visualizzatore Log" scatter_plots: "Grafici a Dispersione" histogram: "Istogramma" + lambda_delay: "Ritardo Lambda" + accel_enrich: "Arricchimento Accelerazione" # Activity bar (src/ui/activity_bar.rs) activity: diff --git a/i18n/ja.yaml b/i18n/ja.yaml index 406215a8..f820cf66 100644 --- a/i18n/ja.yaml +++ b/i18n/ja.yaml @@ -9,12 +9,16 @@ menu: export_png: "PNGとしてエクスポート..." export_pdf: "PDFとしてエクスポート..." export_histogram_pdf: "ヒストグラムをPDFでエクスポート..." + export_table_png: "テーブルをPNGでエクスポート..." + export_table_pdf: "テーブルをPDFでエクスポート..." view: "表示" show_grid: "グリッドを表示" tool_mode: "ツールモード" log_viewer: "ログビューア" scatter_plots: "散布図" histogram: "ヒストグラム" + lambda_delay: "ラムダ遅延" + accel_enrich: "加速増量" side_panel: "サイドパネル" files: "ファイル" channels: "チャンネル" @@ -170,6 +174,8 @@ toast: export_failed: "エクスポートに失敗しました: %{error}" histogram_exported_png: "ヒストグラムをPNGとしてエクスポートしました" histogram_exported_pdf: "ヒストグラムをPDFとしてエクスポートしました" + table_exported_png: "テーブルをPNGとしてエクスポートしました(セルのみ、数値はCSVを使用)" + table_exported_pdf: "テーブルをPDFとしてエクスポートしました" scatter_exported_png: "散布図をPNGとしてエクスポートしました" scatter_exported_pdf: "散布図をPDFとしてエクスポートしました" failed_to_save: "保存に失敗しました: %{error}" @@ -354,7 +360,7 @@ table_gen: section_help: "ログされたイベントをラムダ遅延と加速エンリッチメントテーブルに分類します。" load_file_hint: "ファイルを読み込んでテーブルを生成します" status: "%{logs}個のログ、%{events}個のイベント" - window_title: "テーブルジェネレータ" + configure_hint: "ツールプロパティパネルでチャンネルを割り当て、実行をクリックしてください。" setup_header: "セットアップ - %{file}" channels: "チャンネル" auto_detect: "自動検出" @@ -401,6 +407,8 @@ tools: log_viewer: "ログビューア" scatter_plots: "散布図" histogram: "ヒストグラム" + lambda_delay: "ラムダ遅延" + accel_enrich: "加速増量" # Activity bar (src/ui/activity_bar.rs) activity: diff --git a/i18n/pt-BR.yaml b/i18n/pt-BR.yaml index 08a7c755..163d656d 100644 --- a/i18n/pt-BR.yaml +++ b/i18n/pt-BR.yaml @@ -9,12 +9,16 @@ menu: export_png: "Exportar como PNG..." export_pdf: "Exportar como PDF..." export_histogram_pdf: "Exportar Histograma como PDF..." + export_table_png: "Exportar Tabela como PNG..." + export_table_pdf: "Exportar Tabela como PDF..." view: "Visualizar" show_grid: "Mostrar Grade" tool_mode: "Modo de Ferramenta" log_viewer: "Visualizador de Logs" scatter_plots: "Gráficos de Dispersão" histogram: "Histograma" + lambda_delay: "Atraso Lambda" + accel_enrich: "Enriquecimento de Aceleração" side_panel: "Painel Lateral" files: "Arquivos" channels: "Canais" @@ -170,6 +174,8 @@ toast: export_failed: "Falha na exportação: %{error}" histogram_exported_png: "Histograma exportado como PNG" histogram_exported_pdf: "Histograma exportado como PDF" + table_exported_png: "Tabela exportada como PNG (apenas células; use CSV para valores)" + table_exported_pdf: "Tabela exportada como PDF" scatter_exported_png: "Gráfico de dispersão exportado como PNG" scatter_exported_pdf: "Gráfico de dispersão exportado como PDF" failed_to_save: "Falha ao salvar: %{error}" @@ -355,7 +361,7 @@ table_gen: section_help: "Minerar eventos registrados em tabelas de enriquecimento de atraso lambda e aceleração." load_file_hint: "Carregue um arquivo para gerar tabelas" status: "%{logs} registro(s), %{events} eventos" - window_title: "Geradores de Tabela" + configure_hint: "Mapeie os canais no painel Propriedades da Ferramenta e clique em Executar." setup_header: "Configuração - %{file}" channels: "Canais" auto_detect: "Detecção automática" @@ -402,6 +408,8 @@ tools: log_viewer: "Visualizador de Logs" scatter_plots: "Gráficos de Dispersão" histogram: "Histograma" + lambda_delay: "Atraso Lambda" + accel_enrich: "Enriquecimento de Aceleração" # Barra de atividades (src/ui/activity_bar.rs) activity: diff --git a/i18n/pt-PT.yaml b/i18n/pt-PT.yaml index 15c1f81a..45c9cf4c 100644 --- a/i18n/pt-PT.yaml +++ b/i18n/pt-PT.yaml @@ -9,12 +9,16 @@ menu: export_png: "Exportar como PNG..." export_pdf: "Exportar como PDF..." export_histogram_pdf: "Exportar Histograma como PDF..." + export_table_png: "Exportar Tabela como PNG..." + export_table_pdf: "Exportar Tabela como PDF..." view: "Ver" show_grid: "Mostrar Grelha" tool_mode: "Modo de Ferramenta" log_viewer: "Visualizador de Registos" scatter_plots: "Gráficos de Dispersão" histogram: "Histograma" + lambda_delay: "Atraso Lambda" + accel_enrich: "Enriquecimento de Aceleração" side_panel: "Painel Lateral" files: "Ficheiros" channels: "Canais" @@ -170,6 +174,8 @@ toast: export_failed: "Falha na exportação: %{error}" histogram_exported_png: "Histograma exportado como PNG" histogram_exported_pdf: "Histograma exportado como PDF" + table_exported_png: "Tabela exportada como PNG (apenas células; use CSV para valores)" + table_exported_pdf: "Tabela exportada como PDF" scatter_exported_png: "Gráfico de dispersão exportado como PNG" scatter_exported_pdf: "Gráfico de dispersão exportado como PDF" failed_to_save: "Falha ao guardar: %{error}" @@ -354,7 +360,7 @@ table_gen: section_help: "Extrair eventos registados em tabelas de enriquecimento de atraso lambda e aceleração." load_file_hint: "Carregue um ficheiro para gerar tabelas" status: "%{logs} registo(s), %{events} eventos" - window_title: "Geradores de Tabela" + configure_hint: "Mapeie os canais no painel Propriedades da Ferramenta e clique em Executar." setup_header: "Configuração - %{file}" channels: "Canais" auto_detect: "Detecção automática" @@ -401,6 +407,8 @@ tools: log_viewer: "Visualizador de Registos" scatter_plots: "Gráficos de Dispersão" histogram: "Histograma" + lambda_delay: "Atraso Lambda" + accel_enrich: "Enriquecimento de Aceleração" # Activity bar (src/ui/activity_bar.rs) activity: diff --git a/i18n/ru.yaml b/i18n/ru.yaml index 3a7792fe..1d146296 100644 --- a/i18n/ru.yaml +++ b/i18n/ru.yaml @@ -9,12 +9,16 @@ menu: export_png: "Экспортировать в PNG..." export_pdf: "Экспортировать в PDF..." export_histogram_pdf: "Экспортировать гистограмму в PDF..." + export_table_png: "Экспортировать таблицу в PNG..." + export_table_pdf: "Экспортировать таблицу в PDF..." view: "Вид" show_grid: "Показывать сетку" tool_mode: "Режим инструмента" log_viewer: "Просмотр логов" scatter_plots: "Диаграммы рассеяния" histogram: "Гистограмма" + lambda_delay: "Задержка лямбда" + accel_enrich: "Обогащение при ускорении" side_panel: "Боковая панель" files: "Файлы" channels: "Каналы" @@ -170,6 +174,8 @@ toast: export_failed: "Ошибка экспорта: %{error}" histogram_exported_png: "Гистограмма экспортирована в PNG" histogram_exported_pdf: "Гистограмма экспортирована в PDF" + table_exported_png: "Таблица экспортирована в PNG (только ячейки; значения в CSV)" + table_exported_pdf: "Таблица экспортирована в PDF" scatter_exported_png: "Диаграмма рассеяния экспортирована в PNG" scatter_exported_pdf: "Диаграмма рассеяния экспортирована в PDF" failed_to_save: "Не удалось сохранить: %{error}" @@ -354,7 +360,7 @@ table_gen: section_help: "Извлечение записанных событий в таблицы задержки лямбда и обогащения ускорения." load_file_hint: "Загрузите файл для создания таблиц" status: "%{logs} журнал(ов), %{events} события" - window_title: "Генераторы таблиц" + configure_hint: "Назначьте каналы в панели Свойства инструмента и нажмите Запустить." setup_header: "Настройка - %{file}" channels: "Каналы" auto_detect: "Автоматическое определение" @@ -401,6 +407,8 @@ tools: log_viewer: "Просмотр логов" scatter_plots: "Диаграммы рассеяния" histogram: "Гистограмма" + lambda_delay: "Задержка лямбда" + accel_enrich: "Обогащение при ускорении" # Activity bar (src/ui/activity_bar.rs) activity: diff --git a/i18n/ur.yaml b/i18n/ur.yaml index 1581905b..1cfc0cab 100644 --- a/i18n/ur.yaml +++ b/i18n/ur.yaml @@ -10,12 +10,16 @@ menu: export_png: "PNG کے طور پر برآمد کریں..." export_pdf: "PDF کے طور پر برآمد کریں..." export_histogram_pdf: "ہسٹوگرام PDF کے طور پر برآمد کریں..." + export_table_png: "ٹیبل PNG کے طور پر برآمد کریں..." + export_table_pdf: "ٹیبل PDF کے طور پر برآمد کریں..." view: "منظر" show_grid: "گرڈ دکھائیں" tool_mode: "ٹول موڈ" log_viewer: "لاگ ویور" scatter_plots: "سکیٹر پلاٹس" histogram: "ہسٹوگرام" + lambda_delay: "لیمبڈا تاخیر" + accel_enrich: "ایکسلریشن افزودگی" side_panel: "سائیڈ پینل" files: "فائلیں" channels: "چینلز" @@ -171,6 +175,8 @@ toast: export_failed: "برآمد ناکام: %{error}" histogram_exported_png: "ہسٹوگرام PNG کے طور پر برآمد ہوگیا" histogram_exported_pdf: "ہسٹوگرام PDF کے طور پر برآمد ہوگیا" + table_exported_png: "ٹیبل PNG کے طور پر برآمد ہوا (صرف خلیے؛ اقدار کے لیے CSV)" + table_exported_pdf: "ٹیبل PDF کے طور پر برآمد ہوا" scatter_exported_png: "سکیٹر پلاٹ PNG کے طور پر برآمد ہوگیا" scatter_exported_pdf: "سکیٹر پلاٹ PDF کے طور پر برآمد ہوگیا" failed_to_save: "محفوظ کرنے میں ناکامی: %{error}" @@ -355,7 +361,7 @@ table_gen: section_help: "لاگ شدہ واقعات کو lambda تاخیر اور سرعت میں اضافے کی میزیں معدن سے نکالیں۔" load_file_hint: "ٹیبل تیار کرنے کے لیے فائل لوڈ کریں" status: "%{logs} لاگ، %{events} واقعات" - window_title: "ٹیبل جنریٹرز" + configure_hint: "ٹول پراپرٹیز پینل میں چینلز میپ کریں، پھر رن دبائیں۔" setup_header: "سیٹ اپ - %{file}" channels: "چینلز" auto_detect: "خودکار دریافت" @@ -402,6 +408,8 @@ tools: log_viewer: "لاگ ویور" scatter_plots: "سکیٹر پلاٹس" histogram: "ہسٹوگرام" + lambda_delay: "لیمبڈا تاخیر" + accel_enrich: "ایکسلریشن افزودگی" # Activity bar (src/ui/activity_bar.rs) activity: diff --git a/i18n/zh-CN.yaml b/i18n/zh-CN.yaml index efd5e2a4..6e490151 100644 --- a/i18n/zh-CN.yaml +++ b/i18n/zh-CN.yaml @@ -9,12 +9,16 @@ menu: export_png: "导出为 PNG..." export_pdf: "导出为 PDF..." export_histogram_pdf: "导出直方图为 PDF..." + export_table_png: "导出表格为 PNG..." + export_table_pdf: "导出表格为 PDF..." view: "视图" show_grid: "显示网格" tool_mode: "工具模式" log_viewer: "日志查看器" scatter_plots: "散点图" histogram: "直方图" + lambda_delay: "Lambda 延迟" + accel_enrich: "加速加浓" side_panel: "侧边栏" files: "文件" channels: "通道" @@ -170,6 +174,8 @@ toast: export_failed: "导出失败: %{error}" histogram_exported_png: "直方图已导出为 PNG" histogram_exported_pdf: "直方图已导出为 PDF" + table_exported_png: "表格已导出为 PNG(仅单元格;数值请使用 CSV)" + table_exported_pdf: "表格已导出为 PDF" scatter_exported_png: "散点图已导出为 PNG" scatter_exported_pdf: "散点图已导出为 PDF" failed_to_save: "保存失败: %{error}" @@ -355,7 +361,7 @@ table_gen: section_help: "将记录的事件挖掘到λ延迟和加速浓缩表中。" load_file_hint: "加载文件以生成表格" status: "%{logs}个日志,%{events}个事件" - window_title: "表格生成器" + configure_hint: "在工具属性面板中映射通道,然后点击运行。" setup_header: "设置 - %{file}" channels: "通道" auto_detect: "自动检测" @@ -402,6 +408,8 @@ tools: log_viewer: "日志查看器" scatter_plots: "散点图" histogram: "直方图" + lambda_delay: "Lambda 延迟" + accel_enrich: "加速加浓" # 活动栏 (src/ui/activity_bar.rs) activity: diff --git a/src/app.rs b/src/app.rs index 81fe571b..ca572aba 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1609,6 +1609,18 @@ impl UltraLogApp { } /// Get the cursor time for the active tab + /// Switch the active tool. The single entry point for the tool switcher, + /// menu radios, and Cmd+1..5, so analytics and table-tool selection + /// reset stay in one place. + pub fn set_active_tool(&mut self, tool: ActiveTool) { + if self.active_tool == tool { + return; + } + self.active_tool = tool; + self.table_generator.clear_selection(); + analytics::track_tool_switched(tool.name()); + } + pub fn get_cursor_time(&self) -> Option { self.active_tab.and_then(|idx| self.tabs[idx].cursor_time) } @@ -2032,19 +2044,20 @@ impl UltraLogApp { return; } - // ⌘1/2/3 - Switch tool modes + // ⌘1..5 - Switch tool modes (same order as ActiveTool::ALL) 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; + const KEYS: [egui::Key; 5] = [ + egui::Key::Num1, + egui::Key::Num2, + egui::Key::Num3, + egui::Key::Num4, + egui::Key::Num5, + ]; + for (key, tool) in KEYS.into_iter().zip(ActiveTool::ALL) { + if i.key_pressed(key) { + self.set_active_tool(tool); + return; + } } } @@ -2141,6 +2154,7 @@ impl UltraLogApp { ActiveTool::LogViewer => self.export_chart_png(), ActiveTool::ScatterPlot => self.export_scatter_plot_png(), ActiveTool::Histogram => self.export_histogram_png(), + ActiveTool::LambdaDelay | ActiveTool::AccelEnrich => self.export_table_png(), } return; } @@ -2290,7 +2304,6 @@ impl eframe::App for UltraLogApp { self.render_computed_channels_manager(ctx); self.render_formula_editor(ctx); self.render_analysis_panel(ctx); - self.render_table_generator(ctx); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { @@ -2355,8 +2368,9 @@ impl eframe::App for UltraLogApp { }); // 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; + let show_timeline = self.get_time_range().is_some() + && self.active_tool != ActiveTool::ScatterPlot + && self.active_tool.generator_kind().is_none(); if show_timeline { egui::Panel::bottom("timeline_panel") @@ -2433,6 +2447,10 @@ impl eframe::App for UltraLogApp { ui.add_space(10.0); self.render_histogram_view(ui); } + ActiveTool::LambdaDelay | ActiveTool::AccelEnrich => { + ui.add_space(10.0); + self.render_table_tool_view(ui); + } } }); } diff --git a/src/ipc/handler.rs b/src/ipc/handler.rs index eed34973..cdd849ba 100644 --- a/src/ipc/handler.rs +++ b/src/ipc/handler.rs @@ -144,6 +144,8 @@ impl UltraLogApp { ActiveTool::LogViewer => "chart".to_string(), ActiveTool::ScatterPlot => "scatter".to_string(), ActiveTool::Histogram => "histogram".to_string(), + ActiveTool::LambdaDelay => "lambda_delay".to_string(), + ActiveTool::AccelEnrich => "accel_enrich".to_string(), }, }; diff --git a/src/state.rs b/src/state.rs index f9a6ce1c..d0a3d2af 100644 --- a/src/state.rs +++ b/src/state.rs @@ -7,6 +7,7 @@ use std::path::PathBuf; use std::sync::OnceLock; use std::sync::atomic::{AtomicU64, Ordering}; +use crate::analysis::tables::GeneratorKind; use crate::colormap::Colormap; use crate::laps::{GpsCoordSpec, LapInfo}; use crate::parsers::{Channel, EcuType, Log}; @@ -270,15 +271,51 @@ pub enum ActiveTool { ScatterPlot, /// Histogram view for 2D distribution analysis Histogram, + /// Lambda delay table generator (RPM x load) + LambdaDelay, + /// Acceleration enrichment table generator (RPM x TPS rate) + AccelEnrich, } impl ActiveTool { + /// Every tool, in tool-switcher / menu / shortcut order (Cmd+1..5). + pub const ALL: [ActiveTool; 5] = [ + ActiveTool::LogViewer, + ActiveTool::ScatterPlot, + ActiveTool::Histogram, + ActiveTool::LambdaDelay, + ActiveTool::AccelEnrich, + ]; + /// Get the display name for this tool pub fn name(&self) -> &'static str { match self { ActiveTool::LogViewer => "Log Viewer", ActiveTool::ScatterPlot => "Scatter Plots", ActiveTool::Histogram => "Histogram", + ActiveTool::LambdaDelay => "Lambda Delay", + ActiveTool::AccelEnrich => "Accel Enrichment", + } + } + + /// The table generator behind this tool, if it is one. + /// + /// Table tools share one code path (`src/ui/table_generator.rs`), so the + /// match sites that only care whether the active tool is a table use this + /// instead of listing both variants. + pub fn generator_kind(self) -> Option { + match self { + ActiveTool::LambdaDelay => Some(GeneratorKind::LambdaDelay), + ActiveTool::AccelEnrich => Some(GeneratorKind::AccelEnrich), + _ => None, + } + } + + /// The tool that hosts a table generator. + pub fn for_generator(kind: GeneratorKind) -> ActiveTool { + match kind { + GeneratorKind::LambdaDelay => ActiveTool::LambdaDelay, + GeneratorKind::AccelEnrich => ActiveTool::AccelEnrich, } } } diff --git a/src/ui/table_generator.rs b/src/ui/table_generator.rs index f872480e..a07133b1 100644 --- a/src/ui/table_generator.rs +++ b/src/ui/table_generator.rs @@ -1,14 +1,20 @@ -//! Table generator window: lambda delay (#4) and acceleration enrichment (#3) +//! Table generator tools: lambda delay (#4) and acceleration enrichment (#3) //! tables mined from the loaded logs. //! -//! Three states, like the analysis panel: -//! 1. **Setup** - channel roles (auto-suggested, ⚠ on ambiguity), axes, -//! parameters, and *Run on current file*. -//! 2. **Results** - Viridis heatmap with value text and a count badge coloured -//! by confidence; hover for the cell tooltip; click for the event inspector -//! with jump-to-time; toolbar for adding / removing logs, measure, export. -//! 3. **Empty** - the run report with its rejection breakdown, so threshold -//! tuning is guided rather than guesswork. +//! Each generator is an `ActiveTool` (`ActiveTool::LambdaDelay`, +//! `ActiveTool::AccelEnrich`), split the way Histogram is: +//! +//! - **Tool Properties panel** (`render_table_tool_properties`) - channel roles +//! (auto-suggested, ⚠ on ambiguity), load axis, axes, parameters, and +//! *Run / Add current file*. +//! - **Central panel** (`render_table_tool_view`) - Viridis heatmap with value +//! text and a count badge coloured by confidence; hover for the cell tooltip; +//! click for the event inspector with jump-to-time; toolbar for removing +//! logs, measure, unit, export. With no accepted events it shows the run +//! report's rejection breakdown so threshold tuning is guided. +//! +//! State is **app-level, not per-tab**: the accumulators are multi-log by +//! design, so switching tabs only changes which file *Run* reads. use std::collections::HashMap; @@ -42,8 +48,6 @@ struct MappingState { /// All table-generator window state, held on `UltraLogApp`. pub struct TableGeneratorState { - pub open: bool, - pub kind: GeneratorKind, generators: HashMap>, mappings: HashMap, pub accumulators: HashMap, @@ -51,8 +55,7 @@ pub struct TableGeneratorState { last_error: Option, measure: HashMap, selected_cell: Option<(usize, usize)>, - export: ExportOptions, - show_setup: bool, + pub export: ExportOptions, show_warnings: bool, } @@ -63,8 +66,6 @@ impl Default for TableGeneratorState { .map(|k| (k, k.create())) .collect(); Self { - open: false, - kind: GeneratorKind::default(), generators, mappings: HashMap::new(), accumulators: HashMap::new(), @@ -73,20 +74,12 @@ impl Default for TableGeneratorState { measure: HashMap::new(), selected_cell: None, export: ExportOptions::default(), - show_setup: true, show_warnings: false, } } } impl TableGeneratorState { - /// Open the window on a generator. - pub fn open_for(&mut self, kind: GeneratorKind) { - self.open = true; - self.kind = kind; - self.selected_cell = None; - } - /// Status line for the tools panel, e.g. `2 logs, 14 events`. pub fn status(&self, kind: GeneratorKind) -> Option { let acc = self.accumulators.get(&kind)?; @@ -103,12 +96,32 @@ impl TableGeneratorState { ) } - fn generator(&self) -> &dyn TableAnalyzer { - self.generators[&self.kind].as_ref() + fn generator(&self, kind: GeneratorKind) -> &dyn TableAnalyzer { + self.generators[&kind].as_ref() } - fn measure_index(&self) -> usize { - self.measure.get(&self.kind).copied().unwrap_or(0) + /// Selected measure for `kind`, clamped to the accumulator's measure list. + pub fn measure_index(&self, kind: GeneratorKind) -> usize { + let n = self + .accumulators + .get(&kind) + .map(|a| a.measures.len()) + .unwrap_or(1); + self.measure + .get(&kind) + .copied() + .unwrap_or(0) + .min(n.saturating_sub(1)) + } + + /// The accumulator for `kind` when it holds at least one log. + pub fn table(&self, kind: GeneratorKind) -> Option<&TableAccumulator> { + self.accumulators.get(&kind).filter(|a| !a.logs.is_empty()) + } + + /// Forget the selected cell (called when the active tool changes). + pub fn clear_selection(&mut self) { + self.selected_cell = None; } } @@ -135,108 +148,127 @@ fn fmt_value(v: f64, decimals: usize) -> String { } impl UltraLogApp { - /// Render the table generator window. - pub fn render_table_generator(&mut self, ctx: &egui::Context) { - if !self.table_generator.open { + /// Central-panel view for a table tool: results, or the empty state. + pub fn render_table_tool_view(&mut self, ui: &mut egui::Ui) { + let Some(kind) = self.active_tool.generator_kind() else { return; - } - let mut open = true; - egui::Window::new(t!("table_gen.window_title")) - .open(&mut open) - .resizable(true) - .default_width(760.0) - .default_height(640.0) - .order(egui::Order::Foreground) - .show(ctx, |ui| { - self.render_table_generator_body(ui); + }; + let has_table = self.table_generator.table(kind).is_some(); + let file_index = self.selected_file.filter(|&i| i < self.files.len()); + + if !has_table { + ui.vertical_centered(|ui| { + ui.add_space(30.0); + if file_index.is_none() { + ui.label( + egui::RichText::new(t!("analysis.no_file_loaded")) + .color(egui::Color32::GRAY) + .size(16.0), + ); + ui.label( + egui::RichText::new(t!("analysis.load_file_help")) + .color(egui::Color32::GRAY) + .small(), + ); + } else { + ui.label( + egui::RichText::new(self.table_generator.generator(kind).description()) + .color(egui::Color32::GRAY) + .size(14.0), + ); + ui.add_space(8.0); + ui.label( + egui::RichText::new(t!("table_gen.configure_hint")) + .color(egui::Color32::GRAY) + .small(), + ); + if let Some(report) = self.table_generator.last_report.get(&kind) { + ui.add_space(12.0); + ui.label( + egui::RichText::new(format!( + "{}: {}", + report.log_name, + report.summary() + )) + .color(egui::Color32::from_rgb(230, 170, 50)), + ); + for w in &report.warnings { + ui.label( + egui::RichText::new(format!("• {w}")) + .small() + .color(egui::Color32::GRAY), + ); + } + } + if let Some(err) = &self.table_generator.last_error { + ui.add_space(8.0); + ui.label( + egui::RichText::new(err).color(egui::Color32::from_rgb(220, 80, 80)), + ); + } + } + ui.add_space(30.0); }); - if !open { - self.table_generator.open = false; + return; } + + egui::ScrollArea::vertical() + .id_salt(format!("table_tool_view_{}", kind.id())) + .show(ui, |ui| { + self.render_table_results(ui, kind); + }); } - fn render_table_generator_body(&mut self, ui: &mut egui::Ui) { - // Generator switcher. - ui.horizontal(|ui| { - for kind in GeneratorKind::ALL { - let name = self.table_generator.generators[&kind].name(); - let selected = self.table_generator.kind == kind; - if ui.selectable_label(selected, name).clicked() && !selected { - self.table_generator.kind = kind; - self.table_generator.selected_cell = None; - self.table_generator.last_error = None; - } - } - }); + /// Tool Properties panel content for a table tool: the setup controls. + pub fn render_table_tool_properties(&mut self, ui: &mut egui::Ui) { + let Some(kind) = self.active_tool.generator_kind() else { + return; + }; + let font_12 = self.scaled_font(12.0); + let font_14 = self.scaled_font(14.0); + + ui.label( + egui::RichText::new(self.table_generator.generator(kind).name()) + .size(font_14) + .strong(), + ); ui.label( - egui::RichText::new(self.table_generator.generator().description()) - .small() + egui::RichText::new(self.table_generator.generator(kind).description()) + .size(font_12) .color(egui::Color32::GRAY), ); - ui.add_space(4.0); + ui.add_space(8.0); let file_index = self.selected_file.filter(|&i| i < self.files.len()); - if file_index.is_none() - && self - .table_generator - .accumulators - .get(&self.table_generator.kind) - .is_none_or(|a| a.is_empty()) - { - ui.vertical_centered(|ui| { - ui.add_space(30.0); - ui.label( - egui::RichText::new(t!("analysis.no_file_loaded")) - .color(egui::Color32::GRAY) - .size(16.0), - ); - ui.label( - egui::RichText::new(t!("analysis.load_file_help")) - .color(egui::Color32::GRAY) - .small(), - ); - ui.add_space(30.0); - }); + let Some(fi) = file_index else { + ui.label( + egui::RichText::new(t!("table_gen.load_file_hint")) + .size(font_12) + .color(egui::Color32::GRAY), + ); return; - } - - if let Some(fi) = file_index { - self.ensure_table_mapping(fi); - } + }; + self.ensure_table_mapping(kind, fi); - egui::ScrollArea::vertical().show(ui, |ui| { - if let Some(fi) = file_index { - let kind = self.table_generator.kind; - let header = t!( - "table_gen.setup_header", - file = self.files[fi].name.as_str() - ); - let open_default = self - .table_generator - .accumulators - .get(&kind) - .is_none_or(|a| a.is_empty()); - egui::CollapsingHeader::new(egui::RichText::new(header.as_ref()).strong()) - .default_open(open_default) - .open(if self.table_generator.show_setup { - None - } else { - Some(false) - }) - .show(ui, |ui| { - self.table_generator.show_setup = true; - self.render_table_setup(ui, fi); - }); - } - ui.add_space(6.0); - self.render_table_results(ui); - }); + ui.label( + egui::RichText::new(t!( + "table_gen.setup_header", + file = self.files[fi].name.as_str() + )) + .size(font_12) + .color(egui::Color32::GRAY), + ); + ui.add_space(4.0); + egui::ScrollArea::vertical() + .id_salt(format!("table_tool_props_{}", kind.id())) + .show(ui, |ui| { + self.render_table_setup(ui, kind, fi); + }); } /// Make sure a mapping exists for the current generator and file, /// re-suggesting when the file changed. - fn ensure_table_mapping(&mut self, file_index: usize) { - let kind = self.table_generator.kind; + fn ensure_table_mapping(&mut self, kind: GeneratorKind, file_index: usize) { let load_id = self.files[file_index].load_id; if self .table_generator @@ -246,11 +278,15 @@ impl UltraLogApp { { return; } - self.suggest_table_mapping(file_index, true); + self.suggest_table_mapping(kind, file_index, true); } - fn suggest_table_mapping(&mut self, file_index: usize, keep_overrides: bool) { - let kind = self.table_generator.kind; + fn suggest_table_mapping( + &mut self, + kind: GeneratorKind, + file_index: usize, + keep_overrides: bool, + ) { let file = &self.files[file_index]; let generator = &self.table_generator.generators[&kind]; let suggestion = suggest_mapping( @@ -282,8 +318,7 @@ impl UltraLogApp { self.table_generator.mappings.insert(kind, state); } - fn render_table_setup(&mut self, ui: &mut egui::Ui, file_index: usize) { - let kind = self.table_generator.kind; + fn render_table_setup(&mut self, ui: &mut egui::Ui, kind: GeneratorKind, file_index: usize) { let channel_names: Vec = self.files[file_index] .log .channels @@ -325,7 +360,7 @@ impl UltraLogApp { kind.id(), spec.role )) - .width(260.0) + .width((ui.available_width() - 40.0).clamp(120.0, 320.0)) .selected_text(shown) .show_ui(ui, |ui| { if ui @@ -400,34 +435,37 @@ impl UltraLogApp { .data_mut(|d| d.insert_temp(egui::Id::new("table_gen_reaxis"), true)); } }); - egui::Grid::new(format!("table_gen_axes_{}", kind.id())) - .num_columns(3) - .spacing([8.0, 4.0]) - .show(ui, |ui| { - for (label, text, axis) in [ - (state.axes.0.header(), &mut state.x_text, &mut state.axes.0), - (state.axes.1.header(), &mut state.y_text, &mut state.axes.1), - ] { - ui.label(label); - let resp = ui.add(egui::TextEdit::singleline(text).desired_width(420.0)); - if resp.changed() - && let Some(edges) = AxisSpec::parse_edges(text.as_str()) - { - *axis = AxisSpec::new(axis.label.clone(), axis.unit.clone(), edges); - } - let valid = AxisSpec::parse_edges(text.as_str()).is_some(); - ui.label(if valid { - egui::RichText::new(t!("table_gen.bins", n = axis.bins())) - .small() - .color(egui::Color32::GRAY) - } else { - egui::RichText::new(t!("table_gen.invalid_axis")) - .small() - .color(egui::Color32::from_rgb(220, 80, 80)) - }); - ui.end_row(); - } + for (i, (text, axis)) in [ + (&mut state.x_text, &mut state.axes.0), + (&mut state.y_text, &mut state.axes.1), + ] + .into_iter() + .enumerate() + { + let valid = AxisSpec::parse_edges(text.as_str()).is_some(); + ui.horizontal(|ui| { + ui.label(axis.header()); + ui.label(if valid { + egui::RichText::new(t!("table_gen.bins", n = axis.bins())) + .small() + .color(egui::Color32::GRAY) + } else { + egui::RichText::new(t!("table_gen.invalid_axis")) + .small() + .color(egui::Color32::from_rgb(220, 80, 80)) + }); }); + let resp = ui.add( + egui::TextEdit::singleline(text) + .id_salt(format!("table_gen_axis_{}_{}", kind.id(), i)) + .desired_width(f32::INFINITY), + ); + if resp.changed() + && let Some(edges) = AxisSpec::parse_edges(text.as_str()) + { + *axis = AxisSpec::new(axis.label.clone(), axis.unit.clone(), edges); + } + } // --- Parameters --------------------------------------------------- ui.add_space(6.0); @@ -547,7 +585,7 @@ impl UltraLogApp { .add_enabled(missing.is_empty() && axes_ok, button) .clicked() { - self.run_table_generator(file_index); + self.run_table_generator(kind, file_index); } if !missing.is_empty() { let names: Vec<&str> = missing.iter().map(|r| r.label()).collect(); @@ -572,7 +610,7 @@ impl UltraLogApp { .data_mut(|d| d.remove_temp::(egui::Id::new("table_gen_reaxis"))) .unwrap_or(false); if redetect { - self.suggest_table_mapping(file_index, false); + self.suggest_table_mapping(kind, file_index, false); } else if reaxis && let Some(state) = self.table_generator.mappings.get_mut(&kind) { let generator = &self.table_generator.generators[&kind]; state.axes = generator.default_axes(&self.files[file_index].log, &state.mapping); @@ -584,8 +622,7 @@ impl UltraLogApp { /// Run the current generator on `file_index` and fold the events into /// the accumulator. Axes are frozen when the accumulator is created; a /// later file with different axes replaces the table. - fn run_table_generator(&mut self, file_index: usize) { - let kind = self.table_generator.kind; + fn run_table_generator(&mut self, kind: GeneratorKind, file_index: usize) { let Some(state) = self.table_generator.mappings.get(&kind).cloned() else { return; }; @@ -634,7 +671,6 @@ impl UltraLogApp { self.table_generator.last_report.insert(kind, report); self.table_generator.last_error = None; self.table_generator.selected_cell = None; - self.table_generator.show_setup = false; self.show_toast(&summary); } Err(e) => { @@ -644,8 +680,7 @@ impl UltraLogApp { } } - fn render_table_results(&mut self, ui: &mut egui::Ui) { - let kind = self.table_generator.kind; + fn render_table_results(&mut self, ui: &mut egui::Ui, kind: GeneratorKind) { let Some(acc) = self.table_generator.accumulators.get(&kind) else { return; }; @@ -653,10 +688,7 @@ impl UltraLogApp { return; } let measures = acc.measures.clone(); - let measure_idx = self - .table_generator - .measure_index() - .min(measures.len().saturating_sub(1)); + let measure_idx = self.table_generator.measure_index(kind); let grid = acc.grid(measure_idx); let logs: Vec<(u64, String)> = acc.logs.iter().map(|l| (l.id, l.name.clone())).collect(); let accepted = acc.accepted_count(); @@ -884,10 +916,9 @@ impl UltraLogApp { } self.table_generator.last_report.remove(&kind); self.table_generator.selected_cell = None; - self.table_generator.show_setup = true; } if export_csv { - self.export_table_csv(); + self.export_table_csv(kind); } if copy { let acc = &self.table_generator.accumulators[&kind]; @@ -1070,16 +1101,27 @@ impl UltraLogApp { if let Some(tab_idx) = self.tabs.iter().position(|t| t.file_index == file_index) { self.active_tab = Some(tab_idx); self.selected_file = Some(file_index); + self.set_active_tool(crate::state::ActiveTool::LogViewer); + // Same sequence as the min/max jump buttons in channels.rs. The + // record is looked up on the event's own file, not `files.first()` + // as `find_record_at_time` does. + let times = self.files[file_index].log.get_times_as_f64(); + let record = times + .partition_point(|&t| t < time) + .min(times.len().saturating_sub(1)); + self.set_cursor_time(Some(time)); + self.set_cursor_record(Some(record)); self.set_jump_to_time(Some(time)); + self.is_playing = false; + self.last_frame_time = None; } } - fn export_table_csv(&mut self) { - let kind = self.table_generator.kind; + fn export_table_csv(&mut self, kind: GeneratorKind) { let Some(acc) = self.table_generator.accumulators.get(&kind) else { return; }; - let measure_idx = self.table_generator.measure_index(); + let measure_idx = self.table_generator.measure_index(kind); let generated = chrono::Local::now().format("%Y-%m-%d %H:%M").to_string(); let csv = to_csv(acc, measure_idx, &self.table_generator.export, &generated); let Some(path) = rfd::FileDialog::new() diff --git a/src/ui/tool_properties_panel.rs b/src/ui/tool_properties_panel.rs index 893c088a..b6fcf830 100644 --- a/src/ui/tool_properties_panel.rs +++ b/src/ui/tool_properties_panel.rs @@ -19,6 +19,9 @@ impl UltraLogApp { ActiveTool::LogViewer => self.render_log_viewer_properties(ui), ActiveTool::Histogram => self.render_histogram_properties(ui), ActiveTool::ScatterPlot => self.render_scatter_plot_properties(ui), + ActiveTool::LambdaDelay | ActiveTool::AccelEnrich => { + self.render_table_tool_properties(ui) + } } } diff --git a/src/ui/tool_switcher.rs b/src/ui/tool_switcher.rs index 2ac0ca89..36054eae 100644 --- a/src/ui/tool_switcher.rs +++ b/src/ui/tool_switcher.rs @@ -6,7 +6,6 @@ use eframe::egui; use rust_i18n::t; -use crate::analytics; use crate::app::UltraLogApp; use crate::state::ActiveTool; @@ -16,14 +15,7 @@ impl UltraLogApp { ui.horizontal(|ui| { ui.add_space(10.0); - // Define available tools - let tools = [ - ActiveTool::LogViewer, - ActiveTool::ScatterPlot, - ActiveTool::Histogram, - ]; - - for tool in tools { + for tool in ActiveTool::ALL { let is_selected = self.active_tool == tool; // Style the button based on selection state @@ -50,6 +42,8 @@ impl UltraLogApp { ActiveTool::LogViewer => t!("tools.log_viewer"), ActiveTool::ScatterPlot => t!("tools.scatter_plots"), ActiveTool::Histogram => t!("tools.histogram"), + ActiveTool::LambdaDelay => t!("tools.lambda_delay"), + ActiveTool::AccelEnrich => t!("tools.accel_enrich"), }; // Create pill-style button @@ -66,8 +60,7 @@ impl UltraLogApp { ); if response.clicked() { - self.active_tool = tool; - analytics::track_tool_switched(tool.name()); + self.set_active_tool(tool); } if response.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); diff --git a/src/ui/tools_panel.rs b/src/ui/tools_panel.rs index 8265385d..43ec0aff 100644 --- a/src/ui/tools_panel.rs +++ b/src/ui/tools_panel.rs @@ -144,9 +144,11 @@ impl UltraLogApp { let status = self.table_generator.status(kind); let enabled = has_file || status.is_some(); ui.horizontal(|ui| { - let btn = egui::Button::new(egui::RichText::new(name).size(font_12)); + let tool = ActiveTool::for_generator(kind); + let btn = egui::Button::new(egui::RichText::new(name).size(font_12)) + .selected(self.active_tool == tool); if ui.add_enabled(enabled, btn).clicked() { - self.table_generator.open_for(kind); + self.set_active_tool(tool); } if let Some(status) = status { ui.label( @@ -321,19 +323,20 @@ impl UltraLogApp { let has_file = self.selected_file.is_some() && !self.files.is_empty(); let has_channels = !self.get_selected_channels().is_empty(); + let has_table = self + .active_tool + .generator_kind() + .is_some_and(|k| self.table_generator.table(k).is_some()); // 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, + ActiveTool::LambdaDelay | ActiveTool::AccelEnrich => has_table, }; - let can_export_pdf = match self.active_tool { - ActiveTool::LogViewer => has_file && has_channels, - ActiveTool::ScatterPlot => has_file, - ActiveTool::Histogram => has_file, - }; + let can_export_pdf = can_export_png; ui.horizontal(|ui| { // PNG Export @@ -363,6 +366,9 @@ impl UltraLogApp { ActiveTool::LogViewer => self.export_chart_png(), ActiveTool::ScatterPlot => self.export_scatter_plot_png(), ActiveTool::Histogram => self.export_histogram_png(), + ActiveTool::LambdaDelay | ActiveTool::AccelEnrich => { + self.export_table_png() + } } } @@ -398,6 +404,9 @@ impl UltraLogApp { ActiveTool::LogViewer => self.export_chart_pdf(), ActiveTool::ScatterPlot => self.export_scatter_plot_pdf(), ActiveTool::Histogram => self.export_histogram_pdf(), + ActiveTool::LambdaDelay | ActiveTool::AccelEnrich => { + self.export_table_pdf() + } } } diff --git a/tests/core/state_tests.rs b/tests/core/state_tests.rs index b08fce27..3f847771 100644 --- a/tests/core/state_tests.rs +++ b/tests/core/state_tests.rs @@ -187,6 +187,35 @@ 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"); + assert_eq!(ActiveTool::LambdaDelay.name(), "Lambda Delay"); + assert_eq!(ActiveTool::AccelEnrich.name(), "Accel Enrichment"); +} + +#[test] +fn test_active_tool_all_is_shortcut_order() { + // Cmd+1..5 index into this array; the first three must not move. + assert_eq!(ActiveTool::ALL.len(), 5); + assert!(ActiveTool::ALL[0] == ActiveTool::LogViewer); + assert!(ActiveTool::ALL[1] == ActiveTool::ScatterPlot); + assert!(ActiveTool::ALL[2] == ActiveTool::Histogram); + assert!(ActiveTool::ALL[3] == ActiveTool::LambdaDelay); + assert!(ActiveTool::ALL[4] == ActiveTool::AccelEnrich); +} + +#[test] +fn test_active_tool_generator_kind_round_trips() { + use ultralog::analysis::tables::GeneratorKind; + for tool in ActiveTool::ALL { + match tool.generator_kind() { + Some(kind) => assert!(ActiveTool::for_generator(kind) == tool), + None => assert!(matches!( + tool, + ActiveTool::LogViewer | ActiveTool::ScatterPlot | ActiveTool::Histogram + )), + } + } + assert!(ActiveTool::LambdaDelay.generator_kind() == Some(GeneratorKind::LambdaDelay)); + assert!(ActiveTool::AccelEnrich.generator_kind() == Some(GeneratorKind::AccelEnrich)); } #[test] From 5660fedc7dbcbafc86dc8f436d2abf7f1fe73900 Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Fri, 18 Sep 2026 16:19:24 -0400 Subject: [PATCH 3/6] feat(export): PNG and PDF export for tuning tables - table_export.rs: pure renderers over TableAccumulator sharing tables::export::cell_value with CSV, so all exports blank and convert identically; PDF carries values, counts and axis labels, PNG is cells and grid lines (no font rasterizer in the crate) - wired into Cmd+E, the File > Export submenu and the Tools panel - guards a missing measure list instead of panicking --- src/analysis/tables/export.rs | 2 +- src/ui/export.rs | 15 +- src/ui/menu.rs | 66 ++-- src/ui/mod.rs | 1 + src/ui/table_export.rs | 549 ++++++++++++++++++++++++++++++++++ 5 files changed, 594 insertions(+), 39 deletions(-) create mode 100644 src/ui/table_export.rs diff --git a/src/analysis/tables/export.rs b/src/analysis/tables/export.rs index f5fa160c..9b537597 100644 --- a/src/analysis/tables/export.rs +++ b/src/analysis/tables/export.rs @@ -98,7 +98,7 @@ fn header_row(grid: &TableGrid, sep: &str) -> String { } /// Value of one cell after options: `None` renders blank. -fn cell_value( +pub fn cell_value( grid: &TableGrid, row: usize, col: usize, diff --git a/src/ui/export.rs b/src/ui/export.rs index a5250aae..174599b3 100644 --- a/src/ui/export.rs +++ b/src/ui/export.rs @@ -12,7 +12,14 @@ use crate::normalize::normalize_channel_name_with_custom; use crate::state::HistogramMode; /// Helper to push text ops into a Vec -fn push_text(ops: &mut Vec, text: &str, size: f32, x: Mm, y: Mm, font: &PdfFontHandle) { +pub(crate) fn push_text( + ops: &mut Vec, + text: &str, + size: f32, + x: Mm, + y: Mm, + font: &PdfFontHandle, +) { ops.push(Op::StartTextSection); ops.push(Op::SetFont { font: font.clone(), @@ -28,7 +35,7 @@ fn push_text(ops: &mut Vec, text: &str, size: f32, x: Mm, y: Mm, font: &PdfF } /// Helper to push a filled rectangle (polygon) into ops -fn push_filled_rect(ops: &mut Vec, x: f32, y: f32, w: f32, h: f32) { +pub(crate) fn push_filled_rect(ops: &mut Vec, x: f32, y: f32, w: f32, h: f32) { let rect = Polygon { rings: vec![PolygonRing { points: vec![ @@ -57,7 +64,7 @@ fn push_filled_rect(ops: &mut Vec, x: f32, y: f32, w: f32, h: f32) { } /// Helper to push a closed line (border) into ops -fn push_closed_line(ops: &mut Vec, points: &[(f32, f32)]) { +pub(crate) fn push_closed_line(ops: &mut Vec, points: &[(f32, f32)]) { let line = Line { points: points .iter() @@ -1592,7 +1599,7 @@ impl UltraLogApp { } /// Draw a line between two points using Bresenham's algorithm -fn draw_line(img: &mut RgbaImage, x0: u32, y0: u32, x1: u32, y1: u32, color: Rgba) { +pub(crate) fn draw_line(img: &mut RgbaImage, x0: u32, y0: u32, x1: u32, y1: u32, color: Rgba) { let dx = (x1 as i32 - x0 as i32).abs(); let dy = -(y1 as i32 - y0 as i32).abs(); let sx: i32 = if x0 < x1 { 1 } else { -1 }; diff --git a/src/ui/menu.rs b/src/ui/menu.rs index e69ac551..ea09ba31 100644 --- a/src/ui/menu.rs +++ b/src/ui/menu.rs @@ -79,7 +79,12 @@ impl UltraLogApp { config.x_channel.is_some() && config.y_channel.is_some() }; - let can_export = has_chart_data || has_histogram_data; + let has_table_data = self + .active_tool + .generator_kind() + .is_some_and(|k| self.table_generator.table(k).is_some()); + + let can_export = has_chart_data || has_histogram_data || has_table_data; ui.add_enabled_ui(can_export, |ui| { ui.menu_button(t!("menu.export"), |ui| { @@ -87,7 +92,16 @@ impl UltraLogApp { .text_styles .insert(egui::TextStyle::Button, egui::FontId::proportional(font_14)); - if self.active_tool == ActiveTool::Histogram && has_histogram_data { + if has_table_data { + if ui.button(t!("menu.export_table_png")).clicked() { + self.export_table_png(); + ui.close(); + } + if ui.button(t!("menu.export_table_pdf")).clicked() { + self.export_table_pdf(); + ui.close(); + } + } else if self.active_tool == ActiveTool::Histogram && has_histogram_data { if ui.button(t!("menu.export_histogram_pdf")).clicked() { self.export_histogram_pdf(); ui.close(); @@ -139,38 +153,22 @@ impl UltraLogApp { .color(egui::Color32::GRAY), ); - if ui - .radio_value( - &mut self.active_tool, - ActiveTool::LogViewer, - t!("menu.log_viewer"), - ) - .on_hover_text("\u{2318}1") - .clicked() - { - ui.close(); - } - if ui - .radio_value( - &mut self.active_tool, - ActiveTool::ScatterPlot, - t!("menu.scatter_plots"), - ) - .on_hover_text("\u{2318}2") - .clicked() - { - ui.close(); - } - if ui - .radio_value( - &mut self.active_tool, - ActiveTool::Histogram, - t!("menu.histogram"), - ) - .on_hover_text("\u{2318}3") - .clicked() - { - ui.close(); + for (i, tool) in ActiveTool::ALL.into_iter().enumerate() { + let label = match tool { + ActiveTool::LogViewer => t!("menu.log_viewer"), + ActiveTool::ScatterPlot => t!("menu.scatter_plots"), + ActiveTool::Histogram => t!("menu.histogram"), + ActiveTool::LambdaDelay => t!("menu.lambda_delay"), + ActiveTool::AccelEnrich => t!("menu.accel_enrich"), + }; + if ui + .radio(self.active_tool == tool, label.as_ref()) + .on_hover_text(format!("\u{2318}{}", i + 1)) + .clicked() + { + self.set_active_tool(tool); + ui.close(); + } } ui.separator(); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 2d9f65a2..7f3d8594 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -53,6 +53,7 @@ pub mod normalization_editor; pub mod scatter_plot; pub mod sidebar; pub mod tab_bar; +pub mod table_export; pub mod table_generator; pub mod timeline; pub mod toast; diff --git a/src/ui/table_export.rs b/src/ui/table_export.rs new file mode 100644 index 00000000..62badb77 --- /dev/null +++ b/src/ui/table_export.rs @@ -0,0 +1,549 @@ +//! PNG and PDF export for generated tuning tables (lambda delay, accel +//! enrichment). +//! +//! The renderers are free functions over a [`TableAccumulator`] so they are +//! unit-testable without an `UltraLogApp`. Cell values, blanking of +//! low-confidence cells, and delay-unit conversion all go through +//! `analysis::tables::export::cell_value`, the same path CSV export uses, so +//! the four exports never disagree. +//! +//! PDF draws values and axis labels with Helvetica. PNG follows the histogram +//! precedent: this crate has no font rasterizer, so PNG is cells and grid +//! lines only. CSV and clipboard remain the numeric exports. + +use std::path::Path; + +use ::image::{Rgba, RgbaImage}; +use printpdf::*; +use rust_i18n::t; + +use crate::analysis::tables::binning::Confidence; +use crate::analysis::tables::export::{ExportOptions, cell_value}; +use crate::analysis::tables::{GeneratorKind, TableAccumulator}; +use crate::analytics; +use crate::app::UltraLogApp; +use crate::colormap::{Colormap, sample}; +use crate::ui::export::{draw_line, push_closed_line, push_filled_rect, push_text}; + +/// Colour for a cell's normalized value. +fn cell_rgb(t: f64) -> [u8; 3] { + let c = sample(Colormap::Viridis, t as f32); + [c.r(), c.g(), c.b()] +} + +/// Black or white text for a Viridis background (WCAG relative luminance). +fn text_on(rgb: [u8; 3]) -> bool { + let lin = |c: u8| { + let v = c as f64 / 255.0; + if v <= 0.03928 { + v / 12.92 + } else { + ((v + 0.055) / 1.055).powf(2.4) + } + }; + let lum = 0.2126 * lin(rgb[0]) + 0.7152 * lin(rgb[1]) + 0.0722 * lin(rgb[2]); + lum > 0.4 +} + +fn fmt_edge(v: f64) -> String { + if v.fract().abs() < 1e-9 { + format!("{}", v as i64) + } else { + format!("{v:.1}") + } +} + +/// `(value, decimals)` per cell after export options; `None` renders blank. +type ResolvedCells = Vec>>; + +/// Resolve every cell to `(value, decimals)` after export options, plus the +/// value range used for colouring. +fn resolve_cells( + acc: &TableAccumulator, + measure_idx: usize, + opts: &ExportOptions, +) -> Result<(ResolvedCells, f64, f64), Box> { + let measure = acc + .measures + .get(measure_idx) + .ok_or("Table has no measures")?; + let grid = acc.grid(measure_idx); + if grid.rows() == 0 || grid.cols() == 0 { + return Err("Table has no cells".into()); + } + let delay_table = acc.generator == GeneratorKind::LambdaDelay; + let mut lo = f64::INFINITY; + let mut hi = f64::NEG_INFINITY; + let cells: ResolvedCells = (0..grid.rows()) + .map(|r| { + (0..grid.cols()) + .map(|c| { + let v = cell_value(&grid, r, c, measure, opts, delay_table); + if let Some((x, _)) = v { + lo = lo.min(x); + hi = hi.max(x); + } + v + }) + .collect() + }) + .collect(); + if !lo.is_finite() { + lo = 0.0; + hi = 1.0; + } + Ok((cells, lo, hi)) +} + +fn normalize(v: f64, lo: f64, hi: f64) -> f64 { + if (hi - lo).abs() < f64::EPSILON { + 0.5 + } else { + ((v - lo) / (hi - lo)).clamp(0.0, 1.0) + } +} + +/// Write the table as a 1920x1080 PNG: Viridis cells, grid lines, no text. +pub fn render_table_png( + acc: &TableAccumulator, + measure_idx: usize, + opts: &ExportOptions, + path: &Path, +) -> Result<(), Box> { + let (cells, lo, hi) = resolve_cells(acc, measure_idx, opts)?; + let grid = acc.grid(measure_idx); + let (rows, cols) = (grid.rows(), grid.cols()); + + let width = 1920u32; + let height = 1080u32; + let margin = 80u32; + let left = margin; + let right = width - margin; + let top = margin; + let bottom = height - margin; + let cell_w = (right - left) as f64 / cols as f64; + let cell_h = (bottom - top) as f64 / rows as f64; + + let mut img = RgbaImage::new(width, height); + for px in img.pixels_mut() { + *px = Rgba([30, 30, 30, 255]); + } + + for (r, row) in cells.iter().enumerate() { + // Highest Y bin at the top, the way ECU tables are laid out. + let draw_row = rows - 1 - r; + let y0 = top as f64 + draw_row as f64 * cell_h; + for (c, cell) in row.iter().enumerate() { + let x0 = left as f64 + c as f64 * cell_w; + let rgb = match cell { + Some((v, _)) => cell_rgb(normalize(*v, lo, hi)), + None => [36, 36, 36], + }; + let px = Rgba([rgb[0], rgb[1], rgb[2], 255]); + let x_end = (x0 + cell_w).min(right as f64) as u32; + let y_end = (y0 + cell_h).min(bottom as f64) as u32; + for y in y0 as u32..y_end { + for x in x0 as u32..x_end { + img.put_pixel(x, y, px); + } + } + } + } + + let line = Rgba([60, 60, 60, 255]); + for c in 0..=cols { + let x = (left as f64 + c as f64 * cell_w).min(right as f64) as u32; + draw_line(&mut img, x, top, x, bottom, line); + } + for r in 0..=rows { + let y = (top as f64 + r as f64 * cell_h).min(bottom as f64) as u32; + draw_line(&mut img, left, y, right, y, line); + } + let border = Rgba([120, 120, 120, 255]); + draw_line(&mut img, left, top, right, top, border); + draw_line(&mut img, left, bottom, right, bottom, border); + draw_line(&mut img, left, top, left, bottom, border); + draw_line(&mut img, right, top, right, bottom, border); + + img.save(path)?; + Ok(()) +} + +/// Write the table as an A4-landscape PDF with values and axis labels. +pub fn render_table_pdf( + acc: &TableAccumulator, + measure_idx: usize, + opts: &ExportOptions, + title: &str, + generated: &str, + path: &Path, +) -> Result<(), Box> { + let (cells, lo, hi) = resolve_cells(acc, measure_idx, opts)?; + let grid = acc.grid(measure_idx); + let (rows, cols) = (grid.rows(), grid.cols()); + let measure = &acc.measures[measure_idx]; + + let font_bold = PdfFontHandle::Builtin(BuiltinFont::HelveticaBold); + let font_regular = PdfFontHandle::Builtin(BuiltinFont::Helvetica); + let mut ops: Vec = Vec::new(); + + // A4 landscape, mm. + let margin: f64 = 15.0; + let label_w: f64 = 22.0; + let label_h: f64 = 8.0; + let chart_left = margin + label_w; + let chart_right: f64 = 297.0 - margin; + let chart_top: f64 = 210.0 - margin - 26.0; + let chart_bottom = margin + label_h + 6.0; + let cell_w = (chart_right - chart_left) / cols as f64; + let cell_h = (chart_top - chart_bottom) / rows as f64; + + let black = Color::Rgb(Rgb::new(0.0, 0.0, 0.0, None)); + let white = Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)); + + ops.push(Op::SetFillColor { col: black.clone() }); + push_text( + &mut ops, + title, + 16.0, + Mm(margin as f32), + Mm(200.0), + &font_bold, + ); + let unit = if acc.generator == GeneratorKind::LambdaDelay && measure.unit == "ms" { + opts.delay_unit.label().to_string() + } else { + measure.unit.to_string() + }; + let logs: Vec<&str> = acc.logs.iter().map(|l| l.name.as_str()).collect(); + let subtitle = format!( + "{} ({}) | {} | Logs: {}", + measure.label, + unit, + generated, + logs.join(", ") + ); + push_text( + &mut ops, + &subtitle, + 9.0, + Mm(margin as f32), + Mm(193.0), + &font_regular, + ); + let (filled, high) = grid.coverage(); + let coverage = format!( + "Rows: {} | Columns: {} | {}/{} cells filled, {} high confidence{}", + grid.y_axis.header(), + grid.x_axis.header(), + filled, + rows * cols, + high, + if opts.exclude_low { + " | low-confidence cells blank" + } else { + "" + } + ); + push_text( + &mut ops, + &coverage, + 8.0, + Mm(margin as f32), + Mm(188.0), + &font_regular, + ); + + // Cells. + let value_size = (cell_h as f32 * 1.6).clamp(5.0, 11.0); + let count_size = (value_size * 0.6).max(4.0); + for (r, row) in cells.iter().enumerate() { + let y = chart_bottom + r as f64 * cell_h; + for (c, cell) in row.iter().enumerate() { + let x = chart_left + c as f64 * cell_w; + let Some((v, decimals)) = cell else { + ops.push(Op::SetFillColor { + col: Color::Rgb(Rgb::new(0.93, 0.93, 0.93, None)), + }); + push_filled_rect(&mut ops, x as f32, y as f32, cell_w as f32, cell_h as f32); + continue; + }; + let rgb = cell_rgb(normalize(*v, lo, hi)); + ops.push(Op::SetFillColor { + col: Color::Rgb(Rgb::new( + rgb[0] as f32 / 255.0, + rgb[1] as f32 / 255.0, + rgb[2] as f32 / 255.0, + None, + )), + }); + push_filled_rect(&mut ops, x as f32, y as f32, cell_w as f32, cell_h as f32); + + let fg = if text_on(rgb) { + black.clone() + } else { + white.clone() + }; + ops.push(Op::SetFillColor { col: fg }); + let text = format!("{v:.decimals$}"); + // Helvetica averages ~0.5em per glyph; centre approximately. + let text_w_mm = text.len() as f64 * value_size as f64 * 0.5 * 0.3528; + push_text( + &mut ops, + &text, + value_size, + Mm((x + (cell_w - text_w_mm) / 2.0).max(x + 0.5) as f32), + Mm((y + cell_h / 2.0 - value_size as f64 * 0.12) as f32), + &font_regular, + ); + if let Some(stats) = grid.cell(r, c) { + // Built-in Helvetica maps `~` to an arrow glyph, so the + // markers stay in plain ASCII: `*` medium, `?` low. + let mark = match stats.confidence { + Confidence::High | Confidence::Empty => "", + Confidence::Medium => "*", + Confidence::Low => "?", + }; + push_text( + &mut ops, + &format!("{}{}", stats.count, mark), + count_size, + Mm((x + 0.6) as f32), + Mm((y + cell_h - count_size as f64 * 0.42) as f32), + &font_regular, + ); + } + } + } + + // Grid lines. + ops.push(Op::SetOutlineColor { + col: Color::Rgb(Rgb::new(0.5, 0.5, 0.5, None)), + }); + ops.push(Op::SetOutlineThickness { pt: Pt(0.25) }); + for c in 0..=cols { + let x = chart_left + c as f64 * cell_w; + push_closed_line( + &mut ops, + &[ + (x as f32, chart_bottom as f32), + (x as f32, chart_top as f32), + ], + ); + } + for r in 0..=rows { + let y = chart_bottom + r as f64 * cell_h; + push_closed_line( + &mut ops, + &[ + (chart_left as f32, y as f32), + (chart_right as f32, y as f32), + ], + ); + } + + // Axis labels: lower edges, X along the bottom, Y down the left. + ops.push(Op::SetFillColor { col: black }); + let axis_size = (cell_w as f32 * 0.9).clamp(5.0, 8.0); + for c in 0..cols { + let x = chart_left + c as f64 * cell_w; + push_text( + &mut ops, + &fmt_edge(grid.x_axis.lower_edge(c)), + axis_size, + Mm((x + 0.6) as f32), + Mm((chart_bottom - 4.0) as f32), + &font_regular, + ); + } + for r in 0..rows { + let y = chart_bottom + r as f64 * cell_h; + push_text( + &mut ops, + &fmt_edge(grid.y_axis.lower_edge(r)), + axis_size, + Mm(margin as f32), + Mm((y + cell_h / 2.0 - 1.0) as f32), + &font_regular, + ); + } + push_text( + &mut ops, + &grid.x_axis.header(), + 8.0, + Mm(chart_left as f32), + Mm((chart_bottom - 10.0) as f32), + &font_bold, + ); + push_text( + &mut ops, + &grid.y_axis.header(), + 8.0, + Mm(margin as f32), + Mm((chart_top + 2.0) as f32), + &font_bold, + ); + + let page = PdfPage::new(Mm(297.0), Mm(210.0), ops); + let mut doc = PdfDocument::new(title); + doc.with_pages(vec![page]); + let mut warnings = Vec::new(); + let bytes = doc.save(&PdfSaveOptions::default(), &mut warnings); + std::fs::write(path, bytes)?; + Ok(()) +} + +impl UltraLogApp { + /// Export the active table tool's table as PNG. + pub fn export_table_png(&mut self) { + let Some(kind) = self.active_tool.generator_kind() else { + return; + }; + let Some(path) = rfd::FileDialog::new() + .add_filter("PNG Image", &["png"]) + .set_file_name(format!("ultralog_{}.png", kind.id())) + .save_file() + else { + return; + }; + let result = { + let Some(acc) = self.table_generator.table(kind) else { + return; + }; + let measure_idx = self.table_generator.measure_index(kind); + render_table_png(acc, measure_idx, &self.table_generator.export, &path) + }; + match result { + Ok(()) => { + analytics::track_export(&format!("{}_png", kind.id())); + self.show_toast_success(&t!("toast.table_exported_png")); + } + Err(e) => self.show_toast_error(&t!("toast.export_failed", error = e.to_string())), + } + } + + /// Export the active table tool's table as PDF. + pub fn export_table_pdf(&mut self) { + let Some(kind) = self.active_tool.generator_kind() else { + return; + }; + let Some(path) = rfd::FileDialog::new() + .add_filter("PDF Document", &["pdf"]) + .set_file_name(format!("ultralog_{}.pdf", kind.id())) + .save_file() + else { + return; + }; + let result = { + let Some(acc) = self.table_generator.table(kind) else { + return; + }; + let measure_idx = self.table_generator.measure_index(kind); + let title = format!("UltraLog {}", self.active_tool.name()); + let generated = chrono::Local::now().format("%Y-%m-%d %H:%M").to_string(); + render_table_pdf( + acc, + measure_idx, + &self.table_generator.export, + &title, + &generated, + &path, + ) + }; + match result { + Ok(()) => { + analytics::track_export(&format!("{}_pdf", kind.id())); + self.show_toast_success(&t!("toast.table_exported_pdf")); + } + Err(e) => self.show_toast_error(&t!("toast.export_failed", error = e.to_string())), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analysis::tables::{AxisSpec, MeasureSpec, RunReport, TableEvent}; + + fn synthetic_accumulator() -> TableAccumulator { + let x = AxisSpec::new("RPM", "rpm", vec![1000.0, 2000.0, 3000.0, 4000.0]); + let y = AxisSpec::new("MAP", "kPa", vec![30.0, 50.0, 70.0]); + let measures = vec![MeasureSpec { + key: "dead_time", + label: "Dead time", + unit: "ms", + decimals: 0, + }]; + let mut acc = TableAccumulator::new(GeneratorKind::LambdaDelay, (x, y), measures); + let events: Vec = (0..12) + .map(|i| TableEvent { + log_id: 1, + log_name: "synthetic.csv".into(), + time: i as f64, + rpm: 1500.0 + (i % 3) as f64 * 1000.0, + axis_value: 40.0 + (i % 2) as f64 * 20.0, + values: vec![100.0 + i as f64 * 5.0], + quality: 1.0, + reject: None, + note: String::new(), + }) + .collect(); + let report = RunReport { + log_name: "synthetic.csv".into(), + candidates: 12, + accepted: 12, + ..Default::default() + }; + acc.add_log(1, "synthetic.csv", events, report); + acc + } + + #[test] + fn png_writes_a_valid_image() { + let acc = synthetic_accumulator(); + let dir = std::env::temp_dir().join(format!("ultralog_table_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("table.png"); + render_table_png(&acc, 0, &ExportOptions::default(), &path).unwrap(); + let img = ::image::open(&path).unwrap(); + assert_eq!(img.width(), 1920); + assert_eq!(img.height(), 1080); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn pdf_writes_a_document_with_values() { + let acc = synthetic_accumulator(); + let dir = std::env::temp_dir().join(format!("ultralog_table_pdf_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("table.pdf"); + render_table_pdf( + &acc, + 0, + &ExportOptions::default(), + "UltraLog Lambda Delay", + "2026-09-18 10:00", + &path, + ) + .unwrap(); + let bytes = std::fs::read(&path).unwrap(); + assert!(bytes.starts_with(b"%PDF")); + assert!(bytes.len() > 1000); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn empty_table_is_an_error_not_a_panic() { + let x = AxisSpec::new("RPM", "rpm", vec![1000.0, 2000.0]); + let y = AxisSpec::new("MAP", "kPa", vec![30.0, 50.0]); + let acc = TableAccumulator::new(GeneratorKind::LambdaDelay, (x, y), vec![]); + let path = std::env::temp_dir().join("ultralog_never_written.png"); + assert!(render_table_png(&acc, 0, &ExportOptions::default(), &path).is_err()); + } + + #[test] + fn text_colour_flips_on_bright_viridis() { + assert!(!text_on(cell_rgb(0.0))); + assert!(text_on(cell_rgb(1.0))); + } +} From b2b60cd171c702ec2cafe6818622b20b3ebf4721 Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Fri, 18 Sep 2026 16:19:24 -0400 Subject: [PATCH 4/6] docs: table generators are top-level tools --- CLAUDE.md | 26 +++++++++++++++++++++----- README.md | 2 +- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a9910795..e2612c95 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,7 +132,8 @@ src/ ├── settings_panel.rs # Consolidated settings (display, units, normalization, updates) ├── tool_properties_panel.rs # Dynamic panel showing controls for the active tool (channels / histogram / scatter) ├── analysis_panel.rs # Window for running analysis algorithms (src/analysis) on the active log - ├── table_generator.rs # Table generator window (setup / heatmap results / event inspector / export) + ├── table_generator.rs # Table tools: Tool Properties setup + central heatmap / inspector view + ├── table_export.rs # PNG / PDF export for generated tables (pure renderers + app wrappers) ├── data_panel.rs # Right-side data panel hosting DataWidget panes (rail, header, hide/restore) ├── widgets/ │ ├── mod.rs # DataWidget trait + static widget registry @@ -145,7 +146,7 @@ src/ ├── toast.rs # Toast notification system ├── icons.rs # Custom icon drawing utilities ├── tab_bar.rs # Multi-file tab interface - ├── tool_switcher.rs # Switch between Log Viewer, Scatter Plot, and Histogram tools + ├── tool_switcher.rs # Switch between the five tools (ActiveTool::ALL) ├── scatter_plot.rs # XY scatter plot visualization ├── histogram.rs # 2D histogram/heatmap view for channel distributions ├── export.rs # PNG and PDF export functionality @@ -291,6 +292,21 @@ low cells are never interpolated and export blank. - **Axis cap** - `binning::MAX_BINS_PER_AXIS` (64) keeps any future MCP payload well under the 512 KiB response guard. `histogram.rs` now calls `binning::uniform_bin` for its cell math so both tools agree on boundaries. +- **Tools, not a window** - `ActiveTool::LambdaDelay` / `ActiveTool::AccelEnrich` render like + Histogram: setup in the Tool Properties panel (`render_table_tool_properties`), results in the + central panel (`render_table_tool_view`). `ActiveTool::generator_kind()` is the one helper the + match sites use, and `ActiveTool::ALL` fixes the switcher / View-menu / Cmd+1..5 order, so a new + tool is appended there and nowhere else. Tool switches go through `UltraLogApp::set_active_tool` + (analytics + table selection reset), not direct assignment. +- **State is app-level, not per-tab** - unlike `Tab::histogram_state`, `UltraLogApp::table_generator` + holds the accumulators, mappings and export options for both generators. The tables are + multi-log by design, so the active tab only decides which file *Run / Add current file* reads; + switching tabs or tools does not lose a table. +- **PNG has no text** - the crate has no font rasterizer, so `table_export::render_table_png` + draws cells and grid lines only (same as the histogram PNG). `render_table_pdf` draws values, + counts and axis labels with built-in Helvetica, whose encoding turns `~` into an arrow, so the + confidence markers are `*` (medium) and `?` (low). Both go through `tables::export::cell_value` + so PNG, PDF, CSV and clipboard blank the same cells and convert delay units identically. - **Auto-suggestion** (`channel_map::suggest_mapping`) - normalization hit (100) → strong name hints (50) → spec category + hint (60) → generic hints (40), then a data-plausibility veto on the channel median; `overall|avg|average` names lose 10 points so a single sensor beats an averaged @@ -372,7 +388,7 @@ UI rendering is split into focused modules that implement methods on `UltraLogAp - **`toast.rs`** - Toast notification overlay for user feedback - **`icons.rs`** - Custom icon drawing (upload icon for drop zone) - **`tab_bar.rs`** - Chrome-style tabs for multi-file support -- **`tool_switcher.rs`** - Switch between Log Viewer, Scatter Plot, and Histogram tools +- **`tool_switcher.rs`** - Switch between the five tools in `ActiveTool::ALL` (Log Viewer, Scatter Plot, Histogram, Lambda Delay, Accel Enrichment) - **`scatter_plot.rs`** - XY scatter plot for channel correlation analysis - **`histogram.rs`** - 2D histogram/heatmap view of channel distributions, with configurable cell coloring (average Z-value or hit count) - **`export.rs`** - PNG and PDF export with chart rendering @@ -553,7 +569,7 @@ The Track Map widget can draw map tile backgrounds. Tiles are **opt-in** (off by - **Multi-ECU Support** - Haltech, ECUMaster, RomRaider, Speeduino, rusEFI, AiM, Link, Emerald, MegaSquirt, TunerStudio MSL, MHD Tuning, Motorsport Electronics, RaceChrono, Woolich Racing Tuned, BlueDriver, DynamicEFI, and Locomotive log formats - **Computed Channels** - Create virtual channels from mathematical formulas with time-shifting (e.g., `RPM[-1]`, `Boost@-0.5s`) - **Analysis Algorithms** - AFR/Lambda drift and zone detection, derived metrics (VE, injector duty cycle), signal filters, and descriptive statistics (`src/analysis/`) -- **Table Generators** - Lambda delay and acceleration enrichment tuning tables mined from one or more logs, with auto-suggested channel roles, confidence-tiered cells, an event inspector, and CSV/clipboard export (`src/analysis/tables/`, `src/ui/table_generator.rs`) +- **Table Generators** - Lambda delay and acceleration enrichment tuning tables mined from one or more logs, each a top-level tool beside Histogram, with auto-suggested channel roles, confidence-tiered cells, an event inspector, and CSV/clipboard/PNG/PDF export (`src/analysis/tables/`, `src/ui/table_generator.rs`, `src/ui/table_export.rs`) - **GPS Track Map** - Right-side data panel with a track map: lap detection, channel-colored polyline (Viridis/Turbo with editable range), hover-scrub/click-seek cursor sync, and opt-in Esri/OSM tile backgrounds (`src/ui/widgets/track_map.rs`, `src/tiles.rs`, `src/laps.rs`). GPS coordinate encodings are auto-detected and normalized to decimal degrees (`GpsCoordSpec` in `src/laps.rs`): NMEA `DDMM.mmmm`, milli/micro/1e-7-scaled integer degrees, and 0-360 longitude. Detection is conservative - values already in valid degree ranges are never transformed, and radians are deliberately not detected (ambiguous with genuine near-equator degree tracks). - **Claude Desktop / MCP Integration** - Embedded MCP server (`src/mcp/`) lets Claude control the running app over `http://localhost:52385/mcp` — select channels, add computed channels, query log data - **Unit Preferences** - Users can select display units for temperature, pressure, speed, distance, fuel economy, volume, flow rate, and acceleration @@ -578,7 +594,7 @@ Handled in `UltraLogApp::handle_keyboard_shortcuts` (`src/app.rs`); ignored whil - **Cmd/Ctrl+O** - Open file - **Cmd/Ctrl+W** - Close current tab - **Cmd/Ctrl+,** - Open Settings panel -- **Cmd/Ctrl+1/2/3** - Switch tool (Log Viewer / Scatter Plot / Histogram) +- **Cmd/Ctrl+1..5** - Switch tool, in `ActiveTool::ALL` order (Log Viewer / Scatter Plot / Histogram / Lambda Delay / Accel Enrichment) - **Cmd/Ctrl+Shift+F/C/T** - Switch side panel (Files / Tool Properties / Tools) - **Arrow Left/Right** - Step cursor one record (Shift = 10 records) - **Home/End** - Jump cursor to start/end of log diff --git a/README.md b/README.md index 2a090604..767c7260 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ Configurable units for 8 measurement categories: - **Scatter Plot** - XY scatter visualization for channel correlation analysis - **Histogram** - 2D heatmap visualization with configurable grid sizes (10x10 to 25x25) for analyzing channel distributions - **MCP Server** - Built-in Model Context Protocol server lets Claude Desktop (or any MCP client) control UltraLog — load files, select channels, get stats, create computed channels, and run analysis via `http://localhost:52453/mcp` -- **Table Generators** - Mine logged events into tuning tables (Tools panel → Table Generators): +- **Table Generators** - Mine logged events into tuning tables. Each is a tool tab beside Histogram (⌘4 / ⌘5), with setup in the Tool Properties panel and CSV, clipboard, PNG and PDF export: - **Lambda Delay Table** - time from injector pulse-width steps to the wideband response, binned by RPM × load, for closed-loop O2 delay tables (ms, engine cycles or ignition events) - **Acceleration Enrichment Table** - tip-in lean/rich excursion depth, duration and a suggested starting correction, binned by RPM × throttle rate, with lambda-delay compensation from a table generated in the same session - Auto-suggested channel roles, per-cell sample counts and confidence, multi-log accumulation, event inspector with jump-to-time, CSV export and tab-separated clipboard copy From a94eaa1815dd40f5e514743ca42aa8aff251e510 Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Fri, 18 Sep 2026 16:25:26 -0400 Subject: [PATCH 5/6] fix(tables): address pre-PR review feedback - last_error is per generator so a failed Lambda Delay run no longer shows under Accel Enrichment - Jump reopens a closed tab via switch_to_file_tab instead of no-op - table PNG/PDF export checks for a table before the save dialog - IPC show_chart / show_scatter_plot go through set_active_tool - PDF axis edges reuse the CSV fmt_edge so both agree - tests: PNG row order pixel probe, exclude_low and unit-conversion paths, measure_index clamp, clear_selection --- src/analysis/tables/export.rs | 3 +- src/app.rs | 2 +- src/ipc/handler.rs | 4 +- src/ui/table_export.rs | 97 +++++++++++++++++++++++++++++++---- src/ui/table_generator.rs | 65 +++++++++++++++++++---- 5 files changed, 149 insertions(+), 22 deletions(-) diff --git a/src/analysis/tables/export.rs b/src/analysis/tables/export.rs index 9b537597..089e0a54 100644 --- a/src/analysis/tables/export.rs +++ b/src/analysis/tables/export.rs @@ -79,7 +79,8 @@ fn fmt_number(v: f64, decimals: usize) -> String { format!("{v:.decimals$}") } -fn fmt_edge(v: f64) -> String { +/// Axis edge label: integers print bare, otherwise two decimals. +pub fn fmt_edge(v: f64) -> String { if (v - v.round()).abs() < 1e-9 { format!("{}", v.round() as i64) } else { diff --git a/src/app.rs b/src/app.rs index ca572aba..49f460fa 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1608,7 +1608,6 @@ impl UltraLogApp { } } - /// Get the cursor time for the active tab /// Switch the active tool. The single entry point for the tool switcher, /// menu radios, and Cmd+1..5, so analytics and table-tool selection /// reset stay in one place. @@ -1621,6 +1620,7 @@ impl UltraLogApp { analytics::track_tool_switched(tool.name()); } + /// Get the cursor time for the active tab pub fn get_cursor_time(&self) -> Option { self.active_tab.and_then(|idx| self.tabs[idx].cursor_time) } diff --git a/src/ipc/handler.rs b/src/ipc/handler.rs index cdd849ba..afca6b17 100644 --- a/src/ipc/handler.rs +++ b/src/ipc/handler.rs @@ -831,7 +831,7 @@ impl UltraLogApp { .position(|c| c.name().eq_ignore_ascii_case(y_channel)); // Switch to scatter plot view - self.active_tool = ActiveTool::ScatterPlot; + self.set_active_tool(ActiveTool::ScatterPlot); // Configure the scatter plot (now we can get mutable borrow) if let Some(state) = self.get_scatter_plot_state_mut() @@ -845,7 +845,7 @@ impl UltraLogApp { } fn handle_show_chart(&mut self) -> IpcResponse { - self.active_tool = ActiveTool::LogViewer; + self.set_active_tool(ActiveTool::LogViewer); IpcResponse::ok() } diff --git a/src/ui/table_export.rs b/src/ui/table_export.rs index 62badb77..f72f277c 100644 --- a/src/ui/table_export.rs +++ b/src/ui/table_export.rs @@ -18,7 +18,7 @@ use printpdf::*; use rust_i18n::t; use crate::analysis::tables::binning::Confidence; -use crate::analysis::tables::export::{ExportOptions, cell_value}; +use crate::analysis::tables::export::{ExportOptions, cell_value, fmt_edge}; use crate::analysis::tables::{GeneratorKind, TableAccumulator}; use crate::analytics; use crate::app::UltraLogApp; @@ -45,14 +45,6 @@ fn text_on(rgb: [u8; 3]) -> bool { lum > 0.4 } -fn fmt_edge(v: f64) -> String { - if v.fract().abs() < 1e-9 { - format!("{}", v as i64) - } else { - format!("{v:.1}") - } -} - /// `(value, decimals)` per cell after export options; `None` renders blank. type ResolvedCells = Vec>>; @@ -399,6 +391,10 @@ impl UltraLogApp { let Some(kind) = self.active_tool.generator_kind() else { return; }; + if self.table_generator.table(kind).is_none() { + self.show_toast_error(&t!("table_gen.no_events")); + return; + } let Some(path) = rfd::FileDialog::new() .add_filter("PNG Image", &["png"]) .set_file_name(format!("ultralog_{}.png", kind.id())) @@ -427,6 +423,10 @@ impl UltraLogApp { let Some(kind) = self.active_tool.generator_kind() else { return; }; + if self.table_generator.table(kind).is_none() { + self.show_toast_error(&t!("table_gen.no_events")); + return; + } let Some(path) = rfd::FileDialog::new() .add_filter("PDF Document", &["pdf"]) .set_file_name(format!("ultralog_{}.pdf", kind.id())) @@ -463,6 +463,7 @@ impl UltraLogApp { #[cfg(test)] mod tests { use super::*; + use crate::analysis::tables::export::DelayUnit; use crate::analysis::tables::{AxisSpec, MeasureSpec, RunReport, TableEvent}; fn synthetic_accumulator() -> TableAccumulator { @@ -532,6 +533,84 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } + #[test] + fn png_draws_the_highest_row_at_the_top() { + // Row 0 (MAP 30-50) holds every event's low value; row 1 is empty. + // On screen the highest Y bin is at the top, so the PNG must put + // row 0's Viridis fill in the bottom band and dark grey in the top. + let x = AxisSpec::new("RPM", "rpm", vec![1000.0, 2000.0]); + let y = AxisSpec::new("MAP", "kPa", vec![30.0, 50.0, 70.0]); + let measures = vec![MeasureSpec { + key: "dead_time", + label: "Dead time", + unit: "ms", + decimals: 0, + }]; + let mut acc = TableAccumulator::new(GeneratorKind::LambdaDelay, (x, y), measures); + let events: Vec = (0..10) + .map(|i| TableEvent { + log_id: 1, + log_name: "s".into(), + time: i as f64, + rpm: 1500.0, + axis_value: 40.0, + values: vec![100.0], + quality: 1.0, + reject: None, + note: String::new(), + }) + .collect(); + acc.add_log(1, "s", events, RunReport::default()); + let dir = std::env::temp_dir().join(format!("ultralog_row_order_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("rows.png"); + render_table_png(&acc, 0, &ExportOptions::default(), &path).unwrap(); + let img = ::image::open(&path).unwrap().to_rgba8(); + let top = img.get_pixel(960, 80 + 200); + let bottom = img.get_pixel(960, 1080 - 80 - 200); + assert_eq!(top.0, [36, 36, 36, 255], "empty row 1 must be at the top"); + assert_ne!( + bottom.0, + [36, 36, 36, 255], + "filled row 0 must be at the bottom" + ); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn resolve_cells_honours_export_options() { + let acc = synthetic_accumulator(); + // Every cell in the fixture has 2 samples -> Low, so blanking low + // cells empties the table and the colour range falls back to 0..1. + let blank = ExportOptions { + exclude_low: true, + ..ExportOptions::default() + }; + let (cells, lo, hi) = resolve_cells(&acc, 0, &blank).unwrap(); + assert!(cells.iter().flatten().all(Option::is_none)); + assert_eq!((lo, hi), (0.0, 1.0)); + + // Unit conversion goes through the same path CSV uses. + let ms = ExportOptions { + exclude_low: false, + ..ExportOptions::default() + }; + let cycles = ExportOptions { + exclude_low: false, + delay_unit: DelayUnit::EngineCycles, + ..ExportOptions::default() + }; + let (a, _, _) = resolve_cells(&acc, 0, &ms).unwrap(); + let (b, _, _) = resolve_cells(&acc, 0, &cycles).unwrap(); + let (va, _) = a[0][0].unwrap(); + let (vb, _) = b[0][0].unwrap(); + // 1000-2000 rpm cell centre is 1500 rpm: cycles = ms * rpm / 120000. + assert!((vb - va * 1500.0 / 120_000.0).abs() < 1e-9); + + // A measure index past the list is an error, not a panic. + assert!(resolve_cells(&acc, 5, &ms).is_err()); + } + #[test] fn empty_table_is_an_error_not_a_panic() { let x = AxisSpec::new("RPM", "rpm", vec![1000.0, 2000.0]); diff --git a/src/ui/table_generator.rs b/src/ui/table_generator.rs index a07133b1..0895e5e6 100644 --- a/src/ui/table_generator.rs +++ b/src/ui/table_generator.rs @@ -52,7 +52,7 @@ pub struct TableGeneratorState { mappings: HashMap, pub accumulators: HashMap, last_report: HashMap, - last_error: Option, + last_error: HashMap, measure: HashMap, selected_cell: Option<(usize, usize)>, pub export: ExportOptions, @@ -70,7 +70,7 @@ impl Default for TableGeneratorState { mappings: HashMap::new(), accumulators: HashMap::new(), last_report: HashMap::new(), - last_error: None, + last_error: HashMap::new(), measure: HashMap::new(), selected_cell: None, export: ExportOptions::default(), @@ -200,7 +200,7 @@ impl UltraLogApp { ); } } - if let Some(err) = &self.table_generator.last_error { + if let Some(err) = self.table_generator.last_error.get(&kind) { ui.add_space(8.0); ui.label( egui::RichText::new(err).color(egui::Color32::from_rgb(220, 80, 80)), @@ -596,7 +596,7 @@ impl UltraLogApp { ); } }); - if let Some(err) = &self.table_generator.last_error { + if let Some(err) = self.table_generator.last_error.get(&kind) { ui.label(egui::RichText::new(err).color(egui::Color32::from_rgb(220, 80, 80))); } @@ -669,12 +669,12 @@ impl UltraLogApp { let summary = report.summary(); acc.add_log(file.load_id, &file.name, events, report.clone()); self.table_generator.last_report.insert(kind, report); - self.table_generator.last_error = None; + self.table_generator.last_error.remove(&kind); self.table_generator.selected_cell = None; self.show_toast(&summary); } Err(e) => { - self.table_generator.last_error = Some(e.to_string()); + self.table_generator.last_error.insert(kind, e.to_string()); self.show_toast_error(&e.to_string()); } } @@ -1098,9 +1098,9 @@ impl UltraLogApp { self.show_toast_warning(&t!("table_gen.log_unloaded")); return; }; - if let Some(tab_idx) = self.tabs.iter().position(|t| t.file_index == file_index) { - self.active_tab = Some(tab_idx); - self.selected_file = Some(file_index); + { + // Reopens the tab if it was closed with Cmd+W; the file is still loaded. + self.switch_to_file_tab(file_index); self.set_active_tool(crate::state::ActiveTool::LogViewer); // Same sequence as the min/max jump buttons in channels.rs. The // record is looked up on the event's own file, not `files.first()` @@ -1143,3 +1143,50 @@ impl UltraLogApp { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::analysis::tables::{AxisSpec, MeasureSpec}; + + #[test] + fn measure_index_clamps_to_the_accumulator() { + let mut state = TableGeneratorState::default(); + let kind = GeneratorKind::LambdaDelay; + // No accumulator yet: whatever is stored, the index is 0. + state.measure.insert(kind, 7); + assert_eq!(state.measure_index(kind), 0); + let x = AxisSpec::new("RPM", "rpm", vec![1000.0, 2000.0]); + let y = AxisSpec::new("MAP", "kPa", vec![30.0, 50.0]); + let measures = vec![ + MeasureSpec { + key: "a", + label: "a", + unit: "ms", + decimals: 0, + }, + MeasureSpec { + key: "b", + label: "b", + unit: "ms", + decimals: 0, + }, + ]; + state + .accumulators + .insert(kind, TableAccumulator::new(kind, (x, y), measures)); + assert_eq!(state.measure_index(kind), 1); + state.measure.insert(kind, 0); + assert_eq!(state.measure_index(kind), 0); + } + + #[test] + fn clear_selection_forgets_the_cell() { + let mut state = TableGeneratorState { + selected_cell: Some((1, 2)), + ..Default::default() + }; + state.clear_selection(); + assert_eq!(state.selected_cell, None); + } +} From 1ab314822dd8e9090e6e7c5ccc6556213e0ab40e Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Fri, 18 Sep 2026 16:30:27 -0400 Subject: [PATCH 6/6] chore(release): bump to 2.15.0 and add tuning table generators to the site - Cargo.toml, README shield, JSON-LD softwareVersion, hero badge, and releaseNotes tag all move to 2.15.0 - What's New: Lambda Delay Tables and Accel Enrichment Tables cards lead the grid - SEO: meta description, keywords, JSON-LD description and featureList mention the table generators - Sitemap lastmod refreshed --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 2 +- docs/index.html | 30 +++++++++++++++++++++++------- docs/sitemap.xml | 2 +- 5 files changed, 27 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 808b0aca..81387308 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4578,7 +4578,7 @@ checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "ultralog" -version = "2.14.1" +version = "2.15.0" dependencies = [ "anyhow", "arboard", diff --git a/Cargo.toml b/Cargo.toml index a22e105b..a221b772 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ultralog" -version = "2.14.1" +version = "2.15.0" edition = "2024" # egui/eframe 0.36 is the binding constraint on the minimum supported Rust # version; edition 2024 itself only needs 1.85. diff --git a/README.md b/README.md index 767c7260..7c981684 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.14.1-green.svg) +![Version](https://img.shields.io/badge/version-2.15.0-green.svg) --- diff --git a/docs/index.html b/docs/index.html index 8cd0fc88..2ec9e0a3 100644 --- a/docs/index.html +++ b/docs/index.html @@ -9,9 +9,9 @@ UltraLog - Free ECU Log Viewer & Analyzer | Haltech, ECUMaster, Speeduino, AiM & More + content="UltraLog is a free, open-source ECU datalog viewer built with Rust. Analyze logs from Haltech, ECUMaster, RomRaider, Speeduino, rusEFI, AiM, Link, Emerald, BlueDriver, MegaSquirt, TunerStudio MSL, MHD, Woolich, and RaceChrono lap-timing sessions. Features include lambda delay and acceleration enrichment table generators, stacked plot areas, histogram heatmaps, scatter plots, computed channels, MCP server for AI integration, and professional analysis tools. Download for Windows, macOS, and Linux."> + content="ECU log viewer, datalog analyzer, Haltech NSP, ECUMaster EMU Pro, RomRaider, Speeduino MLG, rusEFI, AiM XRK DRK, Link ECU LLG, Emerald K6 M3D, automotive tuning software, engine tuning, AFR analysis, boost log, free tuning software, open source ECU, car data logger, dyno analysis, motorsport data, fuel map analysis, ignition timing, lambda analysis, volumetric efficiency, injector duty cycle, Butterworth filter, scatter plot, histogram heatmap, computed channels, Rust application, stacked plots, MCP server, Model Context Protocol, Claude Desktop, BlueDriver OBD-II, AI log analysis, RaceChrono CSV, TunerStudio MSL, RealDash log, lap timing analysis, track day data, GPS track map, lambda delay table, O2 delay table, acceleration enrichment, accel enrichment table, transient fuel tuning"> @@ -107,13 +107,13 @@ "@type": "SoftwareApplication", "name": "UltraLog", "alternateName": ["UltraLog ECU Viewer", "UltraLog Datalog Analyzer"], - "description": "A high-performance, cross-platform ECU log viewer and analyzer built with Rust. Supports Haltech, ECUMaster, RomRaider, Speeduino, rusEFI, AiM, Link, Emerald, MegaSquirt, TunerStudio MSL, MHD, Motorsport Electronics, RaceChrono, Woolich, BlueDriver, DynamicEFI, and Locomotive log formats with advanced analysis tools.", + "description": "A high-performance, cross-platform ECU log viewer and analyzer built with Rust. Supports Haltech, ECUMaster, RomRaider, Speeduino, rusEFI, AiM, Link, Emerald, MegaSquirt, TunerStudio MSL, MHD, Motorsport Electronics, RaceChrono, Woolich, BlueDriver, DynamicEFI, and Locomotive log formats with advanced analysis tools, including lambda delay and acceleration enrichment table generators.", "url": "https://ultralog.co/", "applicationCategory": "UtilitiesApplication", "applicationSubCategory": "Automotive Software", "operatingSystem": ["Windows 10", "Windows 11", "macOS", "Linux"], - "softwareVersion": "2.14.1", - "releaseNotes": "https://github.com/ClassicMiniDIY/UltraLog/releases/tag/v2.14.1", + "softwareVersion": "2.15.0", + "releaseNotes": "https://github.com/ClassicMiniDIY/UltraLog/releases/tag/v2.15.0", "downloadUrl": "https://github.com/ClassicMiniDIY/UltraLog/releases/latest", "installUrl": "https://github.com/ClassicMiniDIY/UltraLog/releases/latest", "screenshot": [ @@ -147,6 +147,8 @@ }, "featureList": [ "Multi-channel time series visualization", + "Lambda delay table generator", + "Acceleration enrichment table generator", "2D histogram heatmaps", "XY scatter plot analysis", "Computed math channels with formulas", @@ -1427,7 +1429,7 @@

Unlock Your Performanc