apex-backend / apex /ttm /g1_smoke.py
ssookra's picture
wave-48 backend deploy
a464cc6 verified
Raw
History Blame Contribute Delete
7.54 kB
"""G1. TTM zero-shot smoke on a real FastF1 5-lap export (Phase 0 task 0.7).
G-0.5 already proved TTM-r2 loads on RTX 3060 Ti + emits (B, 30, 14) on a
random tensor. G1 strengthens that proof by running the same forward pass
on a real FastF1 5-lap telemetry slice (Bahrain 2024 Q, cached Phase 0
task 0.6), measuring load + inference latency against the council v2
budget (< 60s end-to-end per plan G1 row).
FastF1 ships a reduced channel set (no analog brake_pa, no steering_rad,
no separated G-channels per pre-mortem row 62). G1's purpose is to prove
the TTM-forward path works on real telemetry, not to claim the 14-channel
contract is satisfied by FastF1. The mapping below uses FastF1's actual
channels and fills the absent ones with zeros + a single warning at the
top of the log so downstream consumers know the gap.
Run from repo root:
app/backend/.venv/Scripts/python.exe -u app/backend/apex/ttm/g1_smoke.py
"""
from __future__ import annotations
import sys
import time
from pathlib import Path
import numpy as np
import torch
REPO_ROOT = Path(__file__).resolve().parents[4]
sys.path.insert(0, str(REPO_ROOT / "app" / "backend"))
from apex.shared.contracts import CHANNEL_COUNT, CHANNELS, HORIZON, channel_index, new_audit_id # noqa: E402
from apex.shared.logging import audit_context, get_logger # noqa: E402
logger = get_logger("ttm.g1_smoke")
# FastF1 telemetry has these analog channels available; the rest we fill with
# zeros and document in the pre-mortem (row 62 channel-availability gap).
FASTF1_CHANNEL_MAP = {
"throttle_pct": "Throttle", # 0..100
"brake_pa": "Brake", # BOOLEAN in FastF1; tile as 0/3.5e6 Pa to give the validator something to chew
"rpm": "RPM",
"speed_mps": "Speed", # FastF1 ships km/h; divide by 3.6
"gear": "nGear",
}
FASTF1_ABSENT_CHANNELS = (
"steering_rad", "lat_g", "long_g", "coa_overlap_flag",
"tire_load_n", "mu_v", "track_pitch_rad", "track_bank_rad", "yaw_rate_rad_s",
)
def load_5lap_export() -> np.ndarray:
"""Pull 5 laps of Hamilton's Bahrain 2024 Q telemetry from the cache.
Returns a (T, 14) float32 array in CHANNELS column order. T is whatever
the 5-lap concatenated telemetry length is at FastF1's native sampling
rate (the cache holds raw telemetry at ~50 Hz).
"""
import fastf1
fastf1.Cache.enable_cache(str(REPO_ROOT / "app" / "backend" / ".fastf1_cache"))
session = fastf1.get_session(2024, "Bahrain", "Q")
session.load(telemetry=True, laps=True, weather=False)
# Hamilton was driver '44' in 2024.
laps = session.laps.pick_drivers("44").iloc[:5]
parts = []
for lap in laps.iterlaps():
# iterlaps yields (idx, lap) tuples
idx, lap_row = lap
car_data = lap_row.get_car_data()
parts.append(car_data)
import pandas as pd
car = pd.concat(parts, ignore_index=True)
# Build (T, 14) in CHANNELS order
T = len(car)
out = np.zeros((T, CHANNEL_COUNT), dtype=np.float32)
for our_name, ff1_name in FASTF1_CHANNEL_MAP.items():
i = channel_index(our_name)
if ff1_name not in car.columns:
logger.warning("g1.fastf1_column_missing", column=ff1_name)
continue
col = car[ff1_name].to_numpy(dtype=np.float32)
if our_name == "speed_mps":
col = col / 3.6 # km/h -> m/s
if our_name == "brake_pa":
col = col.astype(np.float32) * 3.5e6 # bool -> ~3.5 MPa peak
out[:, i] = col
return out
def main() -> int:
print("=" * 72)
print("G1 - TTM zero-shot smoke on FastF1 5-lap export")
print("=" * 72)
audit_id = new_audit_id()
with audit_context(audit_id):
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"device: {device}; audit_id: {audit_id}")
logger.info("g1.start", device=str(device))
# ---- Load 5-lap export from FastF1 cache -------------------------
print("[1/4] loading 5-lap Bahrain 2024 Q (Hamilton) from cache ...")
t0 = time.time()
telemetry = load_5lap_export()
load_s = time.time() - t0
print(f" loaded in {load_s:.2f}s; shape={telemetry.shape} channels={CHANNEL_COUNT}")
logger.info(
"g1.fastf1_loaded",
elapsed_s=round(load_s, 2),
shape=tuple(telemetry.shape),
absent_channels=FASTF1_ABSENT_CHANNELS,
)
# ---- Build TTM input (1 sample, context_length window) -----------
print("[2/4] loading TTM-r2 ...")
from tsfm_public import TinyTimeMixerForPrediction
t0 = time.time()
model = TinyTimeMixerForPrediction.from_pretrained(
"ibm-granite/granite-timeseries-ttm-r2",
num_input_channels=CHANNEL_COUNT,
prediction_filter_length=HORIZON,
).to(device).eval()
ttm_load_s = time.time() - t0
print(f" loaded in {ttm_load_s:.2f}s; context_length={model.config.context_length}")
logger.info("g1.ttm_loaded", elapsed_s=round(ttm_load_s, 2))
ctx = model.config.context_length
T = telemetry.shape[0]
if T < ctx:
# Edge-pad: replicate first row
print(f" telemetry T={T} < context_length={ctx}; edge-padding")
pad = np.repeat(telemetry[:1], ctx - T, axis=0)
telemetry = np.concatenate([pad, telemetry], axis=0)
x_np = telemetry[-ctx:][None, :, :] # (1, ctx, 14)
x = torch.from_numpy(x_np).to(device)
print(f" ttm input shape: {tuple(x.shape)}")
# ---- TTM forward ------------------------------------------------
print("[3/4] TTM forward (zero-shot) ...")
# Warm-up call (CUDA kernels JIT)
with torch.no_grad():
_ = model(past_values=x)
torch.cuda.synchronize() if device.type == "cuda" else None
t0 = time.time()
with torch.no_grad():
out = model(past_values=x)
torch.cuda.synchronize() if device.type == "cuda" else None
infer_ms = (time.time() - t0) * 1000
print(f" inference took {infer_ms:.1f} ms (warm)")
print(f" output shape: {tuple(out.prediction_outputs.shape)}")
logger.info("g1.ttm_forward", warm_ms=round(infer_ms, 1), output_shape=tuple(out.prediction_outputs.shape))
# ---- Verdict -----------------------------------------------------
print("[4/4] verdict ...")
expected = (1, HORIZON, CHANNEL_COUNT)
total_load_s = load_s + ttm_load_s
shape_ok = tuple(out.prediction_outputs.shape) == expected
load_ok = total_load_s < 60.0 # plan G1 row: load + 1Hz inference < 60s
infer_ok = infer_ms < 60_000 # inference itself well under 60s
all_finite = bool(torch.isfinite(out.prediction_outputs).all())
verdict = shape_ok and load_ok and infer_ok and all_finite
print(f" shape == {expected}: {shape_ok}")
print(f" load(FastF1+TTM) < 60s: {load_ok} ({total_load_s:.2f}s)")
print(f" warm inference < 60s: {infer_ok} ({infer_ms:.1f}ms)")
print(f" all-finite output: {all_finite}")
print()
print("=" * 72)
print(f"VERDICT: {'PASS' if verdict else 'FAIL'}")
print("=" * 72)
logger.info("g1.verdict", pass_=verdict, total_load_s=round(total_load_s, 2), warm_ms=round(infer_ms, 1))
return 0 if verdict else 1
if __name__ == "__main__":
sys.exit(main())