Files
samplez/tunie/research/reference-maps/compose_lowthrottle.py
uhryniuk 2ad5fab677 Add a layered low-throttle fuel correction on top of the Arrow delete tune
Real-world reports: stalling/choking under ~10% throttle and while
blipping the throttle at low speed, after snorkel removal with no
main-table correction for the extra airflow. Confirmed via a genuine
percentage-delta pulled from 20184dynoTuneSteveO2-Disable.hex (also
snorkel-removed) vs its own stock baseline 20186Map.hex, in the
Low-throttle fuel tables, restricted to the throttle columns actually
implicated (raw throttle <= 100, ~10%).

compose_lowthrottle.py composes onto the already-composed
20262-arrow-delete-composed.hex (not raw stock 20262) -- additive on top
of the existing SAI/O2-off + idle-trim layer, same "respect what's
already there" principle as compose_arrow_delete.py. Percentage-based
per-cell, not flat additive, since Arrow's own table already has its own
exhaust-specific correction baked in.

Output: derived/20262-arrow-delete-lowthrottle-composed.hex. Checksum
patched and verified valid; confirmed layer-1's device flags and trim
bytes survive unchanged underneath.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FP2GaxS9HkUdL5sLBnjKje
2026-09-10 15:39:10 -05:00

149 lines
5.8 KiB
Python

"""Compose a Low-throttle fuel correction on top of an already-composed map.
Second layer in a two-step derivation:
20262Map.hex
-> compose_arrow_delete.py -> 20262-arrow-delete-composed.hex (layer 1)
-> compose_lowthrottle.py -> 20262-arrow-delete-lowthrottle-composed.hex (layer 2)
Layer 2 is additive on top of layer 1's own values, not a fresh edit of stock
20262 -- it composes with whatever layer 1 already did (SAI/O2 off, idle-CO
trim), the same "respect what's already there" principle used throughout
this project.
Motivation (2026-09-10 session): real-world reports of stalling/choking
under ~10% throttle and while blipping the throttle at low speed, after
snorkel removal with no main-table correction for the extra airflow.
Confirmed via a real percentage-delta pulled from a genuine reference file
that also had its snorkel removed: `20184dynoTuneSteveO2-Disable.hex` vs
its own stock baseline `20186Map.hex`, in the "Low-throttle fuel" tables
(cyl 1 @ fe=15, cyl 2 @ fe=16 -- see TABLES.md), restricted to the
throttle columns actually implicated by the reported symptom (raw
throttle-axis value <= 100, i.e. <= 10.0%).
Method: percentage delta, not flat additive -- VE/fuel-mass values scale
with magnitude, and Arrow's own low-throttle table already differs from
stock (it has its own exhaust-specific correction baked in), so the right
operation is "apply the same proportional richening the reference delete
map needed" on top of whatever value is already there, not overwrite it.
pct(row, col) = (delete_ref[row][col] - stock_ref[row][col]) / stock_ref[row][col]
new(row, col) = round(base[row][col] * (1 + pct(row, col)))
Usage:
python3 compose_lowthrottle.py \\
derived/20262-arrow-delete-composed.hex \\
20186Map.hex 20184dynoTuneSteveO2-Disable.hex \\
derived/20262-arrow-delete-lowthrottle-composed.hex
"""
from __future__ import annotations
import struct
import sys
import checksum
from decode_map import decode, encode
from table_map import resolve
from toggle_devices import repack, unpack
LOW_THROTTLE_TABLES = ("Low-throttle fuel — cyl 1", "Low-throttle fuel — cyl 2")
MAX_SANE_VALUE = 20000 # real VE values in every reference file top out ~11000
def _table_by_name(resolved: dict, name: str) -> dict:
for t in resolved["tables"]:
if t["name"] == name:
return t
raise KeyError(name)
def compose(base_raw: bytes, stock_raw: bytes, delete_raw: bytes, base_path: str, stock_path: str, delete_path: str) -> tuple[bytes, list[str]]:
base_dec = decode(base_raw)
base_rom, positions = unpack(base_dec)
base_rom = bytearray(base_rom)
base_resolved = resolve(base_path)
stock_resolved = resolve(stock_path)
delete_resolved = resolve(delete_path)
throttle_axis = base_resolved["throttle_axis"]
low_cols = [i for i, v in enumerate(throttle_axis) if v <= 100]
notes = [f"low-throttle columns touched (raw<=100): {[throttle_axis[c] for c in low_cols]}"]
for name in LOW_THROTTLE_TABLES:
base_t = _table_by_name(base_resolved, name)
stock_t = _table_by_name(stock_resolved, name)
delete_t = _table_by_name(delete_resolved, name)
off = base_t["offset"] # this file's OWN resolved offset, not another file's
rows, cols = base_t["rows"], base_t["cols"]
changed = 0
pct_min, pct_max = None, None
for row in range(rows):
for col in low_cols:
stock_v = stock_t["data"][row][col]
delete_v = delete_t["data"][row][col]
base_v = base_t["data"][row][col]
pct = (delete_v - stock_v) / stock_v if stock_v else 0.0
new_v = round(base_v * (1 + pct))
new_v = max(0, min(65535, new_v))
if new_v > MAX_SANE_VALUE:
notes.append(
f" WARNING {name} row={row} col={col}: "
f"computed {new_v} exceeds sanity bound {MAX_SANE_VALUE}"
)
if new_v != base_v:
changed += 1
pct_min = pct if pct_min is None else min(pct_min, pct)
pct_max = pct if pct_max is None else max(pct_max, pct)
byte_off = off + (row * cols + col) * 2
struct.pack_into(">H", base_rom, byte_off, new_v)
pct_range = f"{pct_min:+.1%} to {pct_max:+.1%}" if changed else "no change"
notes.append(f"{name} (0x{off:05X}): {changed} cells touched, delta range {pct_range}")
before_sum = checksum.stored(bytes(base_rom))
checksum.patch(base_rom)
after_sum = checksum.compute(bytes(base_rom))
notes.append(f"checksum: stored {before_sum:04x} (stale) -> patched {after_sum:04x}")
repacked = repack(base_dec, bytes(base_rom), positions)
out = encode(repacked)
assert decode(out) == repacked, "round-trip failed"
return out, notes
def main() -> int:
if len(sys.argv) != 5:
print(
"usage: compose_lowthrottle.py <base_composed.hex> <stock.hex> "
"<delete_reference.hex> <outfile.hex>",
file=sys.stderr,
)
return 2
base_path, stock_path, delete_path, out_path = sys.argv[1:5]
out, notes = compose(
open(base_path, "rb").read(),
open(stock_path, "rb").read(),
open(delete_path, "rb").read(),
base_path, stock_path, delete_path,
)
open(out_path, "wb").write(out)
for n in notes:
print(" ", n)
print(f"wrote {out_path} ({len(out)} bytes)")
print("NOTE: only the under-~10%-throttle columns of the two Low-throttle")
print(" fuel tables were touched. Main fuel tables (>10% throttle)")
print(" remain untouched stock-Arrow calibration.")
return 0
if __name__ == "__main__":
raise SystemExit(main())