#!/usr/bin/env python3 """ log_summary.py: a first-pass datalog summary you (or your AI agent) can run on any ECU CSV export. From https://vibetuners.com (free to use and modify). pip install pandas python3 log_summary.py my-log.csv Edit COLUMNS to match your ECU's CSV headers (see your channel dictionary). This is a starting point for an agent to extend, NOT a tuning tool. Check every finding against the log yourself. """ import sys import pandas as pd COLUMNS = { "time": "Time (s)", "rpm": "Engine Speed (rpm)", "tps": "Throttle Position (%)", "map": "Manifold Pressure (kPa)", "lam": "Lambda 1", "target": "Lambda Target", "ign": "Ignition Angle (deg)", "knock": "Knock Level (V)", "iat": "Intake Air Temp (C)", "ect": "Coolant Temp (C)", } LEAN_PCT = 3.0 # flag sustained error above this % MIN_RUN = 5 # samples in a row to count as sustained KNOCK_V = 0.8 # knock level considered an event WIDEBAND_LAG_S = 0.10 # shift lambda earlier by this much SKIP_TIPIN_S = 0.4 # ignore this long after throttle goes > 90% def main(path): df = pd.read_csv(path) c = {k: v for k, v in COLUMNS.items() if v in df.columns} missing = [v for k, v in COLUMNS.items() if k not in c] dt = df[c["time"]].diff().median() print(f"File: {path}") print(f"Rows: {len(df)} · ~{1/dt:.0f} Hz · {df[c['time']].iloc[-1]:.1f} s") if missing: print(f"Missing expected channels: {', '.join(missing)}") # sensor sanity for k in ("lam", "map", "iat", "ect"): if k in c: s = df[c[k]] if s.nunique() <= 2: print(f"! SENSOR: {c[k]} is flat ({s.iloc[0]}). Check wiring before tuning.") # wideband lag correction if "lam" in c: shift = max(0, round(WIDEBAND_LAG_S / dt)) df["lam_adj"] = df[c["lam"]].shift(-shift) # full-load segment, minus tip-in wot = df[df[c["tps"]] > 90].copy() if wot.empty: print("No full-load (TPS > 90%) data found.") return start = wot[c["time"]].iloc[0] wot = wot[wot[c["time"]] >= start + SKIP_TIPIN_S] print(f"\nFull load: {len(wot)} samples, " f"{wot[c['rpm']].min():.0f}-{wot[c['rpm']].max():.0f} rpm, " f"peak MAP {wot[c['map']].max():.0f} kPa") if "target" in c and "lam_adj" in wot: wot["err"] = (wot["lam_adj"] / wot[c["target"]] - 1) * 100 for label, mask in (("LEAN", wot["err"] > LEAN_PCT), ("RICH", wot["err"] < -LEAN_PCT)): runs = (mask != mask.shift()).cumsum() for _, g in wot[mask].groupby(runs[mask]): if len(g) >= MIN_RUN: pk = g["err"].abs().idxmax() print(f" {label}: {g[c['time']].iloc[0]:.2f}-{g[c['time']].iloc[-1]:.2f} s, " f"{g[c['rpm']].min():.0f}-{g[c['rpm']].max():.0f} rpm, " f"peak {wot.loc[pk, 'err']:+.1f}% at {wot.loc[pk, c['rpm']]:.0f} rpm / " f"{wot.loc[pk, c['map']]:.0f} kPa") if "knock" in c: k = wot[wot[c["knock"]] > KNOCK_V] if len(k): print(f" KNOCK: {len(k)} samples > {KNOCK_V} V, " f"{k[c['rpm']].min():.0f}-{k[c['rpm']].max():.0f} rpm, peak {k[c['knock']].max():.2f} V") if "ign" in c: before = wot[wot[c["time"]] < k[c["time"]].iloc[0]].tail(10)[c["ign"]].median() print(f" ignition {before:.1f}° just before → {k[c['ign']].min():.1f}° during event") for k, lim in (("iat", 55), ("ect", 100)): if k in c and df[c[k]].max() > lim: print(f" TEMP: {c[k]} reached {df[c[k]].max():.1f} (limit {lim})") if "iat" in c: print(f" IAT over pull: {wot[c['iat']].iloc[0]:.1f} → {wot[c['iat']].iloc[-1]:.1f} °C") if __name__ == "__main__": if len(sys.argv) != 2: sys.exit("usage: python3 log_summary.py ") main(sys.argv[1])