diff --git a/tunie/research/reference-maps/compose_lowthrottle.py b/tunie/research/reference-maps/compose_lowthrottle.py new file mode 100644 index 0000000..077b759 --- /dev/null +++ b/tunie/research/reference-maps/compose_lowthrottle.py @@ -0,0 +1,148 @@ +"""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 " + " ", + 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()) diff --git a/tunie/research/reference-maps/derived/20262-arrow-delete-lowthrottle-composed.hex b/tunie/research/reference-maps/derived/20262-arrow-delete-lowthrottle-composed.hex new file mode 100644 index 0000000..5dea564 Binary files /dev/null and b/tunie/research/reference-maps/derived/20262-arrow-delete-lowthrottle-composed.hex differ