From 839437125a39f27c7fecb544228f85c3c2b4a288 Mon Sep 17 00:00:00 2001 From: angusdenham Date: Tue, 18 Aug 2026 19:00:48 +1000 Subject: [PATCH] Fix jagged track map and Docker PORT handling Track outline (#101): the outline was drawn from the session's fastest lap, but when a session's position feed repeats coordinates instead of updating them, that lap has too few distinct points to trace the circuit. The 2026 Hungarian race gave 26 distinct points against ~300 in qualifying, rendering a 26-sided polygon. Every lap in that session is equally affected, so the reference lap is now chosen by distinct position points rather than lap time, falling back to another session from the same weekend. Both the outline and the replay coordinate normalisation go through the same helper so driver dots stay aligned with the track. Docker PORT: PORT set the host side of the compose mapping while the container side was hardcoded to 8000, and env_file also passed PORT into the container. Setting it published one port and listened on another. Compose now maps the same port on both sides, with PORT set explicitly so a shell-provided value reaches the container too. Also adds backend/.fastf1-cache to .dockerignore; the existing entry pointed at backend/data/fastf1-cache, so a local precompute put 3.2GB into the build context. Co-Authored-By: Claude Opus 5 --- .dockerignore | 1 + CHANGELOG.md | 8 +++ README.md | 7 ++- backend/services/f1_data.py | 100 +++++++++++++++++++++++++++++++++--- docker-compose.yml | 4 +- 5 files changed, 112 insertions(+), 8 deletions(-) diff --git a/.dockerignore b/.dockerignore index e891e3f..57c7f22 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,6 +8,7 @@ backend/__pycache__ backend/*.pyc backend/data/sessions backend/data/fastf1-cache +backend/.fastf1-cache backend/data/pit_loss_raw.json frontend/node_modules frontend/.next diff --git a/CHANGELOG.md b/CHANGELOG.md index 978e044..4005ef2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to F1 Replay Timing will be documented in this file. +## 2.1.2 + +### Fixes + +- **Jagged track map** — circuits drew as a coarse polygon when a session's position feed repeated coordinates instead of updating them (the 2026 Hungarian race gave 26 usable points a lap, against ~300 in qualifying). The outline lap is now chosen by position detail rather than lap time, falling back to another session from the same weekend. Requires re-compute for affected sessions. (reported by [@starscream10](https://github.com/starscream10)) + +- **`PORT` ignored in Docker** — setting `PORT` published one port but left the container listening on 8000, so the app was unreachable and only 8000 worked. Compose now maps the same port on both sides. (reported by [@guilleortas](https://github.com/guilleortas)) + ## 2.1.1 ### Improvements diff --git a/README.md b/README.md index 6067a30..e87cee0 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,9 @@ You can also use pre-built images instead of building from source: ```bash # Pull and run directly docker run -d -p 8000:8000 --env-file .env -v f1data:/data -v f1cache:/data/fastf1-cache ghcr.io/adn8naiagent/f1replaytiming:latest + +# On a different port, pass PORT and map the same port on both sides +docker run -d -e PORT=9000 -p 9000:9000 --env-file .env -v f1data:/data -v f1cache:/data/fastf1-cache ghcr.io/adn8naiagent/f1replaytiming:latest ``` Or with docker-compose: @@ -160,8 +163,10 @@ services: f1timing: image: ghcr.io/adn8naiagent/f1replaytiming:latest ports: - - "${PORT:-8000}:8000" + - "${PORT:-8000}:${PORT:-8000}" env_file: .env + environment: + PORT: ${PORT:-8000} volumes: - f1data:/data - f1cache:/data/fastf1-cache diff --git a/backend/services/f1_data.py b/backend/services/f1_data.py index 5d484e4..87ca57f 100644 --- a/backend/services/f1_data.py +++ b/backend/services/f1_data.py @@ -242,6 +242,94 @@ async def get_session_info(year: int, round_num: int, session_type: str = "R") - return await asyncio.to_thread(_get_session_info_sync, year, round_num, session_type) +# The track outline is drawn from one lap's position trace. The F1 position feed +# sometimes repeats a car's previous coordinate instead of sending a new one, so a +# lap can have a full complement of samples but only a handful of distinct points, +# and it renders as a coarse polygon rather than the circuit. Below this many +# distinct points, look for a better lap elsewhere in the weekend. +MIN_OUTLINE_POSITION_POINTS = 150 + +# Preference order when borrowing an outline from another session of the same +# weekend. Track geometry does not change between sessions, so any of them will do. +_OUTLINE_SESSION_PREFERENCE = ("Q", "FP3", "FP2", "FP1", "SQ", "S", "R") + + +def _distinct_position_points(lap) -> int: + """Count the position samples on `lap` where the car actually moved. + + Repeated coordinates contribute no shape to the outline, so they don't count + towards whether a lap is dense enough to draw. + """ + try: + pos = lap.get_pos_data() + except Exception: + return 0 + if pos is None or len(pos) < 2: + return 0 + x = pos["X"].values.astype(float) + y = pos["Y"].values.astype(float) + repeats = int(((np.diff(x) == 0) & (np.diff(y) == 0)).sum()) + return len(x) - repeats + + +@lru_cache(maxsize=64) +def _outline_source_session(year: int, round_num: int, session_type: str) -> str | None: + """Session type whose fastest lap should draw the track for this session. + + Normally the session's own fastest lap. When a session's position feed was + degraded every lap in it traces the same coarse polygon (the 2026 Hungarian + race gives 26 distinct points against ~300 in qualifying), so fall back to + another session from the same weekend. + """ + candidates = [session_type] + [s for s in _OUTLINE_SESSION_PREFERENCE if s != session_type] + own_points = None + best_type, best_points = None, -1 + + for st in candidates: + try: + lap = _load_session(year, round_num, st).laps.pick_fastest() + except Exception: + continue + if lap is None: + continue + + points = _distinct_position_points(lap) + if st == session_type: + own_points = points + if points > best_points: + best_type, best_points = st, points + + if points >= MIN_OUTLINE_POSITION_POINTS: + if st != session_type: + logger.warning( + f"[{year} R{round_num} {session_type}] outline lap has only {own_points} " + f"distinct position points, drawing the track from {st} instead ({points} points)" + ) + return st + + if best_type is not None: + logger.warning( + f"[{year} R{round_num} {session_type}] no session this weekend has a clean position " + f"trace, falling back to {best_type} with {best_points} distinct points" + ) + return best_type + + +def _pick_outline_lap(year: int, round_num: int, session_type: str): + """The lap that draws the track outline and normalises replay coordinates. + + Both uses must share one lap, otherwise the driver dots are scaled against a + different bounding box than the track they're drawn on. + """ + src = _outline_source_session(year, round_num, session_type) + if src is None: + return None + try: + return _load_session(year, round_num, src).laps.pick_fastest() + except Exception: + return None + + def _get_track_data_sync(year: int, round_num: int, session_type: str = "R") -> dict: session = _load_session(year, round_num, session_type) @@ -258,9 +346,9 @@ def _get_track_data_sync(year: int, round_num: int, session_type: str = "R") -> except Exception: pass - # Get track coordinates from fastest lap telemetry - fastest_lap = session.laps.pick_fastest() - telemetry = fastest_lap.get_telemetry() + # Get track coordinates from the outline reference lap + fastest_lap = _pick_outline_lap(year, round_num, session_type) + telemetry = fastest_lap.get_telemetry() if fastest_lap is not None else None if telemetry is None or "X" not in telemetry.columns or len(telemetry) == 0: raise ValueError("Telemetry data not available for this session") @@ -573,10 +661,10 @@ def _get_driver_positions_by_time_sync( sample_interval = 0.5 num_samples = int(total_seconds / sample_interval) - # Use the same normalization as the track outline (fastest lap) + # Use the same normalization as the track outline (same reference lap) # so driver dots align exactly with the drawn track - fastest_lap = laps.pick_fastest() - fastest_tel = fastest_lap.get_telemetry() + fastest_lap = _pick_outline_lap(year, round_num, session_type) + fastest_tel = fastest_lap.get_telemetry() if fastest_lap is not None else None if fastest_tel is not None and "X" in fastest_tel.columns and len(fastest_tel) > 0: x_min = float(fastest_tel["X"].min()) x_max = float(fastest_tel["X"].max()) diff --git a/docker-compose.yml b/docker-compose.yml index 5acb1d8..11e3b92 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,8 +2,10 @@ services: f1timing: build: . # Uses root Dockerfile (unified FE+BE) ports: - - "${PORT:-8000}:8000" + - "${PORT:-8000}:${PORT:-8000}" env_file: .env + environment: + PORT: ${PORT:-8000} volumes: - f1data:/data - f1cache:/data/fastf1-cache