Files
samplez/tunie/research/TUNING_IMPL_PLAN.md
uhryniuk 4aa5da53d2 Commit tune maps and research so tunes are reachable from the phone
Removes the *.hex/maps_cache gitignore rule (explicit user call, reversing
the earlier no-redistribution stance) so the official TuneECU catalogue
maps, derived SAI/O2-delete composites, and the checksum/composition
tooling are actually available to pull up on a phone browser when using
the real TuneECU app. Also folds in tonight's KWP2000 fixes (TesterPresent
keep-alive, connect-failure cleanup, slow-init StartCommunication fix) and
the accumulated research docs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FP2GaxS9HkUdL5sLBnjKje
2026-08-27 01:34:05 -05:00

437 lines
25 KiB
Markdown

# Implementation plan: ECU-streamed, onboard-logged road tuning
Concrete engineering plan for the approach settled on in
[`TUNING_GUIDE.md`](TUNING_GUIDE.md#better-idea-aug-2026-stream-live-data-straight-off-the-ecu-with-tunie):
stream RPM/TPS/coolant live off the ECU via `tunie`'s existing read-only
KWP2000 transport, sample a wideband AFR sensor on the same onboard unit,
write one synced log, post-process off-bike into F Trim corrections. This
document is the task list; `TUNING_GUIDE.md` and `COMMUNITY_TUNING.md` are
the research backing each decision here.
**Status: not started.** Nothing in this plan has bike contact yet — Phase
0 is pure software and can begin immediately; everything after Phase 1
is blocked on the cable/connector work already tracked in `../STATUS.md`.
## The near-term validation game plan
Six concrete steps, checked against everything in this document and
`TUNING_GUIDE.md`, with the gaps filled in:
**0. Fix the cable.** Not one of the six steps below, but it's the actual
current blocker sitting in front of all of them — the Triumph diagnostic
connector adapter, tracked in `../STATUS.md` since before this tuning plan
existed. Nothing past this point has bike contact until it's solved.
**1. Prove basic ECU comms with `tunie` — `tunie info`.** Already the
correct first step per `../STATUS.md`; this plan doesn't change it. Gives
ECU identity, current map ID/DTCs, and — as a side effect — confirms the
cable/protocol work before adding live-poll complexity on top.
**2. Yank the current tune off the bike — real gap, needs a scope
decision, not just a step.** A ROM/map dump uses KWP2000 service `0x35`
RequestUpload, which `tunie` **deliberately does not implement** —
`safety.py` refuses it by construction, on purpose, as this whole
project's core safety guarantee (`README.md`: "Cannot write to the ECU by
construction"). Building this into `tunie` means consciously opening a
door that's been kept shut for a reason, not just adding a feature.
**Simpler alternative, use before building anything new:** TuneECU's own
Android app already has a working "Read Map" function
(`android.html`: "Read Map: Read the map from the ECU, only possible via
cable connection"). Use that for this one step — it's already built,
already proven, and its output (a `.hex` file) is exactly the input format
`decode_map.py`/`reconstruct_rom.py`/`table_map.py` already work with. No
reason to reimplement upload capability in `tunie` just to get a file
these tools already know how to read. Once dumped, cross-check its
signature/ID against `maps.json` to confirm exactly which stock
calibration is actually flashed — resolves an assumption this whole
research effort has been making (which map family applies) rather than
just producing a file.
**If "Read Map" ever fails partway through, don't panic-troubleshoot —
here's what's actually happening.** Traced TuneECU's Recovery mode
(`research/RECOVERY_MODE.md`) specifically so this is known *before* it's
needed rather than discovered live. Short version: Recovery is the same
reprogram function, relabeled — TuneECU checks whether the currently-open
map file decodes to a valid signature (same magic-header + directory
lookup `decode_map.py`/`table_map.py` already implement) and offers
Recovery instead of Reprogram if so. It is **not** detecting the ECU's
live fault state — it's checking you have a complete, valid base map ready.
Practical implication: **keep a matching OEM `.hex` (from `maps.json`,
already downloaded — `20187`/`20191` etc.) on hand before attempting the
dump**, so if the read fails partway, the documented community recovery
procedure is available: cycle ignition off/on, wait ~5s, open that
matching OEM map, reconnect, retry. Full trace, including what's still
unconfirmed (whether a live ECU-side signal is also involved), in
`RECOVERY_MODE.md`.
**3. Get live data, see what it actually gives us.** Two sub-parts, not
one:
- *What's there:* poll the candidate record list (Phase 0/1 below) and
see what responds at all.
- *Labeling, as its own deliberate task, not passive observation:*
ignition on/engine off, move the throttle by hand, watch which record
jumps → that's TPS. Start the engine, compare a record's value
against the tach → RPM. Let it idle and warm up, watch what climbs
slowly → coolant. **Contingency:** the candidate record list
(`0x0100`, `0x0001`, `0x5103`, `0x0139`, `0x0007`) is one plausible
branch recovered from the decompile, not a confirmed-correct one for
this specific bike (see Phase 0/1 above) — if none of them behave
sensibly, the fallback is a wider sweep of nearby record values and/or
going back to trace the `Oe()` switch case selection more rigorously
rather than assuming the guess was right.
- **Basic bench-test safety:** ignition-on/engine-off first, always;
if it gets to engine-running tests, that needs a stand and
ventilation considerations like any other bench run — not a research
question, just don't skip it.
**4. Design and build the onboard live-capture system.** Pi Zero (or
similar) running `tunie`'s poll loop + an ADC reading the wideband
controller's analog output, writing one synced log. CSV or SQLite both
fine for this — SQLite's nicer once you're joining/querying against table
geometry later, CSV is simpler to just eyeball. This is Phase 3 in this
document; **the wideband sensor/controller/mounting setup you already
flagged as probably-missing is real and belongs here** — see "Phase 2"
above (hardware, bung reuse, sensor count decision) for what that actually
involves; it's not just "buy a sensor," it's a genuine sub-project of its
own (mounting decision, wiring, warm-up handling, thread confirmation).
**5. Verify the captured data locally, build logs from it.** Sanity-check
against known references before trusting it: idle RPM should match a
handheld tach, TPS should track smoothly with a slow throttle sweep,
coolant should rise monotonically from cold start, AFR should read near
14.7 at a stable idle before any tune changes. This step is really "does
the whole capture chain produce believable numbers," which is a
prerequisite before trusting it for step 6.
**6. Compare live ride samples against the current map.** This is Phase 4
(post-processing) — map each logged point to a fuel-table cell using the
already-known RPM/throttle axes (`table_map.py`), see how far off the
stock table is from what's actually happening on the modified bike.
**What this game plan does *not* yet cover, worth naming explicitly:**
it's a validation/prototyping pass — proving the whole chain works and
seeing what the data looks like — not yet the actual iterative tuning
loop (F Trim corrections → Commit trims → reflash → repeat →
final AF1/AF2 targets, Phase 5 above). That's the next phase after this
one succeeds, not part of it.
---
## Phase 0 — software groundwork (no bike required, start now)
**Goal:** `tunie` can poll 2-byte extended local identifiers in a loop,
not just the one-shot single-byte sweep it does today.
- [ ] Add 2-byte record support to the local-identifier path. Today
`src/tunie/identify.py` sends `0x21 <1 byte>` (`LOCAL_ID_RECORDS`, single
byte). TuneECU's own dashboard sends `0x21 <hi> <lo>` for a wider record
space — confirmed in the decompile (`m.java`'s `se()`, records like
`0x0100`, `0x5103`, `0x0139`). Extend `Transport.request()` callers to
accept a 2-byte record variant; `safety.py`'s allowlist already permits
`0x21` generally (per `triumph.py`'s `OBSERVED_READ_SERVICES`), just
confirm it doesn't assume single-byte payload length anywhere.
- [ ] Add a **polling-loop mode**, distinct from `identify`'s one-shot
sweep: given a list of records, poll each in round-robin at the fastest
safe rate, timestamp every response, write to a log file (CSV or
similar). This is the core of what's needed — everything else in this
plan depends on it existing.
- [ ] Candidate record list to poll, from the recovered `ch` array
(Keihin-family default case, `m.java:204`): `0x0100`, `0x0001`,
`0x5103`, `0x0139`, `0x0007`. Unlabeled — Phase 1 resolves which is
which. Poll all of them; drop the unused ones once labeled.
- [ ] Write the labeling capture script: connects, polls all candidate
records continuously, dumps a live table to the terminal (record → raw
value, updating) so a person can watch and correlate against what
they're doing to the bike (revving, moving throttle by hand, waiting for
it to warm up).
- [ ] Decide the log format now, since it constrains the post-processing
script (Phase 4): one row per poll cycle, columns `timestamp_ms, rpm,
tps_raw, coolant_raw, <spare records>`. Keep raw values through this
layer — scaling/unit conversion happens once records are labeled and
their encoding is known (may not be linear; TPS especially could be a
raw ADC count needing calibration against the known throttle axis
breakpoints in `reference-maps/TABLES.md`).
**Acceptance:** a script exists that, given a connected bike, would print
a live-updating table of all candidate records with timestamps, with no
bike required to review/test the code path structurally (mock transport
for a dry run is enough to validate the polling loop and log-writing
logic before real hardware is available).
---
## Phase 1 — first ECU contact + record labeling (blocked on cable)
**Blocker:** the Triumph diagnostic connector adapter, per `../STATUS.md`
— unchanged blocker, not something this plan resolves.
**Goal:** know which of the candidate records is RPM, which is TPS, which
is coolant temp (and what any leftover ones are).
- [ ] Run `tunie info` for the first time — the original Phase-1 goal from
`../STATUS.md`, still the correct first step regardless of this plan.
Confirms cable/protocol work before adding the live-poll complexity.
- [ ] Ignition on, engine off (safe, no combustion risk): run the labeling
script from Phase 0. Move the throttle by hand — whichever record jumps
in response is TPS. Note idle values for the others.
- [ ] Engine running, idle: whichever record now shows a plausible RPM
value (four-figures, matches tach) is RPM. Blip the throttle to confirm
it tracks.
- [ ] Let the engine sit and warm up: whichever record climbs slowly over
minutes is coolant temp.
- [ ] Determine each record's raw→real-units scaling by comparing against
a known reference (tach for RPM, a multimeter on the TPS sensor wire or
the throttle's marked positions for TPS, a rough ambient/operating-temp
sanity check for coolant). Write the results into `triumph.py` or a new
`live_records.py` module, with the same "recovered from X, confidence Y"
documentation style the rest of this project uses.
**Acceptance:** a small table, committed to the repo, mapping record ID →
name → raw-to-real-units formula → confidence level, for RPM/TPS/coolant
at minimum.
---
## Phase 2 — wideband hardware (parallel-able with Phase 1, needs purchase)
Per `TUNING_GUIDE.md`'s shopping list — Innovate LM-2/MTX-L, AEM X-Series
UEGO, or **Zeitronix ZT-3** (a third real option, confirmed via an
external research pass Aug 2026 — Woolich Racing sells it as a
tuning-package wideband kit), all with a 0-5V analog output. This project
can't execute this phase (physical purchase, welding, wiring) — tracked
here so the software side knows what interface to design against.
- [ ] **Don't just thread the wideband into whatever bung is available —
masking risk, confirmed.** Long/angled stock-style bungs and
thread-reducing adapters (e.g. 18mm→12mm) can prevent the sensor tip
from reaching the core exhaust gas stream, giving delayed/inaccurate
readings — directly confirmed against a real installer's page
(RB Racing): *"Many manufacturers use long 18mm O2 slanted bungs. These
'mask' the 18mm O2 sensor signal"*; thread reducers are *"junk... the
threaded section is too long masking the signal."* Check bung depth/
angle against the sensor's own spec before assuming any available bung
works, whether stock or new.
- [ ] Check whether the Arrow 2-in-1's headers retain provisions at the
stock O2 bung locations before assuming a weld is needed — M18x1.5 is
the universal O2 sensor thread (narrowband and wideband both use it;
confirmed for this Triumph twin family specifically by the German
tuning primer), so if a bung survives, the wideband sensor threads
straight in, no fabrication required.
- [ ] Decide one sensor or two: the stock design has **two** O2 bungs
(one per cylinder, pre-collector — matches the ECU's per-cylinder
`F1`/`F2` fuel tables). One wideband sensor placed post-collector on the
2-in-1 gives a blended average AFR and loses per-cylinder correction
ability; two sensors (one per header, if those bungs survive on the
Arrow pipe) preserves it, at roughly double the sensor/controller cost.
Pick based on whether per-cylinder tuning resolution is worth it.
- [ ] Sensor + controller acquired, bung fitted, wired to switched 12V.
- [ ] Confirm the controller's analog-output voltage-to-AFR/lambda mapping
(from its manual) — needed for Phase 3's ADC scaling.
**O2 delete is not required by this method — that's specific to the
original German guide's setup, not ours.** The 2003 primer had the wideband
probe *physically replace* the stock narrowband sensor in its own port,
which is why it explicitly required disabling closed loop ("Ab jetzt muss
ein Tune ohne Lambdaregelung gefahren werden!" — "from now on a tune
without lambda regulation must be run") — the ECU's own narrowband signal
was gone, so closed loop couldn't function regardless. **Our plan doesn't
have that constraint**, because the wideband sensor sits in an independent
bung, feeding only our own logger — the stock O2 sensor(s) can stay fully
wired, active, and in closed loop the whole time if desired.
This matters because closed loop and the F Trim/road-tuning method operate
on *different parts of the map*: closed-loop O2 correction only runs at
idle and steady low-mid throttle; everything the road-tuning method is
actually correcting (WOT, hard acceleration, deceleration) is **already
open-loop on the stock ECU regardless of O2 presence**. So keeping O2
active doesn't get in the way of the tuning — if anything it's
complementary: O2 keeps handling live idle/cruise correction (one less
thing to get exactly right by hand), while the wideband-driven F Trim
process corrects the open-loop main fuel table, which O2 feedback never
touches, stock or modified.
**Practical options, now that this is a real choice rather than forced:**
1. **Keep both O2 sensors, wideband in a separate/reused bung.** Full
closed-loop behavior retained; wideband only used for tuning
measurement passes. Needs a bung location that doesn't conflict with
either stock sensor.
2. **Delete O2 (as originally planned for the SAI/O2 project), reuse the
freed bung(s) for the wideband.** Loses closed-loop idle/cruise
correction (richer, more consistent idle/cruise per the original
SAI/O2-delete motivation in `COMMUNITY_TUNING.md`, but no more live
auto-correction there either — needs the road-tuning method to get that
region right by hand instead of relying on O2 feedback).
3. **Hybrid** — delete/replace on one cylinder's bung for the wideband,
leave the other cylinder's O2 sensor active. Asymmetric, more complex,
probably not worth it unless bung availability forces it.
Decision isn't urgent — can be made once the Arrow pipe's actual bung
layout is known (Phase 2's first task above).
**For this build specifically, refined: option 2 (O2 delete), but via
temporary sensor swap rather than permanent bung reuse.** Instead of
pulling the stock narrowband sensors permanently, thread wideband sensors
into the *same stock bungs* for tuning sessions only, then put the stock
narrowband sensors back afterward — zero new holes, zero permanent
modification to the exhaust. The O2-delete *behavior* (richer/cooler
idle-cruise, the original motivation from `COMMUNITY_TUNING.md`) then
becomes a pure software choice — the ECU's device flag, independent of
whatever sensor happens to be physically sitting in the bung — rather than
something tied to permanently removing hardware. Full reasoning, including
the closed-loop-behavior tension this surfaces (WOT tuning and
idle/cruise-mixture behavior are separate axes, don't assume fixing one
fixes the other):
[`TUNING_GUIDE.md`](TUNING_GUIDE.md#best-version-yet-reuse-the-stock-o2-bungs-make-the-o2-delete-decision-reversible-too).
Session-scoped exception: closed loop still has to be bypassed *during*
the tuning rides themselves, since a wideband occupying the narrowband's
spot can't feed the ECU a valid narrowband signal at the same time — that's
temporary, not a permanent-mod question.
**For open-sourcing this to other riders who want to keep O2 active** —
worth supporting as first-class options, not assuming everyone deletes O2:
- **Considered and rejected: reusing the SAI injection point as a wideband
mounting location.** Doesn't work mechanically — SAI air enters through
openings cast into the cylinder head at the exhaust port junction (reed
valves live in the cam cover), not through a bolt-on fitting on a pipe.
There's no simple threaded port there to repurpose; doing this would mean
machining into the head casting. Not a real shortcut.
- **What does work: no-weld, clamp-on O2 bung adapters** — a standard
product (PLM, GlowShift, ProFlow, others), drill one hole, bolt on a
stainless clamp with a gasket, no welding. For O2-keeping riders on a
2-in-1: **one** clamp-on bung post-collector (single blended-AFR
reading, no per-cylinder resolution, but one new hole instead of two).
- **Zero-modification option, with a real cost:** clamp-on tailpipe-end
"sniffer" probes exist too — clamp around the exhaust tip, no drilling
at all. Trade-off: less accurate (ambient dilution, slower response,
less representative of true exhaust gas) — a real cost for zero
permanent modification, not a free option.
- **Design implication for the eventual tooling/writeup:** document this
as a menu (delete + reuse stock bungs / keep O2 + single clamp-on bung
post-collector / keep O2 + zero-modification tailpipe clamp / weld two
dedicated pre-collector bungs for full per-cylinder resolution) rather
than assuming one hardware path — the logging/post-processing pipeline
(Phase 3/4) doesn't care which one a given rider picks, it just needs an
AFR channel wired to the ADC either way.
**Don't wire the wideband's output back into the ECU's O2 input.** A
wideband sensor can't be wired directly into a narrowband-expecting ECU
pin at all (different sensor physics — pumped/Nernst cell needing its own
controller, vs. a simple heated-zirconia voltage source); some controllers
offer a "narrowband emulation" output to fake the ECU into thinking
nothing changed, but that mode discards the precision this whole project
needs, and the `0x21` diagnostic stream still wouldn't carry real AFR
either way. Since O2 delete is already part of the plan, the correct setup
is a fully standalone wideband — own bung, own controller, output only to
the Phase 3 logger's ADC, ECU's O2 circuit left disconnected/flagged off.
---
## Phase 3 — onboard unit integration
**Goal:** one small computer, riding along, producing one synced log per
session.
- [ ] Pick the board. Constraints: needs a USB port (or equivalent) for
the K-line/FTDI cable `tunie` already uses, an ADC input (built-in or via
a small ADC breakout) for the wideband's analog output, enough compute
to run `tunie`'s Python transport comfortably, and to survive vibration/
heat/vibration under the seat. A Raspberry Pi Zero 2 W (or similar) with
a cheap I2C ADC (e.g. ADS1115) is a reasonable default; an ESP32 is an
alternative if the KWP2000 polling loop gets ported to something more
embedded, but doing that port is extra work `tunie`'s existing Python
code doesn't need if a Pi-class board is used instead.
- [ ] Wire ADC channel to the wideband controller's analog output; confirm
voltage range matches the ADC's input range (may need a divider).
- [ ] Combine the two data sources into one process: KWP2000 poll loop
(Phase 0/1) and ADC sample loop, both timestamped against the same
clock, written to one log file per run (CSV: `timestamp_ms, rpm, tps,
coolant, afr`).
- [ ] Power: needs to run off the bike's switched 12V (same source as the
wideband controller) with a regulator appropriate to the board, or a
battery pack for bench testing before wiring it in permanently.
- [ ] Basic operational robustness: start logging automatically on power-up
(no need to interact with it mid-ride — this was a stated goal, since
operating a device while riding and marking throttle positions is a
safety concern per `TUNING_GUIDE.md`'s honest complexity assessment),
and fail safe (stop logging / flag clearly, don't crash silently) if the
K-line connection drops.
**Acceptance:** power the unit on, ride/rev through a test range, power
off, retrieve one CSV with all four channels populated and sensibly
timestamped.
---
## Phase 4 — post-processing pipeline (software, buildable now against synthetic data)
**Goal:** turn a captured log into F Trim table corrections, reusing the
table geometry already reverse-engineered (`reference-maps/table_map.py`'s
RPM axis, throttle axis, and table offsets).
- [ ] For each logged row, find the nearest fuel-table cell: RPM axis
lookup is direct (`table_map.py`'s `rpm_axis`, already extracted per-map);
TPS needs converting from whatever raw units Phase 1 lands on into the
table's throttle-axis units (`throttle_axis`, 0-1000 = 0-100.0%).
- [ ] For each cell with enough samples, compute the AFR error (measured
vs. the flat baseline target used during capture, per the road-tuning
method in `TUNING_GUIDE.md`) and produce a correction value.
- [ ] Output format: a per-cell correction table matching the F Trim
table's shape, ready to be entered into TuneECU's Map Edit F Trim screen
by hand (or, if worth the extra work later, generate a diff to apply
directly via the same decode/repack/encode pipeline
`toggle_devices.py`/`compose_arrow_delete.py` already use — flagged as
optional, manual entry into the app is the simpler and lower-risk MVP).
- [ ] This script can be written and unit-tested **now**, against
synthetic/fake log data, without waiting on any other phase — it only
needs the table geometry, which is already known.
**Acceptance:** given a CSV in the Phase 3 format, produces a per-cell
correction table.
---
## Phase 5 — the actual tuning loop (needs everything above, plus riding)
Execute the method from `TUNING_GUIDE.md`: flat rich baseline tune → ride
with the logger → Phase 4 processing → enter corrections into F Trim →
Commit trims → reflash → repeat 3-4 passes → set real AF1/AF2 targets →
done. Not further decomposed here; it's the method already documented, now
using the improved single-source logging instead of manual correlation.
### After tuning: what happens to the wideband hardware
The wideband sensors are a measurement tool, not a runtime dependency —
once the F Trim corrections are validated and committed into the permanent
FuelMap, the ECU doesn't need live AFR feedback to run correctly.
- [ ] Remove the wideband sensor(s).
- [ ] **Plug the freed bungs with standard M18x1.5 threaded plugs, not a
weld.** Keeps them reusable for a future re-tuning pass (e.g. after the
eventual airbox removal) without welding again.
- [ ] Optional, not required: leave **one** wideband sensor permanently
installed as an ongoing AFR gauge. Worth considering specifically
because this build ends up with no SAI and no O2 feedback at all —
zero live safety-net watching for a lean condition afterward (bad
injector, air leak, etc.). Common practice for fully O2-deleted setups;
doesn't need both sensors, just a dashboard readout.
---
## What can start today vs. what's blocked
| Phase | Blocked on | Can start now? |
|---|---|---|
| 0 — polling loop software | nothing | **yes** |
| 1 — record labeling | cable/connector (`../STATUS.md`) | no |
| 2 — wideband hardware | purchase + fabrication (not this project's job) | independently, whenever |
| 3 — onboard unit | Phase 1 (needs labeled records) + Phase 2 hardware | partially — board/ADC selection and wiring plan, yes; full integration, no |
| 4 — post-processing | table geometry only (already known) | **yes, against synthetic data** |
| 5 — tuning loop | everything | no |
**Recommended immediate next action:** Phase 0 (polling-loop code) and
Phase 4 (post-processing script skeleton) are both pure software, need no
hardware, and are the actual bottleneck-breakers — everything else is
either already blocked on the known cable issue or is a purchase/fabrication
task outside this project's scope.