comma controls challenge: sim-calibrated feedforward + PID (72.3 vs 110.3 baseline)

This commit is contained in:
Karti Tripathi
2026-06-16 13:30:25 -07:00
commit cea2c9c82a
13 changed files with 616 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
"""Diagnostic matrix: evaluate several controller configs on a fixed 100-seg set."""
import numpy as np
from pathlib import Path
from multiprocessing import Pool
from tinyphysics import TinyPhysicsModel, TinyPhysicsSimulator, COST_END_IDX
from controllers.karti import Controller
MODEL_PATH = './models/tinyphysics.onnx'
SEGS = sorted(Path('data').glob('*.csv'))[:100]
NOC0 = (0.0, 0.70797, -0.006298, -0.000029)
_model = None
def _init():
global _model
_model = TinyPhysicsModel(MODEL_PATH, debug=False)
def _ev(args):
seg, params = args
sim = TinyPhysicsSimulator(_model, str(seg), controller=Controller(params), debug=False)
while sim.step_idx < COST_END_IDX:
sim.step()
c = sim.compute_cost()
return c['total_cost'], c['lataccel_cost'], c['jerk_cost']
def evalp(params, pool):
r = np.array(pool.map(_ev, [(s, params) for s in SEGS]))
return r[:, 0].mean(), r[:, 1].mean(), r[:, 2].mean()
TESTS = {
'pid-repro (FF off, baseline gains)': dict(ff=(0,0,0,0), preview=0, kp=0.195, ki=0.1, kd=-0.053, int_clip=1e9),
'FF + baseline-PID gains': dict(preview=0, kp=0.195, ki=0.1, kd=-0.053, int_clip=1e9),
'FF + strong PID': dict(preview=0, kp=0.4, ki=0.1, kd=-0.05, int_clip=1.0),
'FF + very strong PID': dict(preview=0, kp=0.6, ki=0.15, kd=-0.07, int_clip=1.0),
'FF noC0 + baseline gains': dict(ff=NOC0, preview=0, kp=0.195, ki=0.1, kd=-0.053, int_clip=1e9),
'FF noC0 + strong PID': dict(ff=NOC0, preview=0, kp=0.4, ki=0.1, kd=-0.05, int_clip=1.0),
'FF noC0 + strong PID + preview1': dict(ff=NOC0, preview=1, kp=0.4, ki=0.1, kd=-0.05, int_clip=1.0),
}
if __name__ == "__main__":
with Pool(4, initializer=_init) as pool:
print(f"{'config':<38} {'total':>8} {'lat':>7} {'jerk':>7}")
print("-" * 64)
for name, p in TESTS.items():
t, l, j = evalp(p, pool)
print(f"{name:<38} {t:8.3f} {l:7.3f} {j:7.3f}", flush=True)
print("\n(target to beat: pid total=84.85 lat=1.27 jerk=21.3)")
+58
View File
@@ -0,0 +1,58 @@
"""Smoothing sweep: keep FF's tracking gain, kill the jerk it injects."""
import numpy as np
import pandas as pd
from pathlib import Path
from multiprocessing import Pool
from tinyphysics import TinyPhysicsModel, TinyPhysicsSimulator, CONTROL_START_IDX, COST_END_IDX
from controllers.karti import Controller
MODEL_PATH = './models/tinyphysics.onnx'
SEGS = sorted(Path('data').glob('*.csv'))[:100]
_model = None
def _init():
global _model
_model = TinyPhysicsModel(MODEL_PATH, debug=False)
def _ev(args):
seg, params = args
sim = TinyPhysicsSimulator(_model, str(seg), controller=Controller(params), debug=False)
while sim.step_idx < COST_END_IDX:
sim.step()
c = sim.compute_cost()
return c['total_cost'], c['lataccel_cost'], c['jerk_cost']
def evalp(params, pool):
r = np.array(pool.map(_ev, [(s, params) for s in SEGS]))
return r[:, 0].mean(), r[:, 1].mean(), r[:, 2].mean()
# inherent jerk if you tracked the target perfectly (floor reference)
jf = []
for seg in SEGS:
t = pd.read_csv(seg)['targetLateralAcceleration'].values[CONTROL_START_IDX:COST_END_IDX]
jf.append(np.mean((np.diff(t) / 0.1) ** 2) * 100)
print(f"target inherent jerk_cost (perfect-tracking floor): {np.mean(jf):.2f}\n")
TESTS = {
'FF base (no smoothing)': dict(),
'roll_a=0.3': dict(roll_alpha=0.3),
'roll_a=0.2': dict(roll_alpha=0.2),
'roll_a=0.1': dict(roll_alpha=0.1),
'navg=5': dict(ff_navg=5),
'navg=10': dict(ff_navg=10),
'ff_a=0.4': dict(ff_alpha=0.4),
'roll0.2 + navg5': dict(roll_alpha=0.2, ff_navg=5),
'roll0.2 + navg5 + ff_a0.5': dict(roll_alpha=0.2, ff_navg=5, ff_alpha=0.5),
'roll0.15+navg8+ff_a0.4': dict(roll_alpha=0.15, ff_navg=8, ff_alpha=0.4),
'roll0.2+navg5 + gentle pid': dict(roll_alpha=0.2, ff_navg=5, kp=0.12, ki=0.06, kd=-0.03),
'noC0 roll0.2 navg5': dict(ff=(0.0,0.70797,-0.006298,-0.000029), roll_alpha=0.2, ff_navg=5),
}
if __name__ == "__main__":
with Pool(4, initializer=_init) as pool:
print(f"{'config':<32} {'total':>8} {'lat':>7} {'jerk':>7}")
print("-" * 58)
for name, p in TESTS.items():
t, l, j = evalp(p, pool)
print(f"{name:<32} {t:8.3f} {l:7.3f} {j:7.3f}", flush=True)
print("\n(beat: pid total=84.85 lat=1.27 jerk=21.3)")
+60
View File
@@ -0,0 +1,60 @@
"""Sweep the future-target averaging window (navg) + forward roll-avg + PID tweaks."""
import numpy as np
from pathlib import Path
from multiprocessing import Pool
from tinyphysics import TinyPhysicsModel, TinyPhysicsSimulator, COST_END_IDX
from controllers.karti import Controller
MODEL_PATH = './models/tinyphysics.onnx'
SEGS = sorted(Path('data').glob('*.csv'))[:100]
NOC0 = (0.0, 0.70797, -0.006298, -0.000029)
_model = None
def _init():
global _model
_model = TinyPhysicsModel(MODEL_PATH, debug=False)
def _ev(args):
seg, params = args
sim = TinyPhysicsSimulator(_model, str(seg), controller=Controller(params), debug=False)
while sim.step_idx < COST_END_IDX:
sim.step()
c = sim.compute_cost()
return c['total_cost'], c['lataccel_cost'], c['jerk_cost']
def evalp(params, pool):
r = np.array(pool.map(_ev, [(s, params) for s in SEGS]))
return r[:, 0].mean(), r[:, 1].mean(), r[:, 2].mean()
def base(**kw):
d = dict(ff=NOC0)
d.update(kw)
return d
TESTS = {
'navg8': base(ff_navg=8),
'navg10': base(ff_navg=10),
'navg12': base(ff_navg=12),
'navg15': base(ff_navg=15),
'navg20': base(ff_navg=20),
'navg25': base(ff_navg=25),
'navg15 + rollnavg10': base(ff_navg=15, roll_navg=10),
'navg15 + rollnavg20': base(ff_navg=15, roll_navg=20),
'navg15 + kd=-0.03': base(ff_navg=15, kd=-0.03),
'navg15 + ki=0.05': base(ff_navg=15, ki=0.05),
'navg15 + ki0.05 kd-0.03':base(ff_navg=15, ki=0.05, kd=-0.03),
'navg12 keepC0': dict(ff_navg=12),
}
if __name__ == "__main__":
with Pool(4, initializer=_init) as pool:
print(f"{'config':<28} {'total':>8} {'lat':>7} {'jerk':>7}")
print("-" * 54)
best = None
for name, p in TESTS.items():
t, l, j = evalp(p, pool)
star = ""
if best is None or t < best[1]:
best, star = (name, t), " <"
print(f"{name:<28} {t:8.3f} {l:7.3f} {j:7.3f}{star}", flush=True)
print(f"\nbest: {best[0]} ({best[1]:.3f}) | beat pid=84.85")
+62
View File
@@ -0,0 +1,62 @@
"""Final fine sweep: pin navg (4-9) + perturb kp/kd/ff_scale around the optimum."""
import numpy as np
from pathlib import Path
from multiprocessing import Pool
from tinyphysics import TinyPhysicsModel, TinyPhysicsSimulator, COST_END_IDX
from controllers.karti import Controller
MODEL_PATH = './models/tinyphysics.onnx'
SEGS = sorted(Path('data').glob('*.csv'))[:100]
NOC0 = (0.0, 0.70797, -0.006298, -0.000029)
_model = None
def _init():
global _model
_model = TinyPhysicsModel(MODEL_PATH, debug=False)
def _ev(args):
seg, params = args
sim = TinyPhysicsSimulator(_model, str(seg), controller=Controller(params), debug=False)
while sim.step_idx < COST_END_IDX:
sim.step()
c = sim.compute_cost()
return c['total_cost'], c['lataccel_cost'], c['jerk_cost']
def evalp(params, pool):
r = np.array(pool.map(_ev, [(s, params) for s in SEGS]))
return r[:, 0].mean(), r[:, 1].mean(), r[:, 2].mean()
def b(**kw):
d = dict(ff=NOC0); d.update(kw); return d
TESTS = {
'navg4': b(ff_navg=4),
'navg5': b(ff_navg=5),
'navg6': b(ff_navg=6),
'navg7': b(ff_navg=7),
'navg8': b(ff_navg=8),
'navg9': b(ff_navg=9),
'navg6 kp0.25': b(ff_navg=6, kp=0.25),
'navg6 kp0.30': b(ff_navg=6, kp=0.30),
'navg6 ffs1.05': b(ff_navg=6, ff_scale=1.05),
'navg6 ffs1.10': b(ff_navg=6, ff_scale=1.10),
'navg6 kd-0.03': b(ff_navg=6, kd=-0.03),
'navg6 kp0.25 ffs1.05': b(ff_navg=6, kp=0.25, ff_scale=1.05),
'navg5 kp0.25 ffs1.05': b(ff_navg=5, kp=0.25, ff_scale=1.05),
'navg6 kp0.25 kd-0.04 ffs1.05': b(ff_navg=6, kp=0.25, kd=-0.04, ff_scale=1.05),
}
if __name__ == "__main__":
with Pool(4, initializer=_init) as pool:
print(f"{'config':<32} {'total':>8} {'lat':>7} {'jerk':>7}")
print("-" * 58)
best = None
for name, p in TESTS.items():
t, l, j = evalp(p, pool)
star = ""
if best is None or t < best[2]:
best, star = (name, p, t), " <"
print(f"{name:<32} {t:8.3f} {l:7.3f} {j:7.3f}{star}", flush=True)
import json
print(f"\nbest: {best[0]} ({best[2]:.3f})")
print("PARAMS=" + json.dumps(best[1]))
+75
View File
@@ -0,0 +1,75 @@
"""Fit a feedforward inverse-model: given desired lataccel + state, what steer?
Also sweep a lead delay d (steer_t vs target_{t+d}) to find the natural preview.
Run: .venv/bin/python scratch/fit_ff.py [num_files]
"""
import sys
import numpy as np
import pandas as pd
from pathlib import Path
ACC_G = 9.81
N = int(sys.argv[1]) if len(sys.argv) > 1 else 400
files = sorted(Path('data').glob('*.csv'))[:N]
print(f"loading {len(files)} segments...")
cols = []
for f in files:
df = pd.read_csv(f)
roll_la = np.sin(df['roll'].values) * ACC_G
v = df['vEgo'].values
a = df['aEgo'].values
tgt = df['targetLateralAcceleration'].values
steer = -df['steerCommand'].values # right-positive, as sim uses
cols.append((steer, tgt, roll_la, v, a))
def stack(delay=0):
S, T, R, V, A = [], [], [], [], []
for steer, tgt, roll, v, a in cols:
n = len(steer)
if delay >= 0:
s = steer[:n-delay] if delay else steer
t = tgt[delay:]
r = roll[:n-delay] if delay else roll
vv = v[:n-delay] if delay else v
aa = a[:n-delay] if delay else a
S.append(s); T.append(t); R.append(r); V.append(vv); A.append(aa)
return (np.concatenate(S), np.concatenate(T), np.concatenate(R),
np.concatenate(V), np.concatenate(A))
def fit(X, y, name):
mask = np.isfinite(y) & np.all(np.isfinite(X), axis=1)
X, y = X[mask], y[mask]
coef, *_ = np.linalg.lstsq(X, y, rcond=None)
pred = X @ coef
ss_res = np.sum((y - pred)**2)
ss_tot = np.sum((y - y.mean())**2)
r2 = 1 - ss_res / ss_tot
rmse = np.sqrt(np.mean((y - pred)**2))
print(f" {name:<42} R2={r2:.4f} rmse={rmse:.4f}")
print(f" coef={np.round(coef, 6).tolist()}")
return coef, r2
print("\n=== lead-delay sweep (model: 1, net, net*v, net*v^2 where net=tgt-roll) ===")
best = None
for d in range(0, 7):
steer, tgt, roll, v, a = stack(d)
net = tgt - roll
X = np.column_stack([np.ones_like(net), net, net*v, net*v*v])
coef, r2 = fit(X, steer, f"delay={d}")
if best is None or r2 > best[1]:
best = (d, r2, coef)
print(f"\nBEST lead delay d={best[0]} (R2={best[1]:.4f})")
print("\n=== richer models at best delay ===")
d = best[0]
steer, tgt, roll, v, a = stack(d)
net = tgt - roll
ones = np.ones_like(net)
fit(np.column_stack([ones, tgt]), steer, "M0: 1,tgt")
fit(np.column_stack([ones, tgt, roll]), steer, "M1: 1,tgt,roll")
fit(np.column_stack([ones, net, net*v, net*v*v]), steer, "M2: 1,net,net*v,net*v2")
c, _ = fit(np.column_stack([ones, net, net*v, net*v*v, a, roll]), steer,
"M3: +a,roll")
print("\nv_ego range:", round(float(steer.min()), 3))
print("data ranges: tgt[%.2f,%.2f] roll[%.2f,%.2f] v[%.2f,%.2f]" % (
tgt.min(), tgt.max(), roll.min(), roll.max(), v.min(), v.max()))
+61
View File
@@ -0,0 +1,61 @@
"""Re-fit the feedforward against the SIMULATOR (not the real-car logs).
Run the pid controller (tracks well) over many segs, log the (steer -> resulting
lataccel) pairs TinyPhysics actually produces, and fit steer = f(lat, roll, v).
This closes the sim/real gain gap directly.
Run: PYTHONPATH=. .venv/bin/python scratch/fit_ff_sim.py [num_segs]
"""
import sys
import numpy as np
from pathlib import Path
from multiprocessing import Pool
from tinyphysics import (TinyPhysicsModel, TinyPhysicsSimulator,
CONTROL_START_IDX, COST_END_IDX)
from controllers.pid import Controller as PID
MODEL_PATH = './models/tinyphysics.onnx'
N = int(sys.argv[1]) if len(sys.argv) > 1 else 200
ALL = sorted(Path('data').glob('*.csv'))[:N]
_model = None
def _init():
global _model
_model = TinyPhysicsModel(MODEL_PATH, debug=False)
def _collect(seg):
sim = TinyPhysicsSimulator(_model, str(seg), controller=PID(), debug=False)
while sim.step_idx < COST_END_IDX:
sim.step()
lo, hi = CONTROL_START_IDX, COST_END_IDX
steer = np.array(sim.action_history[lo:hi])
lat = np.array(sim.current_lataccel_history[lo:hi])
st = sim.state_history[lo:hi]
roll = np.array([s.roll_lataccel for s in st])
v = np.array([s.v_ego for s in st])
a = np.array([s.a_ego for s in st])
return np.column_stack([steer, lat, roll, v, a])
def fit(X, y, name):
m = np.isfinite(y) & np.all(np.isfinite(X), axis=1)
X, y = X[m], y[m]
coef, *_ = np.linalg.lstsq(X, y, rcond=None)
pred = X @ coef
r2 = 1 - np.sum((y - pred)**2) / np.sum((y - y.mean())**2)
print(f" {name:<34} R2={r2:.4f} rmse={np.sqrt(np.mean((y-pred)**2)):.4f}")
print(f" coef={np.round(coef,6).tolist()}")
return coef
if __name__ == "__main__":
print(f"collecting sim (steer->lataccel) from pid over {len(ALL)} segs...")
with Pool(4, initializer=_init) as pool:
data = np.vstack(pool.map(_collect, ALL))
steer, lat, roll, v, a = data.T
net = lat - roll
ones = np.ones_like(net)
print(f"samples: {len(steer)}")
fit(np.column_stack([ones, lat]), steer, "1,lat")
fit(np.column_stack([ones, lat, roll]), steer, "1,lat,roll")
fit(np.column_stack([ones, net, net*v, net*v*v]), steer, "1,net,net*v,net*v2")
fit(np.column_stack([ones, net, net*v, net*v*v, a, roll]), steer, "+a,roll")
print("\ndata ranges: lat[%.2f,%.2f] roll[%.2f,%.2f] v[%.2f,%.2f]" % (
lat.min(), lat.max(), roll.min(), roll.max(), v.min(), v.max()))
+83
View File
@@ -0,0 +1,83 @@
"""Coordinate-descent tuner for controllers/karti.py against the TinyPhysics sim.
Deterministic (sim is seeded per-segment), so the search is noise-free.
Runs to COST_END_IDX only (identical cost to a full rollout) to save compute.
"""
import json
import sys
import numpy as np
from pathlib import Path
from multiprocessing import Pool
from tinyphysics import TinyPhysicsModel, TinyPhysicsSimulator, COST_END_IDX
from controllers.karti import Controller
MODEL_PATH = './models/tinyphysics.onnx'
ALL = sorted(Path('data').glob('*.csv'))
TUNE = ALL[:80] # fast search set
VAL = ALL[80:480] # held-out validation
_model = None
def _init():
global _model
_model = TinyPhysicsModel(MODEL_PATH, debug=False)
def _eval_one(args):
seg, params = args
sim = TinyPhysicsSimulator(_model, str(seg), controller=Controller(params), debug=False)
while sim.step_idx < COST_END_IDX:
sim.step()
c = sim.compute_cost()
return (c['total_cost'], c['lataccel_cost'], c['jerk_cost'])
def evaluate(params, segs, pool):
res = np.array(pool.map(_eval_one, [(s, params) for s in segs]))
return res[:, 0].mean(), res[:, 1].mean(), res[:, 2].mean()
GRIDS = {
'ff_scale': [0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.1, 1.2, 1.3],
'kp': [0.0, 0.05, 0.1, 0.2, 0.3],
'ki': [0.0, 0.02, 0.05, 0.1],
'kd': [-0.08, -0.05, -0.02, 0.0],
'preview': [0, 1, 2, 3, 4],
'int_clip': [0.5, 1.0, 2.0],
}
ORDER = ['ff_scale', 'kp', 'ki', 'kd', 'preview', 'int_clip']
def main():
cur = dict(ff_scale=1.0, kp=0.10, ki=0.05, kd=-0.02, preview=2, int_clip=1.0)
cache = {}
def ev(params):
key = tuple(sorted(params.items()))
if key not in cache:
cache[key] = evaluate(params, TUNE, pool)
return cache[key]
with Pool(4, initializer=_init) as pool:
base = ev(cur)
print(f"start {cur} -> total={base[0]:.3f} (lat={base[1]:.3f} jerk={base[2]:.3f})", flush=True)
for it in range(2):
print(f"\n===== pass {it+1} =====", flush=True)
for name in ORDER:
best_v, best_cost = cur[name], ev(cur)[0]
for v in GRIDS[name]:
if v == cur[name]:
continue
trial = dict(cur); trial[name] = v
cost = ev(trial)[0]
tag = ""
if cost < best_cost:
best_cost, best_v, tag = cost, v, " <-- best"
print(f" {name}={v!s:<7} total={cost:.3f}{tag}", flush=True)
cur[name] = best_v
print(f" => {name} := {best_v} (total={best_cost:.3f})", flush=True)
tcost = ev(cur)
print(f"\nFINAL params: {cur}", flush=True)
print(f"TUNE(80): total={tcost[0]:.3f} lat={tcost[1]:.3f} jerk={tcost[2]:.3f}", flush=True)
vcost = evaluate(cur, VAL, pool)
print(f"VAL(400): total={vcost[0]:.3f} lat={vcost[1]:.3f} jerk={vcost[2]:.3f}", flush=True)
Path('scratch/best_params.json').write_text(json.dumps(cur, indent=2))
print("saved scratch/best_params.json", flush=True)
if __name__ == "__main__":
main()
+37
View File
@@ -0,0 +1,37 @@
"""Validate the controller's baked-in defaults on a held-out segment range.
Run: PYTHONPATH=. .venv/bin/python scratch/validate.py LO HI [controller]
"""
import sys
import importlib
import numpy as np
from pathlib import Path
from multiprocessing import Pool
from tinyphysics import TinyPhysicsModel, TinyPhysicsSimulator, COST_END_IDX
MODEL_PATH = './models/tinyphysics.onnx'
LO = int(sys.argv[1]) if len(sys.argv) > 1 else 1000
HI = int(sys.argv[2]) if len(sys.argv) > 2 else 2000
CTRL = sys.argv[3] if len(sys.argv) > 3 else 'karti'
SEGS = sorted(Path('data').glob('*.csv'))[LO:HI]
Controller = importlib.import_module(f'controllers.{CTRL}').Controller
_model = None
def _init():
global _model
_model = TinyPhysicsModel(MODEL_PATH, debug=False)
def _ev(seg):
sim = TinyPhysicsSimulator(_model, str(seg), controller=Controller(), debug=False)
limit = min(COST_END_IDX, len(sim.data)) # some segments are shorter than 500 rows
while sim.step_idx < limit:
sim.step()
c = sim.compute_cost()
return c['total_cost'], c['lataccel_cost'], c['jerk_cost']
if __name__ == "__main__":
with Pool(4, initializer=_init) as pool:
r = np.array(pool.map(_ev, SEGS))
tot = r[:, 0]
print(f"controller={CTRL} held-out segs [{LO}:{HI}] (n={len(SEGS)})")
print(f" total={tot.mean():.3f} lat={r[:,1].mean():.3f} jerk={r[:,2].mean():.3f}")
print(f" median total={np.median(tot):.3f} %under100={100*(tot<100).mean():.1f}%")