f1verse

Documentation Β·
MCP server Β·
llms.txt Β·
Changelog Β· Contributing Β·
Security Β· License
The story layer for Formula 1 data. Data libraries fetch and tidy β
f1verse tells you what happened: lead changes, laps led, event timelines,
stint strategy, race pace β and what happens next: title probabilities from
a season simulation that ships with its own backtest.
Zero dependencies. Standard library only. Full live-timing coverage from
2023; lap-by-lap racing back to 1996, pit stops to 2011, and results,
qualifying and standings to 1950 β each answer stating which era it came
from and what that era does not hold.
import f1verse
race = f1verse.load(2026, 12) # year, round β no other library needed
race.laps_led() # {'ANT': 32, 'NOR': 31, 'HAM': 9}
race.leader_runs() # [{'abbr': 'NOR', 'from': 1, 'to': 4}, ...]
race.results()[7] # {'abbr': 'HUL', 'gap': '+1 LAP', ...}
race.race_pace() # median pace β pit/SC/VSC laps excluded by default
race.story() # one call, whole story, plain JSON
race.championship_prediction() # per-lap "if it ended now" title projection
race.team_radio() # timestamped clip URLs (nothing downloaded)
f1verse.championship_projection(2026) # who wins the title, 20,000 seasons
f1verse.title_scenarios(2026) # and who is mathematically out
Give it to an AI agent
f1verse ships its own MCP server. No install step, no dependencies:
{"mcpServers": {"f1verse": {"command": "uvx", "args": ["--from", "f1verse", "f1verse-mcp"]}}}
That is the whole setup β the server is standard library only, so it
starts and answers tools/list in about 140 ms instead of unpacking a
scientific stack into a throwaway environment first. Eight tools, not
eighty: a model picks the right one.
For any other LLM pipeline, the library describes itself:
f1verse.tools() # MCP-dialect JSON schemas
f1verse.tools("openai") # function-calling dialect
f1verse.call_tool("f1_race_story", {"year": 2026, "round": 12})
Errors are written for the caller that has to fix them without reading
this page:
f1verse.call_tool("f1_race_summary", {})
# LookupError: unknown tool 'f1_race_summary' β available: f1_race_story, ...
f1verse.load_session(2026, 12, "Qualy")
# LookupError: ... listing the sessions that weekend actually had
The whole weekend, not just the race
f1verse.sessions(2026, 12)
# Practice 1 Β· Sprint Qualifying Β· Sprint Β· Qualifying Β· Race
q = f1verse.load_session(2026, 12, "Qualifying")
q.results()[0]
# {'abbr': 'NOR', 'q1': 72.695, 'q1_gap': 0.085, 'q3': 71.163, 'q3_gap': 0.0,
# 'best': 71.163, 'eliminated_in': None, ...}
q.segments()["q1"]
# {'fastest': 'PIA', 'advanced': [...16 codes...], 'eliminated': [...],
# 'cut_margin': 0.022}
Each kind gets the classification it actually has. Qualifying gaps are to
the fastest lap of that segment β the pole-sitter above was 0.085 s
off in Q1 β because a single "gap to leader" column would misreport the
session. A sprint loads as a Race; practice is a best-lap table.
Is this data safe to publish?
race.quality_report()
# {'state': 'final', # provisional β settled β final, or corrected
# 'coverage': {'overall': 0.9955, 'sectors': 0.9824, 'compound': 1.0},
# 'missing': ['STR.lap_46.lap_duration', ...],
# 'source_age_seconds': 312,
# 'revisions': [], # source rewrites this install has observed
# 'crosscheck': {...},
# 'publishable': True}
crosscheck answers do independent sources agree. quality_report adds
the three things that verdict is silent about: how complete the data is,
how old the copy is, and whether the classification is still provisional.
The chequered flag is not the final classification β scrutineering
disqualifications and penalties land hours later and rewrite rows in
place. So the rows the stewards can change are not cached forever until
the session is final, and any change that is seen is recorded:
before = race.snapshot() # hashed, comparable, JSON β you persist it
...
f1verse.diff(before, race.refresh().snapshot())
# {'changed': True,
# 'changes': [{'abbr': 'HAM', 'field': 'position', 'before': 4, 'after': None},
# {'abbr': 'HAM', 'field': 'gap', 'before': '+8.1s', 'after': 'DSQ'}]}
f1verse.revisions() # every source rewrite observed, with the
f1verse.vintage(rec) # superseded body when it was small enough
There is deliberately no as_of= time travel: f1verse can tell you what
it sees now and when it saw a value change, not reconstruct a value
nobody here ever fetched.
How the race actually unfolded
race = f1verse.load(2025, 24)
race.running_order()[30]
# ['PIA', 'VER', 'NOR', 'LEC', 'RUS', ...] who was where, lap by lap
race.position_changes()[1]
# {'lap': 2, 'moves': 18, 'biggest': {'abbr': 'PIA', 'gained': 17,
# 'from': 19, 'to': 2}}
race.battles()[0]
# {'ahead': 'HAD', 'behind': 'OCO', 'from': 2, 'to': 14, 'laps': 13,
# 'closest': 0.529} <- a thirteen-lap fight the results table hides
battles finds pairs that held consecutive positions within 1.5 s for at
least three laps. A scrap for eighth that ran a third of the race never
shows up in a classification; it is often the best part of the afternoon.
Races from before the live feeds
old = f1verse.load_archive(2008, 18) # Brazil, the last-corner title
old.coverage
# {'lap_times': True, 'pit_stops': False, 'stints': False,
# 'note': 'lap times only'}
old.leader_runs()
# [{'abbr': 'MAS', 'from': 1, 'to': 9}, {'abbr': 'TRU', 'from': 10, 'to': 11},
# {'abbr': 'MAS', 'from': 12, 'to': 38}, ...]
old.laps_led() # {'MAS': 64, 'TRU': 2, 'ALO': 2, 'RAI': 3}
The coverage block is not decoration. 2008 has no stint data anywhere, so
ArchiveRace has no stints() β rather than returning an empty dict that
reads like "no pit stops happened". What the era recorded, you get; what it
did not, it says.
Seasons against each other
f1verse.season_shape(2025)
# {'rounds': 24, 'final_margin': 2.0,
# 'lead_changes': [{'round': 5, 'from': 'NOR', 'to': 'PIA'},
# {'round': 20, 'from': 'PIA', 'to': 'NOR'}], ...}
f1verse.title_margins(top=5)
# 2025 NOR 423.0 vs VER 421.0 margin 2.0 (0.08 of a win)
# 2008 HAM 98.0 vs MAS 97.0 margin 1.0 (0.10 of a win)
# 2012 VET 281.0 vs ALO 278.0 margin 3.0 (0.12 of a win)
Points systems changed repeatedly, so a raw margin cannot compare eras β one
point in 1958 was most of a win. Every row also carries the gap measured in
wins, which is the comparison that survives the rule changes.
Where the passing happened
f1verse.overtake_hotspots(race)[0]
# {'from_s': 3510.0, 'to_s': 3540.0, 'signals': 69, 'drivers': [...]}
The timing feed publishes an OvertakeState per car that almost never
changes β about a hundred transitions in nineteen thousand records. That
sparsity is the value: the transitions are a free index of the moments worth
looking at, from the same feed that times the race. Read it as "something
happened here", then confirm against running_order.
Beyond a single race
f1verse.career("max_verstappen")
# {'starts': 245, 'wins': 71, 'podiums': 131, 'poles': 48, ...} 1950-present
f1verse.milestones("max_verstappen")
# [{'stat': 'poles', 'current': 48, 'target': 50, 'remaining': 2}]
f1verse.circuit_profile(2026, 13)
# corners, marshal sectors, track outline, and pit loss split by track state
# {'normal': 25.43, 'sc': 16.11, 'vsc': 18.4} <- what an undercut costs here
# plus historic record: 75 races held, pole-to-win rate 0.30
# Geometry is also interpreted, but never overclaimed: every corner carries
# its lap position, preceding-run share and local heading deflection; mini-
# and marshal-sector boundaries carry lap percentages.
profile = f1verse.circuit_profile(2026, 13)
profile["layout"]["corners"][0]
# {'number': 1, 'progress_pct': 8.412, 'local_deflection_deg': 72.6, ...}
# What a map cannot say, the cars can. This measures the circuit from the
# session's own telemetry: the height profile, where overtakes actually
# happen, how much of the lap is flat out and where it is braked, and every
# numbered corner as the car experienced it.
f1verse.circuit_survey(2026, 13)["corners"]["corners"][0]
# {'corner': 1, 'apex_speed_kph': 103, 'radius_m': 40.0,
# 'lateral_load_g': 2.09, 'gear_at_apex': 2, 'severity': 'medium'}
# Published specifications are stored rather than derived, and the
# measurement audits them rather than replacing them.
f1verse.circuit_profile(2026, 13, measure=True)["audit"]
# {'verdict': 'agrees', 'checked_age_days': 0,
# 'checks': [{'field': 'length_m', 'published': 4259, 'measured': 4274.4,
# 'off_by_percent': 0.36, 'state': 'agrees'}, ...]}