{
  "markdown": "# f1verse\n\n[![PyPI](https://img.shields.io/pypi/v/f1verse.svg)](https://pypi.org/project/f1verse/)\n[![Python](https://img.shields.io/pypi/pyversions/f1verse.svg)](https://pypi.org/project/f1verse/)\n[![Tests](https://github.com/jinsim/f1verse/actions/workflows/test.yml/badge.svg)](https://github.com/jinsim/f1verse/actions/workflows/test.yml)\n[![Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)](https://github.com/jinsim/f1verse/blob/main/pyproject.toml)\n[![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)\n\n**[Documentation](https://jinsim.github.io/f1verse/)** ·\n[MCP server](https://jinsim.github.io/f1verse/f1-mcp-server/) ·\n[llms.txt](https://jinsim.github.io/f1verse/llms.txt) ·\n[Changelog](CHANGELOG.md) · [Contributing](CONTRIBUTING.md) ·\n[Security](SECURITY.md) · [License](LICENSE)\n\n**The story layer for Formula 1 data.** Data libraries fetch and tidy —\nf1verse tells you *what happened*: lead changes, laps led, event timelines,\nstint strategy, race pace — and *what happens next*: title probabilities from\na season simulation that ships with its own backtest.\n\n**Zero dependencies.** Standard library only. Full live-timing coverage from\n2023; lap-by-lap racing back to 1996, pit stops to 2011, and results,\nqualifying and standings to 1950 — each answer stating which era it came\nfrom and what that era does not hold.\n\n```bash\npip install f1verse\n```\n\n```python\nimport f1verse\n\nrace = f1verse.load(2026, 12)        # year, round — no other library needed\n\nrace.laps_led()      # {'ANT': 32, 'NOR': 31, 'HAM': 9}\nrace.leader_runs()   # [{'abbr': 'NOR', 'from': 1, 'to': 4}, ...]\nrace.results()[7]    # {'abbr': 'HUL', 'gap': '+1 LAP', ...}\nrace.race_pace()     # median pace — pit/SC/VSC laps excluded by default\nrace.story()         # one call, whole story, plain JSON\n\nrace.championship_prediction()   # per-lap \"if it ended now\" title projection\nrace.team_radio()                # timestamped clip URLs (nothing downloaded)\n\nf1verse.championship_projection(2026)   # who wins the title, 20,000 seasons\nf1verse.title_scenarios(2026)           # and who is mathematically out\n```\n\n### Give it to an AI agent\n\n<!-- mcp-name: io.github.jinsim/f1verse -->\n\nf1verse ships its own MCP server. No install step, no dependencies:\n\n```json\n{\"mcpServers\": {\"f1verse\": {\"command\": \"uvx\", \"args\": [\"--from\", \"f1verse\", \"f1verse-mcp\"]}}}\n```\n\nThat is the whole setup — the server is standard library only, so it\nstarts and answers `tools/list` in about 140 ms instead of unpacking a\nscientific stack into a throwaway environment first. Eight tools, not\neighty: a model picks the right one.\n\nFor any other LLM pipeline, the library describes itself:\n\n```python\nf1verse.tools()                  # MCP-dialect JSON schemas\nf1verse.tools(\"openai\")          # function-calling dialect\nf1verse.call_tool(\"f1_race_story\", {\"year\": 2026, \"round\": 12})\n```\n\nErrors are written for the caller that has to fix them without reading\nthis page:\n\n```python\nf1verse.call_tool(\"f1_race_summary\", {})\n# LookupError: unknown tool 'f1_race_summary' — available: f1_race_story, ...\nf1verse.load_session(2026, 12, \"Qualy\")\n# LookupError: ... listing the sessions that weekend actually had\n```\n\n### The whole weekend, not just the race\n\n```python\nf1verse.sessions(2026, 12)\n# Practice 1 · Sprint Qualifying · Sprint · Qualifying · Race\n\nq = f1verse.load_session(2026, 12, \"Qualifying\")\nq.results()[0]\n# {'abbr': 'NOR', 'q1': 72.695, 'q1_gap': 0.085, 'q3': 71.163, 'q3_gap': 0.0,\n#  'best': 71.163, 'eliminated_in': None, ...}\n\nq.segments()[\"q1\"]\n# {'fastest': 'PIA', 'advanced': [...16 codes...], 'eliminated': [...],\n#  'cut_margin': 0.022}\n```\n\nEach kind gets the classification it actually has. Qualifying gaps are to\nthe fastest lap **of that segment** — the pole-sitter above was 0.085 s\noff in Q1 — because a single \"gap to leader\" column would misreport the\nsession. A sprint loads as a `Race`; practice is a best-lap table.\n\n### Is this data safe to publish?\n\n```python\nrace.quality_report()\n# {'state': 'final',            # provisional → settled → final, or corrected\n#  'coverage': {'overall': 0.9955, 'sectors': 0.9824, 'compound': 1.0},\n#  'missing': ['STR.lap_46.lap_duration', ...],\n#  'source_age_seconds': 312,\n#  'revisions': [],             # source rewrites this install has observed\n#  'crosscheck': {...},\n#  'publishable': True}\n```\n\n`crosscheck` answers *do independent sources agree*. `quality_report` adds\nthe three things that verdict is silent about: how complete the data is,\nhow old the copy is, and whether the classification is still provisional.\n\nThe chequered flag is not the final classification — scrutineering\ndisqualifications and penalties land hours later and **rewrite rows in\nplace**. So the rows the stewards can change are not cached forever until\nthe session is final, and any change that is seen is recorded:\n\n```python\nbefore = race.snapshot()          # hashed, comparable, JSON — you persist it\n...\nf1verse.diff(before, race.refresh().snapshot())\n# {'changed': True,\n#  'changes': [{'abbr': 'HAM', 'field': 'position', 'before': 4, 'after': None},\n#              {'abbr': 'HAM', 'field': 'gap', 'before': '+8.1s', 'after': 'DSQ'}]}\n\nf1verse.revisions()               # every source rewrite observed, with the\nf1verse.vintage(rec)              # superseded body when it was small enough\n```\n\nThere is deliberately no `as_of=` time travel: f1verse can tell you what\nit sees now and when it saw a value change, not reconstruct a value\nnobody here ever fetched.\n\n### How the race actually unfolded\n\n```python\nrace = f1verse.load(2025, 24)\n\nrace.running_order()[30]\n# ['PIA', 'VER', 'NOR', 'LEC', 'RUS', ...]   who was where, lap by lap\n\nrace.position_changes()[1]\n# {'lap': 2, 'moves': 18, 'biggest': {'abbr': 'PIA', 'gained': 17,\n#                                     'from': 19, 'to': 2}}\n\nrace.battles()[0]\n# {'ahead': 'HAD', 'behind': 'OCO', 'from': 2, 'to': 14, 'laps': 13,\n#  'closest': 0.529}      <- a thirteen-lap fight the results table hides\n```\n\n`battles` finds pairs that held consecutive positions within 1.5 s for at\nleast three laps. A scrap for eighth that ran a third of the race never\nshows up in a classification; it is often the best part of the afternoon.\n\n### Races from before the live feeds\n\n```python\nold = f1verse.load_archive(2008, 18)     # Brazil, the last-corner title\n\nold.coverage\n# {'lap_times': True, 'pit_stops': False, 'stints': False,\n#  'note': 'lap times only'}\n\nold.leader_runs()\n# [{'abbr': 'MAS', 'from': 1, 'to': 9}, {'abbr': 'TRU', 'from': 10, 'to': 11},\n#  {'abbr': 'MAS', 'from': 12, 'to': 38}, ...]\n\nold.laps_led()          # {'MAS': 64, 'TRU': 2, 'ALO': 2, 'RAI': 3}\n```\n\nThe `coverage` block is not decoration. 2008 has no stint data anywhere, so\n`ArchiveRace` has no `stints()` — rather than returning an empty dict that\nreads like \"no pit stops happened\". What the era recorded, you get; what it\ndid not, it says.\n\n### Seasons against each other\n\n```python\nf1verse.season_shape(2025)\n# {'rounds': 24, 'final_margin': 2.0,\n#  'lead_changes': [{'round': 5, 'from': 'NOR', 'to': 'PIA'},\n#                   {'round': 20, 'from': 'PIA', 'to': 'NOR'}], ...}\n\nf1verse.title_margins(top=5)\n# 2025  NOR 423.0 vs VER 421.0   margin 2.0   (0.08 of a win)\n# 2008  HAM  98.0 vs MAS  97.0   margin 1.0   (0.10 of a win)\n# 2012  VET 281.0 vs ALO 278.0   margin 3.0   (0.12 of a win)\n```\n\nPoints systems changed repeatedly, so a raw margin cannot compare eras — one\npoint in 1958 was most of a win. Every row also carries the gap measured in\nwins, which is the comparison that survives the rule changes.\n\n### Where the passing happened\n\n```python\nf1verse.overtake_hotspots(race)[0]\n# {'from_s': 3510.0, 'to_s': 3540.0, 'signals': 69, 'drivers': [...]}\n```\n\nThe timing feed publishes an `OvertakeState` per car that almost never\nchanges — about a hundred transitions in nineteen thousand records. That\nsparsity is the value: the transitions are a free index of the moments worth\nlooking at, from the same feed that times the race. Read it as \"something\nhappened here\", then confirm against `running_order`.\n\n### Beyond a single race\n\n```python\nf1verse.career(\"max_verstappen\")\n# {'starts': 245, 'wins': 71, 'podiums': 131, 'poles': 48, ...}  1950-present\n\nf1verse.milestones(\"max_verstappen\")\n# [{'stat': 'poles', 'current': 48, 'target': 50, 'remaining': 2}]\n\nf1verse.circuit_profile(2026, 13)\n# corners, marshal sectors, track outline, and pit loss split by track state\n# {'normal': 25.43, 'sc': 16.11, 'vsc': 18.4}  <- what an undercut costs here\n# plus historic record: 75 races held, pole-to-win rate 0.30\n\n# Geometry is also interpreted, but never overclaimed: every corner carries\n# its lap position, preceding-run share and local heading deflection; mini-\n# and marshal-sector boundaries carry lap percentages.\nprofile = f1verse.circuit_profile(2026, 13)\nprofile[\"layout\"][\"corners\"][0]\n# {'number': 1, 'progress_pct': 8.412, 'local_deflection_deg': 72.6, ...}\n\n# What a map cannot say, the cars can. This measures the circuit from the\n# session's own telemetry: the height profile, where overtakes actually\n# happen, how much of the lap is flat out and where it is braked, and every\n# numbered corner as the car experienced it.\nf1verse.circuit_survey(2026, 13)[\"corners\"][\"corners\"][0]\n# {'corner': 1, 'apex_speed_kph': 103, 'radius_m': 40.0,\n#  'lateral_load_g': 2.09, 'gear_at_apex': 2, 'severity': 'medium'}\n\n# Published specifications are stored rather than derived, and the\n# measurement audits them rather than replacing them.\nf1verse.circuit_profile(2026, 13, measure=True)[\"audit\"]\n# {'verdict': 'agrees', 'checked_age_days': 0,\n#  'checks': [{'field': 'length_m', 'published': 4259, 'measured': 4274.4,\n#              'off_by_percent': 0.36, 'state': 'agrees'}, ...]}\n\n# The published facts and the audit are reachable on their own, so a caller\n# can ask what is claimed without paying for a telemetry survey.\nf1verse.circuit_facts(\"Austin\")\n# {'length_m': 5513, 'race_laps': 56, 'race_distance_km': 308.405,\n#  'provenance': {'length_m': 'curated', ...},\n#  'source': 'formula1.com official circuit page, 2026 united-states',\n#  'checked': '2026-09-01'}\n# None for a circuit nobody has curated yet - it does not invent a length\n\nf1verse.circuit_audit(2026, 13)\n# the same verdict as profile(measure=True)[\"audit\"], on its own\n\nf1verse.circuit_directory()\n# every venue recorded in F1 results, with stable id, city, country and\n# coordinates; unlike a current map, it does not pretend an old layout has\n# today's geometry\n\nf1verse.circuit_history(\"monza\")\n# every F1 event at the venue, recent winners and starting positions,\n# pole-to-win conversion, and the drivers with the most wins\n\nf1verse.head_to_head(2026)\n# teammate quali/race scores per constructor\n\nf1verse.standings(2026)\n```\n\n### Tyre life, and what the stewards struck out\n\n```python\nf1verse.stint_degradation(race)[3]\n# {'driver': 'NOR', 'stint': 2, 'compound': 'HARD', 'tyre_age_at_start': 0,\n#  'clean_laps_used': 18, 'degradation_s_per_lap': 0.041}\n\nf1verse.circuit_abrasion(race)\n# {'factor': 1.4, 'verdict': 'abrasive', 'samples': 55}\n\nf1verse.tyre_outlook(race)          # projected to the cliff\n```\n\nA car gets faster all race as it burns fuel, so raw lap times understate\ndegradation on every stint. Rates are fitted to **fuel-normalised clean\nlaps only**, and a stint with too few of them returns\n`{'degradation_s_per_lap': None, 'reason': 'too few clean laps'}` rather\nthan a number fitted to noise.\n\n```python\nf1verse.lap_deletions(session.race_control)\n# [{'car_number': 55, 'lap_time': '1:25.773', 'stands': True,\n#   'reason': 'TRACK LIMITS AT TURN 3 LAP 3', ...}]\n```\n\nA reinstated lap keeps the reversal visible instead of being quietly\ndropped. Check this before treating a fastest lap or a qualifying\nposition as settled.\n\n### Live timing, straight off the wire\n\n```python\nfrom f1verse.sources import liveclient\n\nwith liveclient.LiveFeed() as feed:\n    for topic, patch, stamp in feed.messages():\n        ...\n\nliveclient.run(\"session-{n}.jsonl\")   # record, rotate on turnover, reconnect\n```\n\nThe official SignalR feed over a WebSocket written in the standard\nlibrary — no websocket package, no SignalR client. Connecting takes three\nundocumented courtesies (an affinity cookie only handed to a pre-flight\nrequest, the official application's user agent, an invocation id on the\nsubscription); they live in one place so no caller rediscovers them.\n\nRecorded frames carry a millisecond arrival stamp — the difference\nbetween an archive and a screenshot — so `replay()` runs a session back at\ntrue speed. The stream is not a lap table, so\n`f1verse.sources.timing.laps_from_stream` rebuilds one: arrival order\nlies, sector times land after the next lap has begun, and qualifying\ncarries phantom lap times that are really the gap between two runs. Every\nrebuilt lap row carries its provenance.\n\n### Running this on a schedule\n\n```python\nf1verse.status(2026)\n# {'latest_race': {'round': 12, 'meeting': 'Dutch Grand Prix'},\n#  'next': {'round': 13, 'session': 'Practice 1'}, 'next_in_hours': 125.7}\n\nf1verse.due(2026, processed=[...session keys you already handled...])\n# sessions finished, settled (45 min past the flag) and not yet processed —\n# nothing published twice, nothing missed after downtime\n```\n\nCaching is policy-driven, not blanket: lap and telemetry data is immutable\nand cached forever, schedules expire every few hours — a calendar cached\nfor a season would hide a cancelled round for the rest of the year — and\nrows the stewards can still rewrite expire until the session is final.\n`f1verse.cache_info()` and `f1verse.clear_cache(older_than=...)` are there\nfor operators; `clear_cache` never drops the revision journal.\n\n### Telemetry, track position, conditions\n\n```python\nf1verse.lap_telemetry(race, \"NOR\", 40)\n# per-sample speed, throttle, brake, gear, RPM and DRS state for one lap\n\nf1verse.lap_trace(race, \"NOR\", 40)      # x/y/z coordinates of that lap\nf1verse.top_speeds(race)                # fastest reading per driver\n\nf1verse.weather_summary(race)\n# {'track_c': {'min': 25.8, 'max': 38.2}, 'rain': True, 'samples': 191}\n```\n\nTelemetry is high-frequency, so these take a bounded window and filter\nserver-side rather than downloading a session and trimming it locally.\n\n### Grounded narration\n\n```python\nfacts = f1verse.race_facts(race)       # all numbers computed and formatted here\nf1verse.brief(race)                    # deterministic text, no model required\n\nresult = f1verse.narrate(\n    race,\n    generate=lambda prompt: my_model(prompt),\n    cache_dir=\".cache/narration\",\n)\n# {'text': '...', 'source': 'generated' | 'cache' | 'template', ...}\n```\n\n`narrate` accepts any text-generation callback; f1verse has no model SDK\ndependency. Drafts are checked against the structured fact sheet. Unknown\nnumbers and driver codes are rejected, generation is retried at most twice,\nand a deterministic summary is returned if verification still fails. The\noptional cache is exact-match only and stores verified text.\n\n### Who wins the championship — and who still can\n\nTwo different questions, and mixing them is how a projection ends up\nimplying somebody is out when the arithmetic says otherwise.\n\n```python\nf1verse.title_scenarios(2026)\n# arithmetic, not a forecast — maximum points left is a fixed number\n# {'rounds_left': 11, 'sprints_left': 1, 'max_points_available': 283,\n#  'drivers': [{'driver': 'RUS', 'gap_to_leader': 59.0,\n#               'still_possible': True, 'needs_avg_per_round': 5.4}, ...],\n#  'still_alive': 23}\n\nf1verse.championship_projection(2026)\n# the rest of the season, played out 20,000 times\n# {'drivers': [{'driver': 'ANT', 'title_probability': 0.954,\n#               'points_now': 242.0, 'projected_points_median': 446,\n#               'projected_points_p10': 390, 'projected_points_p90': 490,\n#               'races_in_sample': 11, 'measured_dnf_rate': 0.08}, ...],\n#  'assumptions': {'ignores': ['car development', 'weather', ...], ...}}\n```\n\nA simulated finish is **resampled from the positions that driver has\nactually finished in**, not drawn from a curve around their average. The\ndistinction decides championships: alternating wins and retirements is a\ndifferent proposition from finishing fourth every weekend, and the mean\nis the same for both. Retirements fire at each driver's measured rate,\nsprints score on their own table, and ties break on wins then seconds,\nas the regulations do.\n\nEvery run also re-draws the driver's own level first, bootstrapping their\nresults before playing the season against that version of them — twelve\nraces is a small sample, and treating it as settled truth is how a\nforecast becomes more confident than anyone should be.\n\n```python\nf1verse.backtest_projection()      # does the model deserve to be believed?\n# {'by_confidence': {'over_90': {'n': 5, 'correct': 5, 'rate': 1.0},\n#                    'under_60': {'n': 2, 'correct': 0, 'rate': 0.0}}, ...}\n```\n\nReplayed at round 12 of 2019-2025 it went 5/5 when it claimed 90%+; both\nmisses were seasons it had itself called at 53% and 57% — the two that ran\nto the final round. Read the buckets, not the headline: a model that says\n55% and is wrong has done nothing wrong.\n\n> `championship_prediction` (in `feeds`) is a different thing — F1's own\n> live \"if it ended now\" table. `championship_projection` is this model.\n> One reports, the other forecasts.\n\n### Predictions, pit-stop verdicts, official documents\n\n```python\nf1verse.win_probabilities({\"NOR\": 1, \"ANT\": 2, \"RUS\": 3},\n                          year=2026, upto_round=12, circuit_id=\"monza\")\n# every probability ships with its own evidence:\n#   grid base rate measured over 233 real races (pole wins 54.1%)\n#   blended with that circuit's pole-to-win conversion (Monza: 0.30)\n#   scaled by recent form (average finish over the last 5 rounds)\n\nf1verse.pit_exchanges(race, pit_loss_s=22.74)\n# [{'lap': 17, 'driver': 'RUS', 'rival': 'PIA', 'verdict': 'worked',\n#   'gain_s': 3.54}, ...]\n# neutralised laps (red flag / SC / VSC) and same-lap covering stops are\n# excluded — calling those undercuts would be wrong\n\nf1verse.fia_documents(2026)          # stewards' decisions, classified\nf1verse.power_unit_documents(2026)   # \"who changed which engine part\"\n```\n\n## Why this exists\n\nRaw timing data needs a lot of domain knowledge before it means anything:\n\n- **Classified gaps are not comparable across lapped cars.** A car one lap\n  down can show a smaller number than one that finished ahead on the lead\n  lap. → `format_gap` applies the broadcast convention (`+1 LAP`).\n- **\"Who led the race\" has to be derived.** Lead changes, laps led and the\n  moments they happened are not published as such.\n  → `leader_runs`, `laps_led`, `timeline`.\n- **Race pace needs rules**, not just a threshold: in/out laps and laps run\n  under SC/VSC have to go, or the number is meaningless.\n  → `race_pace` applies them by default.\n- **Web and video pipelines need plain JSON.** Every f1verse output is\n  JSON-safe Python, ready to serialise.\n- **Data quality should be checkable in code**, not read from logs.\n  → `crosscheck` and `quality_report` return structured verdicts.\n- **Results change after the flag.** A cache that treats a classification\n  as immutable makes a stewards' decision invisible.\n  → revisable rows expire until the session is final; changes are recorded.\n\n## Additional live-timing feeds\n\nThe official live-timing archive publishes several feeds that are rarely\nsurfaced. f1verse parses three of them, with the same caching and\nrate-limit etiquette as the rest of the library:\n\n```python\nf1verse.championship_prediction(session)\n# per-lap \"if the race ended now\" projection of both championships,\n# including the moments the projected champion changed\n\nf1verse.team_radio(session)\n# timestamped team-radio clips: [{'t', 'utc', 'driver_number', 'url'}]\n# URLs only — nothing is downloaded or redistributed\n\nf1verse.timing_stats(session)\n# personal bests, best sectors, speed-trap figures\n```\n\n## Running continuously\n\nSee **[OPERATIONS.md](OPERATIONS.md)** for caching policy, rate limits and\nscheduling.\n\n## Tests\n\n```bash\npip install -e \".[test]\" && pytest -q\n```\n\n## Sources\n\n| Layer | What it gives |\n|---|---|\n| Race data | laps, stints, pits, positions, results, overtakes |\n| Live-timing archive | championship projection, team radio, timing stats |\n| Historic records | careers, circuit records, standings — 1950 onward |\n| Circuit geometry | track outline, corners, marshal sectors, pit loss |\n\nAll are public endpoints, read at runtime. See `src/f1verse/sources/` for\nthe exact hosts, and `NOTICE` for how upstream data rights relate to this\nproject's own license.\n\n## Design rules\n\n1. **Zero required dependencies.** The native loader reads public REST\n   endpoints and the official live-timing archive directly, with its own\n   on-disk cache and polite pacing.\n2. **Everything returned is plain JSON-safe Python.**\n3. **F1 domain rules are defaults, not options.**\n4. **Cross-checked where possible** — lapped-car gaps, for instance, are\n   computed by convention *and* confirmed against a second source.\n5. **Code only.** No timing data, media, or images are bundled or\n   redistributed; data is fetched by the end user.\n\n## License\n\nApache-2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE).\n\nUse it privately or commercially; read it, change it, redistribute it, and\nbuild products or hosted services on it. Preserve the license and notices,\nstate significant changes, and observe the license's patent terms. There is no\nsource-disclosure requirement for software or services that use f1verse.\n\nThis license covers f1verse's own source. The Formula 1 data it reads at\nruntime belongs to its respective rights holders under their own terms.\n\n## Roadmap\n\n- Broader cross-validation coverage\n- Deviation detection: expected range, actual, evidence\n- Localisation packages\n\n\n---\n\n*Unofficial fan project. Not affiliated with, endorsed by, or associated\nwith Formula 1, FIA, FOM, or any F1 team. F1, FORMULA 1 and related marks\nare trademarks of Formula One Licensing BV. This library contains code\nonly — no timing data, media, or images are included or redistributed;\ndata is fetched by the end user from publicly accessible endpoints,\nsubject to the respective providers' terms.*\n",
  "bytes": 22309,
  "sha": "af39cf9752f0fe1ea2731f62300f1b30ad700c27a521aebb01979d52f00a98fd",
  "repo_slug": "jinsim/f1verse",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_jinsim_f1verse_a96c9c53/readme"
}