Skip to content

Commit 24e3eb0

Browse files
authored
Merge pull request #41 from adn8naiagent/dev
1.2.2: Memory management, live timing fixes, UI improvements
2 parents 287ae7c + 59d970f commit 24e3eb0

File tree

12 files changed

+370
-110
lines changed

12 files changed

+370
-110
lines changed

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,25 @@
22

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

5+
## 1.2.2
6+
7+
### Improvements
8+
- **Mobile race control** added collapsible race control messages section to mobile view for both live and replay
9+
- **<1sec interval highlight** intervals under 1 second are highlighted in green during race sessions (toggleable in settings)
10+
- **Live session styling** improved pulse animation on live indicators and cleaner live session button layout (contributed by [@Clav3rbot](https://github.com/Clav3rbot))
11+
- **Broadcast delay** added manual input field for exact delay value
12+
- **Minor UI/UX improvements** main page layout changed to expandable list, fixed minor UI bugs on navigation
13+
14+
15+
### Fixes
16+
- **Memory management** replay session data is now evicted from memory 5 minutes after the last client disconnects
17+
- **Live race control messages** fixed race control messages not updating during live sessions when broadcast delay is set
18+
- **Phantom tyre compounds** fixed incorrect tyre history in live sessions caused by interim/placeholder compound updates from the F1 feed
19+
- **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
20+
- **Session times** corrected session times to display local date with time
21+
22+
---
23+
524
## 1.2.1 - 2026-03-14
625

726
### Fixes

backend/routers/replay.py

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,40 @@
11
import asyncio
22
import logging
33
import math
4+
import os
45
import re
56

67
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
78
from services.storage import get_json
89
from services.process import ensure_session_data_ws
910

11+
def _log_memory():
12+
"""Log current process memory usage."""
13+
try:
14+
# Works on Linux (Docker) — reads from /proc
15+
with open(f"/proc/{os.getpid()}/status") as f:
16+
for line in f:
17+
if line.startswith("VmRSS:"):
18+
mem_mb = int(line.split()[1]) / 1024
19+
break
20+
else:
21+
mem_mb = 0
22+
except FileNotFoundError:
23+
# macOS fallback
24+
import resource
25+
mem_mb = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / (1024 * 1024)
26+
cache_sessions = len(_replay_cache)
27+
return f"process: {mem_mb:.0f}MB, cached sessions: {cache_sessions}"
28+
1029
logger = logging.getLogger(__name__)
1130
router = APIRouter(tags=["replay"])
1231

1332
# In-memory cache for replay frames loaded from R2
1433
_replay_cache: dict[str, list[dict]] = {}
34+
_replay_clients: dict[str, int] = {} # key -> active WebSocket count
35+
_eviction_tasks: dict[str, asyncio.Task] = {} # key -> pending eviction task
36+
37+
CACHE_EVICTION_SECONDS = 300 # 5 minutes after last client disconnects
1538

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

158182

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

162186

187+
def _client_connect(key: str):
188+
"""Register a WebSocket client for a cached session."""
189+
_replay_clients[key] = _replay_clients.get(key, 0) + 1
190+
# Cancel any pending eviction since a client is now connected
191+
task = _eviction_tasks.pop(key, None)
192+
if task:
193+
task.cancel()
194+
logger.info(f"[memory] Cancelled eviction for {key} — new client connected")
195+
196+
197+
async def _client_disconnect(key: str):
198+
"""Unregister a WebSocket client. Schedule eviction if no clients remain."""
199+
_replay_clients[key] = max(0, _replay_clients.get(key, 0) - 1)
200+
if _replay_clients[key] == 0:
201+
_replay_clients.pop(key, None)
202+
if key in _replay_cache:
203+
logger.info(f"[memory] No clients for {key}, scheduling eviction in {CACHE_EVICTION_SECONDS}s — {_log_memory()}")
204+
task = asyncio.create_task(_evict_after_delay(key))
205+
_eviction_tasks[key] = task
206+
207+
208+
async def _evict_after_delay(key: str):
209+
"""Wait, then evict a cached session if no new clients have connected."""
210+
try:
211+
await asyncio.sleep(CACHE_EVICTION_SECONDS)
212+
if _replay_clients.get(key, 0) == 0 and key in _replay_cache:
213+
del _replay_cache[key]
214+
_eviction_tasks.pop(key, None)
215+
logger.info(f"[memory] Evicted {key}{_log_memory()}")
216+
except asyncio.CancelledError:
217+
pass
218+
219+
163220
@router.websocket("/ws/replay/{year}/{round_num}")
164221
async def replay_websocket(
165222
websocket: WebSocket,
@@ -192,12 +249,14 @@ async def send_status(msg: str):
192249
return
193250

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

198254
frames = await _get_frames(year, round_num, type)
255+
cache_key = f"{year}_{round_num}_{type}"
256+
_client_connect(cache_key)
199257

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

332391
except WebSocketDisconnect:
333-
logger.info(f"WebSocket disconnected: {year}/{round_num}")
392+
cache_key = f"{year}_{round_num}_{type}"
393+
await _client_disconnect(cache_key)
394+
logger.info(f"[memory] WebSocket disconnected: {year}/{round_num}/{type}{_log_memory()}")
334395
except Exception as e:
396+
cache_key = f"{year}_{round_num}_{type}"
397+
await _client_disconnect(cache_key)
335398
logger.error(f"WebSocket error: {e}")
336399
try:
337400
await websocket.close()

backend/services/f1_data.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ def _fetch_schedule_sync(year: int) -> list[dict]:
109109
date_utc = row.get(f"Session{i}DateUtc")
110110
sessions_raw.append({
111111
"name": name,
112-
"date_utc": str(date_utc) if pd.notna(date_utc) else None,
112+
"date_utc": date_utc.isoformat() + "Z" if pd.notna(date_utc) else None,
113113
"_ts": date_utc.to_pydatetime().replace(tzinfo=timezone.utc) if pd.notna(date_utc) else None,
114114
})
115115

backend/services/live_state.py

Lines changed: 81 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ class _DriverState:
102102
"_sector_best_personal",
103103
"_sector_best_overall",
104104
"_stint_count",
105+
"_last_stint_idx",
106+
"_s3_complete_time",
107+
"_sector_times",
105108
)
106109

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

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

416-
# Only update colour when the API explicitly includes the
417-
# fastest flags. Re-sends of the same Value without flags
418-
# should not reset the colour to yellow.
419-
has_flags = "OverallFastest" in sector_data or "PersonalFastest" in sector_data
420-
overall_fastest = bool(sector_data.get("OverallFastest", False))
421-
personal_fastest = bool(sector_data.get("PersonalFastest", False))
422-
423-
# Track personal/overall bests
422+
# Parse sector time
424423
try:
425424
sec_time = float(val_str)
426-
current_pb = drv._sector_best_personal.get(sector_idx)
427-
if current_pb is None or sec_time < current_pb:
428-
drv._sector_best_personal[sector_idx] = sec_time
429-
current_ob = self._overall_sector_bests.get(sector_idx)
430-
if current_ob is None or sec_time < current_ob:
431-
self._overall_sector_bests[sector_idx] = sec_time
432425
except ValueError:
433-
pass
426+
continue
434427

435-
# Determine colour
436-
if overall_fastest:
428+
# Store the sector time for later recomputation
429+
drv._sector_times[sector_idx] = sec_time
430+
431+
# Track personal bests
432+
current_pb = drv._sector_best_personal.get(sector_idx)
433+
is_personal_best = current_pb is None or sec_time <= current_pb + 0.0005
434+
if current_pb is None or sec_time < current_pb:
435+
drv._sector_best_personal[sector_idx] = sec_time
436+
437+
# Track overall bests — if this is a new overall best, update
438+
# all other drivers' sectors to remove stale purples
439+
current_ob = self._overall_sector_bests.get(sector_idx)
440+
is_overall_best = current_ob is None or sec_time <= current_ob + 0.0005
441+
if current_ob is None or sec_time < current_ob:
442+
self._overall_sector_bests[sector_idx] = sec_time
443+
# Downgrade other drivers' purple in this sector
444+
self._recompute_sector_colours(sector_idx, drv.racing_number)
445+
446+
# Determine colour from actual times
447+
if is_overall_best:
437448
color = "purple"
438-
elif personal_fastest:
449+
elif is_personal_best:
439450
color = "green"
440451
else:
441452
color = "yellow"
442453

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

451456
# Clear any sectors after this one (new lap progress)
452457
for later in list(existing):
453458
if later > sector_num:
454459
del existing[later]
460+
drv._sector_times.pop(later - 1, None)
461+
462+
# Track S3 completion for 5-second linger
463+
if sector_num == 3:
464+
drv._s3_complete_time = time.monotonic()
465+
elif sector_num == 1:
466+
# Starting new lap — reset S3 timer
467+
drv._s3_complete_time = None
455468

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

471+
def _recompute_sector_colours(self, sector_idx: int, exclude_rn: str) -> None:
472+
"""When a new overall best is set in a sector, downgrade other drivers'
473+
purple indicators for that sector to green or yellow."""
474+
sector_num = sector_idx + 1
475+
new_best = self._overall_sector_bests.get(sector_idx)
476+
if new_best is None:
477+
return
478+
479+
for drv in self._drivers.values():
480+
if drv.racing_number == exclude_rn:
481+
continue
482+
if not drv.sectors:
483+
continue
484+
for s in drv.sectors:
485+
if s["num"] == sector_num and s["color"] == "purple":
486+
# Check if this driver's time is still a personal best
487+
drv_time = drv._sector_times.get(sector_idx)
488+
drv_pb = drv._sector_best_personal.get(sector_idx)
489+
if drv_time is not None and drv_pb is not None and drv_time <= drv_pb + 0.0005:
490+
s["color"] = "green"
491+
else:
492+
s["color"] = "yellow"
493+
458494
# --- Position -----------------------------------------------------
459495

460496
def _handle_position(self, data: dict, _ts: float) -> None:
@@ -610,12 +646,16 @@ def _process_stints(self, drv: _DriverState, stints: dict) -> None:
610646
# Update compound
611647
if "Compound" in latest_stint:
612648
new_compound = latest_stint["Compound"].upper()
613-
old_compound = drv.compound
614-
drv.compound = new_compound
615-
# Track stint changes for tyre_history
616-
if old_compound and new_compound != old_compound:
617-
if not drv.tyre_history or drv.tyre_history[-1] != old_compound:
618-
drv.tyre_history.append(old_compound)
649+
# Ignore interim/placeholder compounds
650+
if new_compound == "UNKNOWN":
651+
pass
652+
else:
653+
# Only add to tyre history when the stint index increases (actual pit stop)
654+
if max_idx > drv._last_stint_idx and drv._last_stint_idx >= 0 and drv.compound:
655+
if not drv.tyre_history or drv.tyre_history[-1] != drv.compound:
656+
drv.tyre_history.append(drv.compound)
657+
drv.compound = new_compound
658+
drv._last_stint_idx = max_idx
619659

620660
# Update tyre life
621661
if "TotalLaps" in latest_stint:
@@ -821,12 +861,20 @@ def get_frame(self) -> dict:
821861
822862
This is intended to be called at ~2 Hz by the broadcaster.
823863
"""
864+
SECTOR_LINGER = 5.0
865+
now = time.monotonic()
866+
824867
drivers_list: list[dict[str, Any]] = []
825868
for drv in self._drivers.values():
826869
# Skip phantom drivers with no identity (created by Position.z
827870
# before DriverList arrives)
828871
if not drv.abbr:
829872
continue
873+
# Clear sectors 5 seconds after S3 completes
874+
if drv._s3_complete_time and (now - drv._s3_complete_time) > SECTOR_LINGER:
875+
drv.sectors = None
876+
drv._s3_complete_time = None
877+
drv._sector_times.clear()
830878
d = drv.to_dict()
831879
# Sanitize all values
832880
for key in list(d.keys()):

0 commit comments

Comments
 (0)