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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,25 @@

All notable changes to F1 Timing Replay will be documented in this file.

## 1.2.2

### Improvements
- **Mobile race control** added collapsible race control messages section to mobile view for both live and replay
- **<1sec interval highlight** intervals under 1 second are highlighted in green during race sessions (toggleable in settings)
- **Live session styling** improved pulse animation on live indicators and cleaner live session button layout (contributed by [@Clav3rbot](https://github.com/Clav3rbot))
- **Broadcast delay** added manual input field for exact delay value
- **Minor UI/UX improvements** main page layout changed to expandable list, fixed minor UI bugs on navigation


### Fixes
- **Memory management** replay session data is now evicted from memory 5 minutes after the last client disconnects
- **Live race control messages** fixed race control messages not updating during live sessions when broadcast delay is set
- **Phantom tyre compounds** fixed incorrect tyre history in live sessions caused by interim/placeholder compound updates from the F1 feed
- **Live qualifying sectors** fixed sector indicators not clearing after lap completion (now clears after 5 seconds) and fixed multiple drivers showing purple in the same sector by computing colours from actual times
- **Session times** corrected session times to display local date with time

---

## 1.2.1 - 2026-03-14

### Fixes
Expand Down
69 changes: 66 additions & 3 deletions backend/routers/replay.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,40 @@
import asyncio
import logging
import math
import os
import re

from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
from services.storage import get_json
from services.process import ensure_session_data_ws

def _log_memory():
"""Log current process memory usage."""
try:
# Works on Linux (Docker) — reads from /proc
with open(f"/proc/{os.getpid()}/status") as f:
for line in f:
if line.startswith("VmRSS:"):
mem_mb = int(line.split()[1]) / 1024
break
else:
mem_mb = 0
except FileNotFoundError:
# macOS fallback
import resource
mem_mb = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / (1024 * 1024)
cache_sessions = len(_replay_cache)
return f"process: {mem_mb:.0f}MB, cached sessions: {cache_sessions}"

logger = logging.getLogger(__name__)
router = APIRouter(tags=["replay"])

# In-memory cache for replay frames loaded from R2
_replay_cache: dict[str, list[dict]] = {}
_replay_clients: dict[str, int] = {} # key -> active WebSocket count
_eviction_tasks: dict[str, asyncio.Task] = {} # key -> pending eviction task

CACHE_EVICTION_SECONDS = 300 # 5 minutes after last client disconnects

# In-memory cache for pit loss data
_pit_loss_cache: dict | None = None
Expand Down Expand Up @@ -153,13 +176,47 @@ def _get_frames_sync(year: int, round_num: int, session_type: str) -> list[dict]
for f in frames:
_sanitize_frame(f)
_replay_cache[key] = frames
logger.info(f"[memory] Cached {key} ({len(frames)} frames) — {_log_memory()}")
return _replay_cache[key]


async def _get_frames(year: int, round_num: int, session_type: str) -> list[dict]:
return await asyncio.to_thread(_get_frames_sync, year, round_num, session_type)


def _client_connect(key: str):
"""Register a WebSocket client for a cached session."""
_replay_clients[key] = _replay_clients.get(key, 0) + 1
# Cancel any pending eviction since a client is now connected
task = _eviction_tasks.pop(key, None)
if task:
task.cancel()
logger.info(f"[memory] Cancelled eviction for {key} — new client connected")


async def _client_disconnect(key: str):
"""Unregister a WebSocket client. Schedule eviction if no clients remain."""
_replay_clients[key] = max(0, _replay_clients.get(key, 0) - 1)
if _replay_clients[key] == 0:
_replay_clients.pop(key, None)
if key in _replay_cache:
logger.info(f"[memory] No clients for {key}, scheduling eviction in {CACHE_EVICTION_SECONDS}s — {_log_memory()}")
task = asyncio.create_task(_evict_after_delay(key))
_eviction_tasks[key] = task


async def _evict_after_delay(key: str):
"""Wait, then evict a cached session if no new clients have connected."""
try:
await asyncio.sleep(CACHE_EVICTION_SECONDS)
if _replay_clients.get(key, 0) == 0 and key in _replay_cache:
del _replay_cache[key]
_eviction_tasks.pop(key, None)
logger.info(f"[memory] Evicted {key} — {_log_memory()}")
except asyncio.CancelledError:
pass


@router.websocket("/ws/replay/{year}/{round_num}")
async def replay_websocket(
websocket: WebSocket,
Expand Down Expand Up @@ -192,12 +249,14 @@ async def send_status(msg: str):
return

# Clear cache entry in case we just processed new data
cache_key = f"{year}_{round_num}_{type}"
_replay_cache.pop(cache_key, None)
_replay_cache.pop(f"{year}_{round_num}_{type}", None)

frames = await _get_frames(year, round_num, type)
cache_key = f"{year}_{round_num}_{type}"
_client_connect(cache_key)

if not frames:
await _client_disconnect(cache_key)
await websocket.send_json({"type": "error", "message": "No position data available"})
await websocket.close()
return
Expand Down Expand Up @@ -330,8 +389,12 @@ async def check_command(timeout: float) -> bool:
await check_command(1.0)

except WebSocketDisconnect:
logger.info(f"WebSocket disconnected: {year}/{round_num}")
cache_key = f"{year}_{round_num}_{type}"
await _client_disconnect(cache_key)
logger.info(f"[memory] WebSocket disconnected: {year}/{round_num}/{type} — {_log_memory()}")
except Exception as e:
cache_key = f"{year}_{round_num}_{type}"
await _client_disconnect(cache_key)
logger.error(f"WebSocket error: {e}")
try:
await websocket.close()
Expand Down
2 changes: 1 addition & 1 deletion backend/services/f1_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ def _fetch_schedule_sync(year: int) -> list[dict]:
date_utc = row.get(f"Session{i}DateUtc")
sessions_raw.append({
"name": name,
"date_utc": str(date_utc) if pd.notna(date_utc) else None,
"date_utc": date_utc.isoformat() + "Z" if pd.notna(date_utc) else None,
"_ts": date_utc.to_pydatetime().replace(tzinfo=timezone.utc) if pd.notna(date_utc) else None,
})

Expand Down
114 changes: 81 additions & 33 deletions backend/services/live_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ class _DriverState:
"_sector_best_personal",
"_sector_best_overall",
"_stint_count",
"_last_stint_idx",
"_s3_complete_time",
"_sector_times",
)

def __init__(self, racing_number: str) -> None:
Expand All @@ -115,6 +118,7 @@ def __init__(self, racing_number: str) -> None:
self.compound: str | None = None
self.tyre_life: int | None = None
self.tyre_history: list[str] = []
self._last_stint_idx: int = -1
self.pit_stops: int = 0
self.in_pit: bool = False
self.has_fastest_lap: bool = False
Expand All @@ -135,6 +139,8 @@ def __init__(self, racing_number: str) -> None:
# Internal tracking for sector colours
self._sector_best_personal: dict[int, float] = {} # sector_num -> best time
self._sector_best_overall: dict[int, bool] = {} # sector_num -> ever overall fastest
self._s3_complete_time: float | None = None # timestamp when S3 completed (for 5s linger)
self._sector_times: dict[int, float] = {} # sector_num -> time in seconds (for colour recomputation)
self._stint_count: int = 0

def to_dict(self) -> dict[str, Any]:
Expand Down Expand Up @@ -389,8 +395,8 @@ def _process_sectors(self, drv: _DriverState, sectors: dict) -> None:

Only updates when a sector has an actual Value (completed time).
When sector N completes, clears all sectors after N (new flying
lap progress). Ignores updates that only carry Segments
(mini-sector progress) without a finished sector time.
lap progress). Computes colours from actual times rather than
trusting API flags, ensuring only one driver can be purple per sector.
"""
# Build lookup of existing sectors
existing: dict[int, dict[str, Any]] = {}
Expand All @@ -413,48 +419,78 @@ def _process_sectors(self, drv: _DriverState, sectors: dict) -> None:
if not val_str:
continue

# Only update colour when the API explicitly includes the
# fastest flags. Re-sends of the same Value without flags
# should not reset the colour to yellow.
has_flags = "OverallFastest" in sector_data or "PersonalFastest" in sector_data
overall_fastest = bool(sector_data.get("OverallFastest", False))
personal_fastest = bool(sector_data.get("PersonalFastest", False))

# Track personal/overall bests
# Parse sector time
try:
sec_time = float(val_str)
current_pb = drv._sector_best_personal.get(sector_idx)
if current_pb is None or sec_time < current_pb:
drv._sector_best_personal[sector_idx] = sec_time
current_ob = self._overall_sector_bests.get(sector_idx)
if current_ob is None or sec_time < current_ob:
self._overall_sector_bests[sector_idx] = sec_time
except ValueError:
pass
continue

# Determine colour
if overall_fastest:
# Store the sector time for later recomputation
drv._sector_times[sector_idx] = sec_time

# Track personal bests
current_pb = drv._sector_best_personal.get(sector_idx)
is_personal_best = current_pb is None or sec_time <= current_pb + 0.0005
if current_pb is None or sec_time < current_pb:
drv._sector_best_personal[sector_idx] = sec_time

# Track overall bests — if this is a new overall best, update
# all other drivers' sectors to remove stale purples
current_ob = self._overall_sector_bests.get(sector_idx)
is_overall_best = current_ob is None or sec_time <= current_ob + 0.0005
if current_ob is None or sec_time < current_ob:
self._overall_sector_bests[sector_idx] = sec_time
# Downgrade other drivers' purple in this sector
self._recompute_sector_colours(sector_idx, drv.racing_number)

# Determine colour from actual times
if is_overall_best:
color = "purple"
elif personal_fastest:
elif is_personal_best:
color = "green"
else:
color = "yellow"

# Only update the colour if the API sent explicit fastest flags.
# If no flags were present, keep the existing colour (if any).
if has_flags or sector_num not in existing:
existing[sector_num] = {"num": sector_num, "color": color}
else:
# Value present but no flags - keep existing entry as-is
pass
existing[sector_num] = {"num": sector_num, "color": color}

# Clear any sectors after this one (new lap progress)
for later in list(existing):
if later > sector_num:
del existing[later]
drv._sector_times.pop(later - 1, None)

# Track S3 completion for 5-second linger
if sector_num == 3:
drv._s3_complete_time = time.monotonic()
elif sector_num == 1:
# Starting new lap — reset S3 timer
drv._s3_complete_time = None

drv.sectors = [existing[k] for k in sorted(existing)] if existing else None

def _recompute_sector_colours(self, sector_idx: int, exclude_rn: str) -> None:
"""When a new overall best is set in a sector, downgrade other drivers'
purple indicators for that sector to green or yellow."""
sector_num = sector_idx + 1
new_best = self._overall_sector_bests.get(sector_idx)
if new_best is None:
return

for drv in self._drivers.values():
if drv.racing_number == exclude_rn:
continue
if not drv.sectors:
continue
for s in drv.sectors:
if s["num"] == sector_num and s["color"] == "purple":
# Check if this driver's time is still a personal best
drv_time = drv._sector_times.get(sector_idx)
drv_pb = drv._sector_best_personal.get(sector_idx)
if drv_time is not None and drv_pb is not None and drv_time <= drv_pb + 0.0005:
s["color"] = "green"
else:
s["color"] = "yellow"

# --- Position -----------------------------------------------------

def _handle_position(self, data: dict, _ts: float) -> None:
Expand Down Expand Up @@ -610,12 +646,16 @@ def _process_stints(self, drv: _DriverState, stints: dict) -> None:
# Update compound
if "Compound" in latest_stint:
new_compound = latest_stint["Compound"].upper()
old_compound = drv.compound
drv.compound = new_compound
# Track stint changes for tyre_history
if old_compound and new_compound != old_compound:
if not drv.tyre_history or drv.tyre_history[-1] != old_compound:
drv.tyre_history.append(old_compound)
# Ignore interim/placeholder compounds
if new_compound == "UNKNOWN":
pass
else:
# Only add to tyre history when the stint index increases (actual pit stop)
if max_idx > drv._last_stint_idx and drv._last_stint_idx >= 0 and drv.compound:
if not drv.tyre_history or drv.tyre_history[-1] != drv.compound:
drv.tyre_history.append(drv.compound)
drv.compound = new_compound
drv._last_stint_idx = max_idx

# Update tyre life
if "TotalLaps" in latest_stint:
Expand Down Expand Up @@ -821,12 +861,20 @@ def get_frame(self) -> dict:

This is intended to be called at ~2 Hz by the broadcaster.
"""
SECTOR_LINGER = 5.0
now = time.monotonic()

drivers_list: list[dict[str, Any]] = []
for drv in self._drivers.values():
# Skip phantom drivers with no identity (created by Position.z
# before DriverList arrives)
if not drv.abbr:
continue
# Clear sectors 5 seconds after S3 completes
if drv._s3_complete_time and (now - drv._s3_complete_time) > SECTOR_LINGER:
drv.sectors = None
drv._s3_complete_time = None
drv._sector_times.clear()
d = drv.to_dict()
# Sanitize all values
for key in list(d.keys()):
Expand Down
Loading
Loading