Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/autopilot/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,15 @@ Question: `RaceTrialInputs.dnf_prob` is the fold of both channels, so the res
Chosen: Carry the channels as two *optional* fields beside `dnf_prob`, which stays what `simulate_positions` draws against. A caller holding only a combined probability leaves them None and gets the pre-slice-6 flat scaling rather than an invented split. `cli.forecast_weekend` fills them; `backtest.forecast_race_challenger` does not, because it resolves the Grand Prix alone through `simulate_positions` and has no sprint to price.
Rejected: Making the channels the primary fields and deriving `dnf_prob` as a computed property. Tidier, and it removes the chance of the fold disagreeing with its parts — but a dataclass property cannot be passed to the constructor, so every existing `RaceTrialInputs(dnf_prob=...)` call site and four test modules would have to be rewritten, for no change in behaviour. Also rejected: a single `incident_share` scalar, which assumes one split for the whole field when the mechanical channel is per team and the incident channel per driver.
Blast radius: Two fields on `RaceTrialInputs` and two keyword arguments in `cli.forecast_weekend`. The risk worth naming: `dnf_prob` and the channels are now two statements of the same quantity and nothing enforces that they agree — a caller could set channels inconsistent with the fold and the Grand Prix would use one while the sprint used the other. `tests/test_cli.py::test_both_channels_reach_the_resolver_and_fold_to_dnf_prob` asserts the production path keeps them consistent; the dataclass itself does not.

## 2026-10-20 — slice-7
Question: The criterion allows either fitting only the forecast round's circuit or caching the full fit per pre-round window. Which?
Chosen: Fit only the forecast circuit: `build_overtaking_data(..., event_name=...)`, passed by both call sites through a new `cli._round_event_name`. One fit per forecast, whatever the window holds. Omitting `event_name` still fits the whole window, so the slice-4 tests and any diagnostic caller are unchanged.
Rejected: A cache keyed on the pre-round window. Each backtest round has a different window, so a cache only helps when the same round is forecast twice in one process, and it adds invalidation questions (a mutated `driver_rounds` frame) for no gain on the backtest path the carry-forward was about.
Blast radius: One keyword on `build_overtaking_data`, two call sites. The fitted value for the forecast circuit is identical either way (`tests/test_overtaking_wiring.py::TestSingleCircuitFit` asserts equality with the full fit), so no forecast number moves.

## 2026-10-20 — slice-7
Question: How should `collect_v2` flag a quali row whose time came from the session laps (2025 Miami), and which laps count?
Chosen: `q_segment = "laps"` on the row plus a WARNING log naming the drivers. Per row: only a row with a `Position` and no Q1/Q2/Q3 time falls back, to that driver's fastest lap with `Deleted` laps excluded. Pole is still the official Q3→Q2→Q1 minimum wherever any official time exists; lap times set pole only when the session has none at all.
Rejected: A new `time_source` column. Clearer, but it changes the `driver_rounds.csv` schema (COLUMNS, the validator, every existing file) for one round in nine seasons; `q_segment` has no readers in `src/` or `tests/`, so overloading it is free. Also rejected: triggering only when the whole session lacks times. Simpler, but the criterion is per row, and a partial upstream gap would then still drop drivers silently.
Blast radius: `_fastest_session_laps` and the fallback block in `collect_v2._build_quali_rows`. Fully-timed sessions are byte-for-byte unchanged (the laps are never read; a test asserts it). Lap-derived times can order near-ties differently from the official classification (NOR/PIA/ANT in Miami sit within 0.002s); `finish_position` still comes from the results, so the grid is unaffected.
2 changes: 1 addition & 1 deletion docs/autopilot/queue.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ includes `--grid-from-quali` and the post-quali backtest arm.

## slice-7: Phase 2 carry-forwards

Status: todo
Status: in-review (PR #20)

Spec: `docs/phase2-status.md` "What is still open"; slice-4 notes above.

Expand Down
5 changes: 3 additions & 2 deletions src/backtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,15 +182,16 @@ def forecast_race_challenger(challenger_model, reliability_data, driver_rounds:
combined_dnf_prob, sample_incident_dnf_prob, sample_mechanical_dnf_prob,
)
from sim.race import RaceTrialInputs, simulate_positions, summarize_trials, load_points_table
from cli import _entrants_for_round, _circuit_info
from cli import _entrants_for_round, _circuit_info, _round_event_name
from model.overtaking import build_overtaking_data

rng = np.random.default_rng(seed)
entrants = _entrants_for_round(driver_rounds, season, round_)
# Same fitted overtaking parameter the Bayesian path uses, off the same
# pre-round window -- "same race resolver" above has to stay true.
overtaking_data = build_overtaking_data(driver_rounds, circuits,
before_season=season, before_round=round_)
before_season=season, before_round=round_,
event_name=_round_event_name(driver_rounds, season, round_))
circuit_type, overtaking_difficulty = _circuit_info(circuits, driver_rounds, season, round_,
overtaking_data)

