"""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())