The Kit/Patterns

Telemetry Analytics

Driving-style + G analytics from telemetry x geometry: lateral-G (v^2 . curvature), throttle %, heavy-brake %, top/avg speed, on-track order. Honest estimates - no steering channel in the feed.

Reference implementation: this source keeps imports to modules from its home codebase. Read it for the technique, adapt the imports to yours; it is not drop-in compile-ready, on purpose.

analytics.ts
// ============================================================================
// Track 2 / Phase 4 — the "F1 doesn't hand you this" analytics tier.
// Built on the Phase-2 projection spine (resample.ts): lateral G-force (v²·curvature),
// driving-style aggregates (full-throttle %, heavy braking %, top/avg speed), real
// gap-in-metres + true on-track order from arc-length s. Pure (no React/DOM).
//
// HONEST: every metric is computed from real fed telemetry/geometry. We have NO
// steering / DRS / brake-bias / tyre-temp channels (2026 cars) — those are refused,
// never placeholdered. Lateral G is from the median racing line's curvature, so it's
// a "racing-line" estimate (badged), not a per-wheel measurement.
// ============================================================================

import type { TrackOutline } from "../shared/types";
import { projectorFor, carTrack, fastestLapWindow, type LapWindow, type Projector } from "./resample";

interface EngineLike {
  bundle: { pos: Record<string, unknown>; car: Record<string, { t: number[]; spd: number[]; thr: number[]; brk: number[] }>; meta: { outline: TrackOutline | null } } | null;
  carTel(num: string, t: number): { spd: number; gear: number; rpm: number; thr: number; brk: number } | null;
  carPos(num: string, t: number): { x: number; y: number; on: boolean } | null;
  t: number;
}

// ---- outline curvature κ (rad per metre), cached per outline ----
const curvCache = new WeakMap<object, Float64Array>();

/** signed-magnitude curvature at each outline point, in rad/metre (uses the projector's metre scale) */
export function outlineCurvature(proj: Projector): Float64Array {
  const o = proj.o;
  const cached = curvCache.get(o);
  if (cached) return cached;
  const n = o.x.length;
  const k = new Float64Array(n);
  for (let i = 0; i < n; i++) {
    const a = (i - 1 + n) % n;
    const b = (i + 1) % n;
    const h1 = Math.atan2(o.y[i] - o.y[a], o.x[i] - o.x[a]);
    const h2 = Math.atan2(o.y[b] - o.y[i], o.x[b] - o.x[i]);
    let dth = h2 - h1;
    while (dth > Math.PI) dth -= 2 * Math.PI;
    while (dth < -Math.PI) dth += 2 * Math.PI;
    const arcUnits = Math.hypot(o.x[i] - o.x[a], o.y[i] - o.y[a]) + Math.hypot(o.x[b] - o.x[i], o.y[b] - o.y[i]);
    const arcM = arcUnits * proj.mPerUnit || 1;
    k[i] = Math.abs(dth) / arcM; // rad per metre
  }
  // light smoothing (curvature from a discrete line is noisy)
  const sm = new Float64Array(n);
  for (let i = 0; i < n; i++) sm[i] = (k[(i - 1 + n) % n] + k[i] * 2 + k[(i + 1) % n]) / 4;
  curvCache.set(o, sm);
  return sm;
}

const GRAV = 9.80665;

export interface LapAnalytics {
  num: string;
  lapMs: number;
  topSpeed: number; // km/h
  avgSpeed: number; // km/h
  fullThrottlePct: number; // % of the lap at ≥98% throttle
  heavyBrakePct: number; // % of the lap braking (brk ≥ 50)
  maxLatG: number; // peak lateral g (racing-line estimate)
  samples: number;
}

/** driving-style + G analytics for a car's lap window (telemetry × geometry) */
export function lapAnalytics(engine: EngineLike, num: string, lap: LapWindow): LapAnalytics | null {
  const b = engine.bundle;
  const proj = projectorFor(engine as never);
  const car = b?.car[num];
  if (!b || !proj || !car || !car.t.length) return null;
  const curv = outlineCurvature(proj);
  const n = proj.o.x.length;
  let top = 0, sumV = 0, full = 0, brake = 0, count = 0, maxG = 0;
  for (let i = 0; i < car.t.length; i++) {
    const t = car.t[i];
    if (t < lap.tStart || t > lap.tEnd) continue;
    const v = car.spd[i];
    count++;
    sumV += v;
    if (v > top) top = v;
    if (car.thr[i] >= 98) full++;
    if (car.brk[i] >= 50) brake++;
    // lateral G at this instant: project the car's smoothed position → curvature
    const p = engine.carPos(num, t);
    if (p) {
      const pr = proj.project(p.x, p.y);
      const k = curv[Math.min(n - 1, pr.seg)];
      const vms = v / 3.6;
      const g = (vms * vms * k) / GRAV;
      if (g > maxG && g < 8) maxG = g; // clamp absurd spikes from line noise
    }
  }
  if (count < 5) return null;
  return {
    num,
    lapMs: lap.dur,
    topSpeed: Math.round(top),
    avgSpeed: Math.round(sumV / count),
    fullThrottlePct: Math.round((full / count) * 100),
    heavyBrakePct: Math.round((brake / count) * 100),
    maxLatG: Math.round(maxG * 10) / 10,
    samples: count,
  };
}

/** convenience: analytics for a driver's fastest lap */
export function fastestLapAnalytics(engine: EngineLike, num: string): LapAnalytics | null {
  const lap = fastestLapWindow(engine as never, num);
  if (!lap) return null;
  return lapAnalytics(engine, num, lap);
}

// ---- live on-track order + gap-in-metres (true geometry, not at-the-line) ----
export interface OnTrackRow {
  num: string;
  s: number; // metres along the current lap from S/F
  gapAheadM: number | null; // metres to the car physically ahead on track
}

/**
 * True on-track order at time t from each car's projected s — this leads the
 * classification line (which only updates at S/F). Cars not on a flying lap
 * (no fix / stopped) are dropped. NOTE: orders by current-lap s only (good for a
 * single-lap snapshot / quali out-laps); cross-lap race order needs lap counting.
 */
export function onTrackOrder(engine: EngineLike, t: number): OnTrackRow[] {
  const b = engine.bundle;
  const proj = projectorFor(engine as never);
  if (!b || !proj) return [];
  const rows: OnTrackRow[] = [];
  for (const num of Object.keys(b.pos)) {
    const ct = carTrack(engine as never, num);
    if (!ct || !ct.t.length) continue;
    // nearest sample at-or-before t (skip if stale > 5s)
    let lo = 0, hi = ct.t.length - 1;
    if (t < ct.t[0] || t > ct.t[hi] + 5000) continue;
    while (lo < hi) {
      const mid = (lo + hi + 1) >> 1;
      if (ct.t[mid] <= t) lo = mid;
      else hi = mid - 1;
    }
    if (t - ct.t[lo] > 5000) continue;
    rows.push({ num, s: ct.s[lo], gapAheadM: null });
  }
  rows.sort((a, b2) => b2.s - a.s); // furthest along first
  for (let i = 1; i < rows.length; i++) rows[i].gapAheadM = Math.round(rows[i - 1].s - rows[i].s);
  return rows;
}
← The whole kit