diff --git a/CLAUDE.md b/CLAUDE.md index 7ab3ecc..a991079 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 d1953d9..2a09060 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 0000000..7b0b5e4 --- /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 e11d043..2bd7b15 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 de515f1..49584f3 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 2f24459..7f4156f 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 e3bd087..33c2731 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 80c3cf0..f1e1e6a 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 5c7df8f..9625145 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 95224fe..644074a 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 d92e5f7..a2547e8 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 97ee1fd..c0357c5 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 28e3e82..406215a 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 c72cb90..08a7c75 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 59ecb0a..15c1f81 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 a23d814..3a7792f 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 3aa6041..1581905 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 3d8486f..efd5e2a 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 17de11e..a31f595 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 0000000..3cde785 --- /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 0000000..1653700 --- /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 0000000..d7784e0 --- /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 0000000..99ccfc0 --- /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 0000000..f5fa160 --- /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 0000000..6d3d4f0 --- /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 0000000..3b2b84e --- /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 0000000..76d20e2 --- /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 0000000..5f75c5f --- /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 7866112..81fe571 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 61b5bce..32e2dfd 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 0569abc..f9a6ce1 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 9a9c46d..9f402a4 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 2b8b9e7..2d9f65a 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 0000000..f872480 --- /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 3c3b91a..8265385 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 12a2aa2..b7fa5ef 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 0000000..d376b68 --- /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 + ); + } +}