diff --git a/frontend/src/components/live/TyreDegPanel.tsx b/frontend/src/components/live/TyreDegPanel.tsx new file mode 100644 index 0000000..d7f072c --- /dev/null +++ b/frontend/src/components/live/TyreDegPanel.tsx @@ -0,0 +1,130 @@ +import { useEffect, useMemo, useState } from 'react' +import { teamColor } from '../../utils' +import type { LiveTimingRow } from '../../lib/live' +import { compoundClass, driverCode, tyreLabel } from '../../lib/live' +import { sparklinePoints } from '../../lib/gapHistory' +import { isRaceSession } from '../../lib/battles' +import type { StintHistoryMap } from '../../lib/tyredeg' +import { + PIT_LOSS_SECONDS, + degradationModel, + estimatePitRejoin, + formatSlope, + recordStintSamples, + stintInputFromRow, +} from '../../lib/tyredeg' +import '../../styles/tyredeg.css' + +const TOP_DRIVER_COUNT = 10 +const SPARK_WIDTH = 64 +const SPARK_HEIGHT = 16 + +interface Props { + rows: LiveTimingRow[] + sessionType: string | undefined + pinned: string[] +} + +function StintSparkline({ seconds }: { seconds: number[] }) { + if (seconds.length < 2) return · + const points = sparklinePoints(seconds, SPARK_WIDTH, SPARK_HEIGHT) + return ( + + ) +} + +export function TyreDegPanel({ rows, sessionType, pinned }: Props) { + const [collapsed, setCollapsed] = useState(false) + const [stints, setStints] = useState({}) + + // One lap-history update per received snapshot (rows is rebuilt per snapshot). + useEffect(() => { + if (rows.length === 0) return + setStints((prev) => recordStintSamples(prev, rows.map(stintInputFromRow))) + }, [rows]) + + const isRace = isRaceSession(sessionType) + + const visible = useMemo( + () => + rows.filter( + (row) => + row.Position > 0 && + !row.Driver.Retired && + (row.Position <= TOP_DRIVER_COUNT || pinned.includes(row.RacingNumber)), + ), + [rows, pinned], + ) + + if (visible.length === 0) return null + + return ( +
+ + + {!collapsed && ( +
+ {visible.map((row) => { + const model = degradationModel(stints[row.RacingNumber]?.samples ?? []) + const rejoin = isRace ? estimatePitRejoin(rows, row.RacingNumber) : null + return ( +
+ P{row.Position} + + {driverCode(row)} + {tyreLabel(row.Tyre)} + {model ? ( + <> + + sample.seconds)} /> + + + {formatSlope(model.slope)} + + + ) : ( + warming up + )} + {rejoin && ( + + → ~P{rejoin.rejoinPosition} + {rejoin.aheadCode && rejoin.behindCode && ( + + {rejoin.aheadCode} · {rejoin.behindCode} + + )} + + )} +
+ ) + })} +
+ )} +
+ ) +} diff --git a/frontend/src/lib/tyredeg.ts b/frontend/src/lib/tyredeg.ts new file mode 100644 index 0000000..2e4cb12 --- /dev/null +++ b/frontend/src/lib/tyredeg.ts @@ -0,0 +1,271 @@ +// Tyre degradation + pit-window estimation for the live timing page. +// Pure functions only — no React, no side effects — so everything is unit-testable. +// +// Lap-time samples are accumulated client-side from successive SSE snapshots +// (mirroring the gapHistory/battles precedent); nothing here talks to the server. + +import type { LiveTimingRow } from './live' +import { driverCode } from './live' +import { parseIntervalSeconds } from './gapHistory' + +/** + * Rough typical pit-lane time loss (entry + stop + exit vs a flying lap), in + * seconds. Per-track values are deliberately out of scope for v1. + */ +export const PIT_LOSS_SECONDS = 22 + +/** Minimum clean laps in the current stint before a slope is trustworthy. */ +export const MIN_CLEAN_LAPS = 4 + +/** + * Laps more than this many seconds off the stint median are treated as + * outliers (traffic, spins, safety car) and excluded from the fit. + */ +export const OUTLIER_DELTA_SECONDS = 5 + +/** + * Slope classification thresholds (seconds per lap): + * slope <= -DEG_SLOPE_THRESHOLD -> 'improving' (track evolution, fuel burn dominating) + * slope >= +DEG_SLOPE_THRESHOLD -> 'degrading' (tyre wear dominating) + * otherwise -> 'stable' + */ +export const DEG_SLOPE_THRESHOLD = 0.05 + +/** Safety cap so a very long stint cannot grow the buffer unbounded. */ +export const MAX_STINT_SAMPLES = 80 + +export type DegTrend = 'improving' | 'stable' | 'degrading' + +export interface StintSample { + lap: number + seconds: number +} + +export interface DriverStintHistory { + compound: string + tyreAge: number + lastLapNumber: number + lastLapTime: string + /** The next completed lap is an out-lap (fresh stint) and must be discarded. */ + skipNextLap: boolean + samples: StintSample[] +} + +/** Per-driver current-stint lap history, keyed by racing number. */ +export type StintHistoryMap = Record + +export interface StintLapInput { + racingNumber: string + lapNumber: number + lastLapTime: string + compound: string + tyreAge: number + inPit: boolean + pitOut: boolean +} + +/** + * Parse an F1 live-timing lap time string ("1:23.456", "83.456") into seconds. + * Same conventions as parseIntervalSeconds, but a lap time is never signed and + * never a lapped/leader marker; zero or negative values are rejected. + */ +export function parseLapTimeSeconds(raw: string | null | undefined): number | null { + const value = parseIntervalSeconds(raw) + if (value === null || value <= 0) return null + return value +} + +export function stintInputFromRow(row: LiveTimingRow): StintLapInput { + return { + racingNumber: row.RacingNumber, + lapNumber: row.Driver.NumberOfLaps || 0, + lastLapTime: row.Driver.LastLapTime || '', + compound: row.Tyre?.Compound || '', + tyreAge: row.Tyre?.Age ?? 0, + inPit: Boolean(row.Driver.InPit), + pitOut: Boolean(row.Driver.PitOut), + } +} + +/** + * Record one snapshot's worth of lap samples for each driver's current stint. + * Returns a new map (input is not mutated); drivers missing from `inputs` are + * pruned. + * + * A lap counts as completed when LastLapTime changes to a new non-empty value + * (NumberOfLaps can tick before the lap time arrives, so the time string is + * the trigger; a sample for the same lap number is replaced, not duplicated). + * The stint buffer resets when the compound changes or the tyre age drops + * (new set fitted), and laps completed in the pit lane (in-lap), on pit exit + * (out-lap), or immediately after a reset are excluded. + */ +export function recordStintSamples( + history: StintHistoryMap, + inputs: ReadonlyArray, +): StintHistoryMap { + const next: StintHistoryMap = {} + + for (const input of inputs) { + if (!input.racingNumber) continue + + const prior = history[input.racingNumber] + if (!prior) { + next[input.racingNumber] = { + compound: input.compound, + tyreAge: input.tyreAge, + lastLapNumber: input.lapNumber, + lastLapTime: input.lastLapTime, + skipNextLap: false, + samples: [], + } + continue + } + + let samples = prior.samples + let skipNextLap = prior.skipNextLap + + const compoundChanged = Boolean(input.compound) && Boolean(prior.compound) && input.compound !== prior.compound + const freshSet = input.tyreAge < prior.tyreAge + if (compoundChanged || freshSet || input.inPit) { + // Stint over (or a new one starting): drop the old laps and flag the + // upcoming out-lap for exclusion. + samples = [] + skipNextLap = true + } + + const lapCompleted = Boolean(input.lastLapTime) && input.lastLapTime !== prior.lastLapTime + if (lapCompleted) { + const seconds = parseLapTimeSeconds(input.lastLapTime) + const dirty = input.inPit || input.pitOut || skipNextLap + if (seconds !== null && !dirty) { + const last = samples[samples.length - 1] + if (last && last.lap === input.lapNumber) { + samples = [...samples.slice(0, -1), { lap: input.lapNumber, seconds }] + } else { + samples = [...samples, { lap: input.lapNumber, seconds }] + if (samples.length > MAX_STINT_SAMPLES) { + samples = samples.slice(samples.length - MAX_STINT_SAMPLES) + } + } + } + if (!input.inPit) skipNextLap = false + } + + next[input.racingNumber] = { + compound: input.compound || prior.compound, + tyreAge: input.tyreAge, + lastLapNumber: input.lapNumber, + lastLapTime: input.lastLapTime || prior.lastLapTime, + skipNextLap, + samples, + } + } + + return next +} + +export interface DegModel { + /** Least-squares slope of lap time vs lap number, in seconds per lap. */ + slope: number + trend: DegTrend + /** Clean (outlier-filtered) samples the fit ran over, in lap order. */ + samples: StintSample[] +} + +/** Drop laps more than OUTLIER_DELTA_SECONDS off the stint median. */ +export function cleanStintSamples( + samples: ReadonlyArray, + maxDelta = OUTLIER_DELTA_SECONDS, +): StintSample[] { + if (samples.length === 0) return [] + const sorted = samples.map((sample) => sample.seconds).sort((a, b) => a - b) + const mid = Math.floor(sorted.length / 2) + const median = sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2 + return samples.filter((sample) => Math.abs(sample.seconds - median) <= maxDelta) +} + +/** + * Fit a linear degradation model over the stint's clean laps. + * Returns null with fewer than MIN_CLEAN_LAPS clean samples — the caller + * should show a "warming up" placeholder rather than a junk slope. + */ +export function degradationModel(samples: ReadonlyArray): DegModel | null { + const clean = cleanStintSamples(samples) + if (clean.length < MIN_CLEAN_LAPS) return null + + const n = clean.length + const meanLap = clean.reduce((sum, s) => sum + s.lap, 0) / n + const meanSec = clean.reduce((sum, s) => sum + s.seconds, 0) / n + + let numerator = 0 + let denominator = 0 + for (const sample of clean) { + numerator += (sample.lap - meanLap) * (sample.seconds - meanSec) + denominator += (sample.lap - meanLap) ** 2 + } + if (denominator === 0) return null + + const slope = numerator / denominator + const trend: DegTrend = + slope <= -DEG_SLOPE_THRESHOLD ? 'improving' : slope >= DEG_SLOPE_THRESHOLD ? 'degrading' : 'stable' + + return { slope, trend, samples: clean } +} + +/** "+0.08s/lap" / "-0.12s/lap" display form. */ +export function formatSlope(slope: number): string { + const sign = slope >= 0 ? '+' : '−' + return `${sign}${Math.abs(slope).toFixed(2)}s/lap` +} + +export interface PitRejoinEstimate { + rejoinPosition: number + /** Cars behind that would get past during the stop. */ + positionsLost: number + /** Driver code directly ahead after rejoining, if any. */ + aheadCode: string | null + /** Driver code directly behind after rejoining, if any. */ + behindCode: string | null +} + +/** + * Estimate where a driver rejoins after a pit stop costing `pitLoss` seconds. + * + * Walks the cars behind the driver, accumulating their Interval (gap to car + * ahead) values: every car whose cumulative gap to the driver is under the + * pit loss gets past. An unparsable interval (lapped marker like "1L") ends + * the walk — those cars are at least a lap down and stay behind. Cars in the + * pits or retired are excluded from the ladder. + */ +export function estimatePitRejoin( + rows: ReadonlyArray, + racingNumber: string, + pitLoss = PIT_LOSS_SECONDS, +): PitRejoinEstimate | null { + const ladder = rows + .filter((row) => row.Position > 0 && !row.Driver.Retired && !row.Driver.InPit) + .sort((a, b) => a.Position - b.Position) + + const index = ladder.findIndex((row) => row.RacingNumber === racingNumber) + if (index === -1) return null + + let cumulative = 0 + let positionsLost = 0 + for (let i = index + 1; i < ladder.length; i++) { + const gap = parseIntervalSeconds(ladder[i].Driver.Interval) + if (gap === null) break + cumulative += Math.max(0, gap) + if (cumulative >= pitLoss) break + positionsLost += 1 + } + + const ahead = positionsLost > 0 ? ladder[index + positionsLost] : ladder[index - 1] + const behind = ladder[index + positionsLost + 1] + + return { + rejoinPosition: ladder[index].Position + positionsLost, + positionsLost, + aheadCode: ahead ? driverCode(ahead) : null, + behindCode: behind ? driverCode(behind) : null, + } +} diff --git a/frontend/src/pages/LiveTimingPage.tsx b/frontend/src/pages/LiveTimingPage.tsx index a824453..1321bba 100644 --- a/frontend/src/pages/LiveTimingPage.tsx +++ b/frontend/src/pages/LiveTimingPage.tsx @@ -23,6 +23,7 @@ import { PinnedDrivers } from '../components/live/PinnedDrivers' import { RaceControlFeed } from '../components/live/RaceControlFeed' import { TeamRadioTicker } from '../components/live/TeamRadioTicker' import { TrackMap } from '../components/live/TrackMap' +import { TyreDegPanel } from '../components/live/TyreDegPanel' import { Radio } from 'lucide-react' type StreamStatus = 'connecting' | 'connected' | 'disconnected' | 'error' @@ -194,6 +195,7 @@ export function LiveTimingPage() { driverInfo={snapshot.DriverInfo} loading={trackOutlineQuery.isLoading} /> +
diff --git a/frontend/src/styles/tyredeg.css b/frontend/src/styles/tyredeg.css new file mode 100644 index 0000000..c6d6950 --- /dev/null +++ b/frontend/src/styles/tyredeg.css @@ -0,0 +1,84 @@ +/* Live tyre-degradation & pit-window panel (see components/live/TyreDegPanel.tsx) */ + +.live-tyredeg-panel { + margin-bottom: var(--s5); +} + +.tyredeg-toggle { + width: 100%; + background: none; + border: none; + padding: 0; + cursor: pointer; + text-align: left; +} + +.tyredeg-chevron { + margin-left: auto; + font-size: 10px; + color: var(--text-3); +} + +.tyredeg-rows { + display: flex; + flex-direction: column; + gap: 3px; +} + +.tyredeg-row { + display: flex; + align-items: center; + gap: var(--s4); + padding: var(--s2) var(--s4); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 3px; + font-size: 12px; +} + +.tyredeg-row .drv-bar { height: 16px; } + +.tyredeg-pos { + min-width: 28px; + color: var(--text-2); + font-size: 11px; +} + +.tyredeg-spark { display: block; } +.tyredeg-spark-empty { color: var(--text-3); } + +.tyredeg-trend { display: inline-flex; align-items: center; } +.tyredeg-trend-improving { color: var(--green); } +.tyredeg-trend-stable { color: var(--text-3); } +.tyredeg-trend-degrading { color: #ff6b6b; } + +.tyredeg-slope { + min-width: 78px; + font-size: 11px; +} + +.tyredeg-warmup { + color: var(--text-3); + font-size: 11px; + font-style: italic; +} + +.tyredeg-rejoin { + margin-left: auto; + display: inline-flex; + align-items: baseline; + gap: var(--s3); + color: var(--text-2); + font-size: 11px; + white-space: nowrap; +} + +.tyredeg-rejoin-between { + color: var(--text-3); + font-size: 10px; +} + +@media (max-width: 640px) { + .tyredeg-rejoin-between { display: none; } + .tyredeg-slope { min-width: 0; } +} diff --git a/frontend/src/test/TyreDegPanel.test.tsx b/frontend/src/test/TyreDegPanel.test.tsx new file mode 100644 index 0000000..1bb9eff --- /dev/null +++ b/frontend/src/test/TyreDegPanel.test.tsx @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { fireEvent, render, screen } from '@testing-library/react' +import { TyreDegPanel } from '../components/live/TyreDegPanel' +import type { LiveTimingRow } from '../lib/live' +import type { LiveDriverData, LiveTyreData } from '../types' + +function makeRow( + number: string, + position: number, + tla: string, + driver: Partial = {}, + tyre: Partial = {}, +): LiveTimingRow { + return { + RacingNumber: number, + Position: position, + Driver: { + RacingNumber: number, + Position: position, + Interval: position === 1 ? '' : '+5.0', + GapToLeader: '', + LastLapTime: '1:30.000', + NumberOfLaps: 10, + InPit: false, + PitOut: false, + Retired: false, + ...driver, + } as LiveDriverData, + Info: { + RacingNumber: number, + BroadcastName: '', + Tla: tla, + TeamName: '', + TeamColour: '3671c6', + FirstName: '', + LastName: '', + }, + Tyre: { Compound: 'MEDIUM', New: false, Age: 5, ...tyre } as LiveTyreData, + } +} + +function snapshotRows(lap: number, lastLapTime: string): LiveTimingRow[] { + return [ + makeRow('1', 1, 'VER', { NumberOfLaps: lap, LastLapTime: lastLapTime }), + makeRow('4', 2, 'NOR', { NumberOfLaps: lap, LastLapTime: lastLapTime }), + ] +} + +describe('TyreDegPanel', () => { + it('shows a warming-up placeholder until enough clean laps accumulate', () => { + render() + const panel = screen.getByTestId('tyredeg-panel') + expect(panel).toHaveTextContent('VER') + expect(panel).toHaveTextContent('M +5') + expect(panel).toHaveTextContent('warming up') + }) + + it('renders slope and rejoin estimate once laps accumulate across snapshots', () => { + const { rerender } = render( + , + ) + // Five completed laps at +0.1s/lap after the first observed snapshot. + for (let lap = 2; lap <= 6; lap++) { + const time = `1:30.${String((lap - 1) * 100).padStart(3, '0')}` + rerender() + } + + const panel = screen.getByTestId('tyredeg-panel') + expect(panel).toHaveTextContent('+0.10s/lap') + expect(panel).not.toHaveTextContent('warming up') + // VER pits from P1 with NOR +5.0 behind: NOR gets past -> rejoin ~P2. + expect(panel).toHaveTextContent('→ ~P2') + }) + + it('hides the rejoin estimate outside race sessions and collapses on toggle', () => { + render() + const panel = screen.getByTestId('tyredeg-panel') + expect(panel).not.toHaveTextContent('~P') + + expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2) + fireEvent.click(screen.getByRole('button', { name: /tyre deg/i })) + expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0) + }) + + it('limits rows to the top ten plus pinned drivers', () => { + const rows = Array.from({ length: 15 }, (_, index) => + makeRow(String(index + 1), index + 1, `D${index + 1}`), + ) + render() + expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(11) + expect(screen.getByText('D14')).toBeInTheDocument() + expect(screen.queryByText('D12')).not.toBeInTheDocument() + }) + + it('renders nothing without rows', () => { + render() + expect(screen.queryByTestId('tyredeg-panel')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/test/tyredeg.test.ts b/frontend/src/test/tyredeg.test.ts new file mode 100644 index 0000000..99fb867 --- /dev/null +++ b/frontend/src/test/tyredeg.test.ts @@ -0,0 +1,281 @@ +import { describe, expect, it } from 'vitest' +import { + DEG_SLOPE_THRESHOLD, + MIN_CLEAN_LAPS, + PIT_LOSS_SECONDS, + cleanStintSamples, + degradationModel, + estimatePitRejoin, + formatSlope, + parseLapTimeSeconds, + recordStintSamples, + stintInputFromRow, +} from '../lib/tyredeg' +import type { StintHistoryMap, StintLapInput, StintSample } from '../lib/tyredeg' +import type { LiveTimingRow } from '../lib/live' +import type { LiveDriverData } from '../types' + +function makeInput(overrides: Partial = {}): StintLapInput { + return { + racingNumber: '1', + lapNumber: 1, + lastLapTime: '1:30.000', + compound: 'MEDIUM', + tyreAge: 1, + inPit: false, + pitOut: false, + ...overrides, + } +} + +/** Feed a sequence of snapshots for one driver through the accumulator. */ +function accumulate(snapshots: Array>): StintHistoryMap { + let history: StintHistoryMap = {} + for (const snapshot of snapshots) { + history = recordStintSamples(history, [makeInput(snapshot)]) + } + return history +} + +function makeRow( + number: string, + position: number, + tla: string, + driver: Partial = {}, +): LiveTimingRow { + return { + RacingNumber: number, + Position: position, + Driver: { + RacingNumber: number, + Position: position, + Interval: '', + GapToLeader: '', + LastLapTime: '1:30.000', + NumberOfLaps: 10, + InPit: false, + PitOut: false, + Retired: false, + ...driver, + } as LiveDriverData, + Info: { + RacingNumber: number, + BroadcastName: '', + Tla: tla, + TeamName: '', + TeamColour: '3671c6', + FirstName: '', + LastName: '', + }, + } +} + +describe('parseLapTimeSeconds', () => { + it('parses minute-form and plain-second lap times', () => { + expect(parseLapTimeSeconds('1:23.456')).toBeCloseTo(83.456) + expect(parseLapTimeSeconds('83.456')).toBeCloseTo(83.456) + }) + + it('rejects empty, garbage, and non-positive values', () => { + expect(parseLapTimeSeconds('')).toBeNull() + expect(parseLapTimeSeconds(undefined)).toBeNull() + expect(parseLapTimeSeconds(null)).toBeNull() + expect(parseLapTimeSeconds('LAP 12')).toBeNull() + expect(parseLapTimeSeconds('-1:30.000')).toBeNull() + }) +}) + +describe('recordStintSamples', () => { + it('accumulates one sample per completed lap across snapshots', () => { + const history = accumulate([ + { lapNumber: 1, lastLapTime: '1:30.000' }, + { lapNumber: 2, lastLapTime: '1:30.100' }, + { lapNumber: 2, lastLapTime: '1:30.100' }, // repeated snapshot, same lap + { lapNumber: 3, lastLapTime: '1:30.200' }, + ]) + expect(history['1'].samples).toEqual([ + { lap: 2, seconds: 90.1 }, + { lap: 3, seconds: 90.2 }, + ]) + }) + + it('does not record a lap from the very first snapshot seen', () => { + const history = accumulate([{ lapNumber: 5, lastLapTime: '1:29.000' }]) + expect(history['1'].samples).toEqual([]) + }) + + it('replaces (not duplicates) a sample when the lap time is corrected', () => { + const history = accumulate([ + { lapNumber: 1, lastLapTime: '1:30.000' }, + { lapNumber: 2, lastLapTime: '1:30.500' }, + { lapNumber: 2, lastLapTime: '1:30.400' }, // late correction for lap 2 + ]) + expect(history['1'].samples).toEqual([{ lap: 2, seconds: 90.4 }]) + }) + + it('resets the stint on compound change and skips the next lap', () => { + const history = accumulate([ + { lapNumber: 1, lastLapTime: '1:30.000', compound: 'SOFT', tyreAge: 1 }, + { lapNumber: 2, lastLapTime: '1:30.100', compound: 'SOFT', tyreAge: 2 }, + { lapNumber: 3, lastLapTime: '1:30.200', compound: 'SOFT', tyreAge: 3 }, + // Boxed for hards: old samples dropped, out-lap (lap 4) excluded. + { lapNumber: 4, lastLapTime: '1:52.000', compound: 'HARD', tyreAge: 0 }, + { lapNumber: 5, lastLapTime: '1:31.000', compound: 'HARD', tyreAge: 1 }, + { lapNumber: 6, lastLapTime: '1:31.100', compound: 'HARD', tyreAge: 2 }, + ]) + expect(history['1'].samples).toEqual([ + { lap: 5, seconds: 91.0 }, + { lap: 6, seconds: 91.1 }, + ]) + }) + + it('resets on a fresh set of the same compound (tyre age drops)', () => { + const history = accumulate([ + { lapNumber: 1, lastLapTime: '1:30.000', tyreAge: 10 }, + { lapNumber: 2, lastLapTime: '1:30.100', tyreAge: 11 }, + { lapNumber: 3, lastLapTime: '1:50.000', tyreAge: 0 }, // new mediums + { lapNumber: 4, lastLapTime: '1:30.500', tyreAge: 1 }, + ]) + expect(history['1'].samples).toEqual([{ lap: 4, seconds: 90.5 }]) + }) + + it('excludes laps completed in the pit lane or on pit exit', () => { + const history = accumulate([ + { lapNumber: 1, lastLapTime: '1:30.000' }, + { lapNumber: 2, lastLapTime: '1:30.100' }, + { lapNumber: 3, lastLapTime: '1:48.000', inPit: true }, // in-lap + { lapNumber: 4, lastLapTime: '1:45.000', pitOut: true, tyreAge: 0 }, // out-lap + { lapNumber: 5, lastLapTime: '1:30.300', tyreAge: 1 }, + ]) + expect(history['1'].samples).toEqual([{ lap: 5, seconds: 90.3 }]) + }) + + it('prunes drivers missing from the snapshot and does not mutate input', () => { + const initial = accumulate([ + { lapNumber: 1, lastLapTime: '1:30.000' }, + { lapNumber: 2, lastLapTime: '1:30.100' }, + ]) + const next = recordStintSamples(initial, [makeInput({ racingNumber: '44' })]) + expect(next['1']).toBeUndefined() + expect(next['44']).toBeDefined() + expect(initial['1'].samples).toHaveLength(1) + }) +}) + +describe('degradationModel', () => { + const series = (times: number[], startLap = 1): StintSample[] => + times.map((seconds, index) => ({ lap: startLap + index, seconds })) + + it('fits the slope of a known linear series', () => { + const model = degradationModel(series([90.0, 90.1, 90.2, 90.3, 90.4])) + expect(model).not.toBeNull() + expect(model!.slope).toBeCloseTo(0.1, 5) + expect(model!.trend).toBe('degrading') + }) + + it('classifies improving and stable stints', () => { + expect(degradationModel(series([91.0, 90.8, 90.6, 90.4]))!.trend).toBe('improving') + expect(degradationModel(series([90.0, 90.01, 90.0, 90.02, 90.01]))!.trend).toBe('stable') + // A slope clearly under the threshold stays stable. + expect(DEG_SLOPE_THRESHOLD).toBeGreaterThan(0.03) + expect(degradationModel(series([90.0, 90.03, 90.06, 90.09, 90.12]))!.trend).toBe('stable') + }) + + it('excludes outliers more than ~5s off the stint median', () => { + // Clean laps follow +0.1s/lap; the 97.5 (traffic/spin) must not skew the fit. + const samples = series([90.0, 90.1, 97.5, 90.3, 90.4, 90.5]) + expect(cleanStintSamples(samples)).toHaveLength(5) + const model = degradationModel(samples) + expect(model!.samples).toHaveLength(5) + expect(model!.slope).toBeCloseTo(0.1, 5) + }) + + it('returns null with fewer than MIN_CLEAN_LAPS clean laps', () => { + expect(degradationModel(series([90.0, 90.1, 90.2]))).toBeNull() + // 4 raw laps but one outlier -> only 3 clean -> still warming up + expect(degradationModel(series([90.0, 90.1, 90.2, 99.0]))).toBeNull() + expect(MIN_CLEAN_LAPS).toBe(4) + }) +}) + +describe('formatSlope', () => { + it('renders signed seconds-per-lap', () => { + expect(formatSlope(0.083)).toBe('+0.08s/lap') + expect(formatSlope(-0.125)).toBe('−0.13s/lap') + }) +}) + +describe('estimatePitRejoin', () => { + const ladder = [ + makeRow('1', 1, 'VER'), + makeRow('4', 2, 'NOR', { Interval: '+5.0' }), + makeRow('44', 3, 'HAM', { Interval: '+10.0' }), + makeRow('14', 4, 'ALO', { Interval: '+15.0' }), + makeRow('16', 5, 'LEC', { Interval: '+20.0' }), + ] + + it('projects the rejoin slot between the right cars', () => { + // VER pits from P1: NOR (5s) and HAM (15s cumulative) get past; + // ALO would be 30s back -> stays behind. Rejoin ~P3. + const estimate = estimatePitRejoin(ladder, '1') + expect(estimate).toEqual({ + rejoinPosition: 3, + positionsLost: 2, + aheadCode: 'HAM', + behindCode: 'ALO', + }) + }) + + it('keeps position when the cars behind are further than the pit loss', () => { + const estimate = estimatePitRejoin(ladder, '14') + // LEC is 20s behind ALO — inside the 22s window, so he gets past. + expect(estimate!.rejoinPosition).toBe(5) + expect(estimate!.aheadCode).toBe('LEC') + expect(estimate!.behindCode).toBeNull() + + const wide = estimatePitRejoin( + [makeRow('1', 1, 'VER'), makeRow('4', 2, 'NOR', { Interval: `+${PIT_LOSS_SECONDS + 3}.0` })], + '1', + ) + expect(wide!.rejoinPosition).toBe(1) + expect(wide!.positionsLost).toBe(0) + expect(wide!.behindCode).toBe('NOR') + }) + + it('stops the ladder walk at lapped cars and skips pitted/retired ones', () => { + const estimate = estimatePitRejoin( + [ + makeRow('1', 1, 'VER'), + makeRow('4', 2, 'NOR', { Interval: '+5.0', InPit: true }), // mid-stop, excluded + makeRow('44', 3, 'HAM', { Interval: '+10.0' }), + makeRow('16', 4, 'LEC', { Interval: '1L' }), // lapped — walk ends here + makeRow('14', 5, 'ALO', { Interval: '+2.0' }), + ], + '1', + ) + expect(estimate!.positionsLost).toBe(1) // only HAM gets past + expect(estimate!.rejoinPosition).toBe(2) + expect(estimate!.aheadCode).toBe('HAM') + expect(estimate!.behindCode).toBe('LEC') + }) + + it('returns null for unknown drivers', () => { + expect(estimatePitRejoin(ladder, '99')).toBeNull() + }) +}) + +describe('stintInputFromRow', () => { + it('maps tower rows into accumulator inputs', () => { + const row = makeRow('4', 2, 'NOR', { NumberOfLaps: 12, LastLapTime: '1:29.900' }) + row.Tyre = { Compound: 'HARD', New: false, Age: 7 } + expect(stintInputFromRow(row)).toEqual({ + racingNumber: '4', + lapNumber: 12, + lastLapTime: '1:29.900', + compound: 'HARD', + tyreAge: 7, + inPit: false, + pitOut: false, + }) + }) +})