Expand Down
11 changes: 9 additions & 2 deletions src/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,12 @@ def _quali_grid_for_round(driver_rounds: pd.DataFrame, season: int, round_: int,
return values.rank(method="first", na_option="bottom").to_numpy(dtype=int)


def _round_event_name(driver_rounds: pd.DataFrame, season: int, round_: int):
"""The round's event name, or None when the round has no rows."""
rows = driver_rounds[(driver_rounds["season"] == season) & (driver_rounds["round"] == round_)]
return rows.iloc[0]["event_name"] if not rows.empty else None


def _circuit_info(circuits: pd.DataFrame, driver_rounds: pd.DataFrame, season: int, round_: int,
overtaking_data=None) -> tuple[str, float]:
"""(circuit_type, overtaking_difficulty) for one round.
Expand All @@ -257,7 +263,7 @@ def _circuit_info(circuits: pd.DataFrame, driver_rounds: pd.DataFrame, season: i
rows = driver_rounds[(driver_rounds["season"] == season) & (driver_rounds["round"] == round_)]
if rows.empty:
return "mixed", DEFAULT_DIFFICULTY
event_name = rows.iloc[0]["event_name"]
event_name = _round_event_name(driver_rounds, season, round_)
circuit_type = rows.iloc[0]["circuit_type"] if pd.notna(rows.iloc[0]["circuit_type"]) else "mixed"
match = circuits[circuits["event_name"] == event_name]
hand_rating = float(match.iloc[0]["overtaking_difficulty"]) if not match.empty else None
Expand Down Expand Up @@ -303,7 +309,8 @@ def forecast_weekend(idata, data, reliability_data, driver_rounds: pd.DataFrame,
# model on a parameter that already saw the answer.
if overtaking_data is None:
overtaking_data = build_overtaking_data(driver_rounds, circuits,
before_season=season, before_round=round_)
before_season=season, before_round=round_,
event_name=_round_event_name(driver_rounds, season, round_))
circuit_type, overtaking_difficulty = _circuit_info(circuits, driver_rounds, season, round_,
overtaking_data)
round_idx = data.round_to_idx.get((season, round_)) # None for a genuinely future round -> use latest
Expand Down
71 changes: 62 additions & 9 deletions src/collect_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,34 @@ def _weather_summary(session) -> tuple[bool | None, float | None]:
return None, None


# `q_segment` on a quali row whose time came from the session laps rather
# than the official Q1/Q2/Q3 columns. Nothing reads q_segment as a segment
# name, so the flag needs no schema change.
LAPS_FALLBACK_SEGMENT = "laps"


def _fastest_session_laps(session) -> dict:
"""abbreviation -> fastest non-deleted lap in seconds, off the session laps.

The fallback for a quali whose results carry positions but no Q1/Q2/Q3
times (2025 R6 Miami upstream). Deleted laps (track limits) are excluded,
so a driver whose only lap was struck off still gets no time.
"""
try:
laps = session.laps
except Exception:
log.warning(" session laps unavailable for %s %s", getattr(session, "event", "?"),
getattr(session, "name", "?"), exc_info=True)
return {}
if laps is None or laps.empty or "LapTime" not in laps.columns:
return {}
laps = laps.dropna(subset=["LapTime"])
if "Deleted" in laps.columns:
laps = laps[laps["Deleted"].fillna(False) != True] # noqa: E712 -- object dtype
fastest = laps.groupby("Driver")["LapTime"].min()
return {str(d): t / np.timedelta64(1, "s") for d, t in fastest.items()}


def _build_quali_rows(year: int, rnd: int, event_name: str, circuit_type: str) -> list[dict]:
rows = []
try:
Expand All @@ -147,22 +175,47 @@ def _build_quali_rows(year: int, rnd: int, event_name: str, circuit_type: str) -
return rows

is_wet, air_temp = _weather_summary(session)
pole_time = None
for q_col in ("Q3", "Q2", "Q1"):
col = results[q_col].dropna()
if not col.empty:
pole_time = col.min() / np.timedelta64(1, "s")
break

best_by_row = []
for _, r in results.iterrows():
best = None
q_segment = None
best, q_segment = None, None
for q_col in ("Q3", "Q2", "Q1"):
t = r.get(q_col)
if pd.notna(t):
best = t / np.timedelta64(1, "s")
q_segment = q_col
break
best_by_row.append((best, q_segment))

# A classified row with no Q time is the Miami 2025 gap: fall back to the
# driver's fastest session lap, and say so in the row and the log.
needs_laps = [i for i, (_, r) in enumerate(results.iterrows())
if best_by_row[i][0] is None and pd.notna(r.get("Position"))]
if needs_laps:
lap_times = _fastest_session_laps(session)
filled = []
for i in needs_laps:
abbr = str(results.iloc[i].get("Abbreviation", ""))
if abbr in lap_times:
best_by_row[i] = (lap_times[abbr], LAPS_FALLBACK_SEGMENT)
filled.append(abbr)
if filled:
log.warning(" %d Round %d quali: no Q1/Q2/Q3 time for %s -- best_lap_s taken from "
"the fastest session lap (q_segment=%r)", year, rnd, ", ".join(filled),
LAPS_FALLBACK_SEGMENT)

# Pole is the official one wherever a Q column has times, exactly as
# before; lap-derived times set it only when the session has none at all.
pole_time = None
for q_col in ("Q3", "Q2", "Q1"):
col = results[q_col].dropna()
if not col.empty:
pole_time = col.min() / np.timedelta64(1, "s")
break
timed = [b for b, _ in best_by_row if b is not None]
if pole_time is None and timed:
pole_time = min(timed)

for (_, r), (best, q_segment) in zip(results.iterrows(), best_by_row):
rows.append({
"season": year, "round": rnd, "event_name": event_name, "circuit_type": circuit_type,
"session_type": "Q",
Expand Down
9 changes: 8 additions & 1 deletion src/model/overtaking.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,15 +179,22 @@ def fit_circuit(rows: pd.DataFrame, event_name: str, hand_rating: float | None)

def build_overtaking_data(driver_rounds: pd.DataFrame, circuits: pd.DataFrame | None = None,
before_season: int | None = None,
before_round: int | None = None) -> OvertakingData:
before_round: int | None = None,
event_name: str | None = None) -> OvertakingData:
"""Fit every circuit with enough history in the window.

`before_season`/`before_round` cut the window to rounds strictly *before*
that round, which is what keeps a backtest honest: the round being
forecast must not be in its own overtaking fit.

`event_name` restricts the fit to that one circuit. A forecast consumes
only its own circuit's parameter, so fitting the rest of the calendar on
every call made a backtest pay rounds x circuits fits (slice-7).
"""
ratings = hand_ratings(circuits)
df = _usable_race_rows(driver_rounds)
if event_name is not None and not df.empty:
df = df[df["event_name"].astype(str) == str(event_name)]
if before_season is not None:
cutoff = (before_season, before_round if before_round is not None else 0)
keep = [(s, r) < cutoff for s, r in zip(df["season"], df["round"])]
Expand Down
131 changes: 131 additions & 0 deletions tests/test_collect_quali_fallback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""`collect_v2` quali rows when the results carry positions but no Q1/Q2/Q3
times -- the 2025 R6 Miami gap, repaired by hand for the Phase 2 backtest
(docs/phase2-status.md, "Data"). Autopilot slice-7.

FastF1 is mocked throughout: no network, no cache.
"""

import logging

import numpy as np
import pandas as pd
import pytest

import collect_v2
from collect_v2 import LAPS_FALLBACK_SEGMENT, _build_quali_rows


def _td(seconds):
return pd.Timedelta(seconds=seconds) if seconds is not None else pd.NaT


class _StubQuali:
def __init__(self, results, laps):
self.results = results
self.laps = laps
self.weather_data = pd.DataFrame({"AirTemp": [25.0], "Rainfall": [False]})
self.event, self.name = "Miami Grand Prix", "Qualifying"

def load(self, **kwargs):
pass


def _results(q_times):
"""q_times: abbreviation -> (Q1, Q2, Q3) in seconds or None, in position order."""
rows = []
for pos, (abbr, (q1, q2, q3)) in enumerate(q_times.items(), start=1):
rows.append({"Abbreviation": abbr, "FullName": abbr, "TeamName": "T",
"Position": float(pos), "Q1": _td(q1), "Q2": _td(q2), "Q3": _td(q3)})
return pd.DataFrame(rows)


def _laps(rows):
"""rows: (driver, lap_time_s, deleted)."""
return pd.DataFrame([{"Driver": d, "LapTime": _td(t), "Deleted": deleted}
for d, t, deleted in rows])


MIAMI_LAPS = _laps([
("NOR", 86.400, False), ("NOR", 86.269, False),
("PIA", 86.271, False), ("PIA", 85.900, True), # struck off: must not count
("ANT", 86.271, False), ("ANT", None, False),
])


def _rows(monkeypatch, results, laps):
monkeypatch.setattr(collect_v2.fastf1, "get_session", lambda *a: _StubQuali(results, laps))
return {r["abbreviation"]: r for r in _build_quali_rows(2025, 6, "Miami Grand Prix", "mixed")}


class TestNoQTimesFallsBackToSessionLaps:
RESULTS = _results({"NOR": (None, None, None), "PIA": (None, None, None),
"ANT": (None, None, None)})

def test_best_lap_is_the_fastest_non_deleted_session_lap(self, monkeypatch):
rows = _rows(monkeypatch, self.RESULTS, MIAMI_LAPS)
assert rows["NOR"]["best_lap_s"] == pytest.approx(86.269)
assert rows["PIA"]["best_lap_s"] == pytest.approx(86.271)
assert rows["ANT"]["best_lap_s"] == pytest.approx(86.271)

def test_fallback_rows_are_flagged_and_classified(self, monkeypatch):
rows = _rows(monkeypatch, self.RESULTS, MIAMI_LAPS)
assert {r["q_segment"] for r in rows.values()} == {LAPS_FALLBACK_SEGMENT}
assert all(r["classified"] for r in rows.values())

def test_gaps_are_to_the_fastest_lap_derived_time(self, monkeypatch):
rows = _rows(monkeypatch, self.RESULTS, MIAMI_LAPS)
assert rows["NOR"]["gap_to_best_s"] == pytest.approx(0.0)
assert rows["PIA"]["gap_to_best_s"] == pytest.approx(0.002)

def test_positions_are_kept_from_the_results(self, monkeypatch):
rows = _rows(monkeypatch, self.RESULTS, MIAMI_LAPS)
assert [rows[d]["finish_position"] for d in ("NOR", "PIA", "ANT")] == [1.0, 2.0, 3.0]

def test_the_fallback_is_logged_with_the_drivers(self, monkeypatch, caplog):
with caplog.at_level(logging.WARNING, logger=collect_v2.log.name):
_rows(monkeypatch, self.RESULTS, MIAMI_LAPS)
assert any("fastest session lap" in m and "NOR" in m for m in caplog.messages)

def test_a_driver_with_only_a_deleted_lap_stays_untimed(self, monkeypatch):
laps = _laps([("NOR", 86.3, False), ("PIA", 85.9, True), ("ANT", 86.4, False)])
rows = _rows(monkeypatch, self.RESULTS, laps)
assert rows["PIA"]["best_lap_s"] is None
assert rows["PIA"]["q_segment"] is None and not rows["PIA"]["classified"]


class TestTimedSessionsAreUnchanged:
def test_official_times_win_and_laps_are_not_read(self, monkeypatch):
results = _results({"VER": (87.0, 86.5, 86.1), "NOR": (87.1, 86.6, 86.2)})

class NoLaps(_StubQuali):
@property
def laps(self):
raise AssertionError("laps read for a session with Q times")

@laps.setter
def laps(self, value):
pass

monkeypatch.setattr(collect_v2.fastf1, "get_session", lambda *a: NoLaps(results, None))
rows = {r["abbreviation"]: r for r in _build_quali_rows(2024, 1, "X", "mixed")}
assert rows["VER"]["best_lap_s"] == pytest.approx(86.1)
assert rows["NOR"]["q_segment"] == "Q3"
assert rows["NOR"]["gap_to_best_s"] == pytest.approx(0.1)

def test_partial_gap_fills_only_the_missing_row_and_keeps_official_pole(self, monkeypatch):
results = _results({"VER": (87.0, 86.5, 86.1), "NOR": (None, None, None)})
laps = _laps([("VER", 85.0, False), ("NOR", 86.3, False)])
rows = _rows(monkeypatch, results, laps)
assert rows["VER"]["best_lap_s"] == pytest.approx(86.1)
assert rows["VER"]["q_segment"] == "Q3"
assert rows["NOR"]["q_segment"] == LAPS_FALLBACK_SEGMENT
assert rows["NOR"]["gap_to_best_s"] == pytest.approx(0.2)

def test_an_unclassified_row_without_times_is_left_alone(self, monkeypatch):
results = _results({"VER": (87.0, 86.5, 86.1)})
results = pd.concat([results, pd.DataFrame([{
"Abbreviation": "SAR", "FullName": "SAR", "TeamName": "T", "Position": np.nan,
"Q1": pd.NaT, "Q2": pd.NaT, "Q3": pd.NaT}])], ignore_index=True)
laps = _laps([("SAR", 88.0, False)])
rows = _rows(monkeypatch, results, laps)
assert rows["SAR"]["best_lap_s"] is None and rows["SAR"]["q_segment"] is None
Loading
Loading