From e7d09cb139e470c8833053797304eb5ebd345700 Mon Sep 17 00:00:00 2001 From: Mike Wills Date: Tue, 29 Sep 2026 23:11:26 -0500 Subject: [PATCH] fix: measure a place's mile on its own race, at the pass the order means The mile beside a place came from whichever course line was nearest, ignoring the races ticked for it, and every race's lead runner pace used that one shared distance. Places are now measured on a ticked race (the highest on the Courses tab), leaders on their own race, and where a race passes a stop twice the pass is the one that keeps the club's order non-decreasing with the least total offset - Mankato's stop I moves from Full 20.6 (behind J) to 16.3. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QkbtsMnQEe8HZ7xyxXHYHB --- CHANGELOG.md | 24 ++++ CLAUDE.md | 14 +- docs/RUNBOOK.md | 8 ++ src/courseops/admin.py | 10 +- src/courseops/geo.py | 39 ++++++ src/courseops/guides/setup.md | 10 +- src/courseops/leaders.py | 33 +++-- src/courseops/progress.py | 145 ++++++++++++++++++++ src/courseops/snapshot.py | 12 +- tests/test_place_miles.py | 245 ++++++++++++++++++++++++++++++++++ 10 files changed, 524 insertions(+), 16 deletions(-) create mode 100644 tests/test_place_miles.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 52e3dfb..d1753cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,30 @@ month, PATCH counting releases in that month from 0. Before that they were ## [Unreleased] +### Fixed + +- **A place's mile is measured on a race it serves, at the right pass.** The + mile shown beside a place (Places tab, and the NCS row of whoever is posted + there) came from whichever course line was nearest, ignoring the races the + club had ticked: on the Mankato roster WE read Half 2.4, WF 10K 4.6 and WC + Full 6.5, and nothing a club could tick changed it. It is now measured on a + ticked race - the highest on the Courses tab if several - and falls back to + the nearest line only for a place with no ticks, or ticks for races nowhere + near it. +- **Lead runner pace and ETA are measured on the leader's own race.** Each stop + carried ONE distance, from the nearest line, shared by every race it serves, + so a Half leader's pace between two stops on shared road could be computed + from Full miles - or run backwards and come out as nothing. Every stop is now + measured on the race whose leader it is. +- **A stop passed twice is measured at the pass the club's order means.** The + Mankato Full goes by water stop I at mile 16.3 and 20.6; the nearer pass was + the later one, so I read 20.6, behind J at 16.8, and a Full leader's I -> J + leg had no pace or ETA. Per race, one pass is chosen per stop so the miles + never run backwards in the club's order, and of those the choice with the + stops nearest the line (`CourseIndex.progression`). Checked on the real + files: every race's stops now read in rising order; a snapshot builds in + 0.06 s. + ## [2026.9.18] - 2026-09-29 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 7ba0538..2b286dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,7 @@ python -m venv .venv ./.venv/Scripts/python.exe -m pip install -e ".[dev]" # Windows cp .env.example .env # then set APRS_CALLSIGN -./.venv/Scripts/python.exe -m pytest -q # 872 tests, no network +./.venv/Scripts/python.exe -m pytest -q # 882 tests, no network courseops init-db courseops add-event marathon2026 "Spring Marathon 2026" --lat 34.73 --lon -86.58 @@ -728,6 +728,16 @@ usability, not style preferences. from this race's progression and the "Passed X" button skipped from D to I. Nothing stated falls back to the snap, which is what keeps existing events working; never make that fallback the primary path again. +- **A distance belongs to ONE race, and to one pass of it.** `index.locate` + answers "which line is nearest", which on shared road is a coin flip + between races whose miles have nothing to do with each other - so a PLACE's + mile comes from `place_positions` (a ticked race, highest on the Courses + tab) and a leader's distances from `progression` on that leader's race. + Where a race passes a stop twice, `progression` picks the passes that keep + the club's order non-decreasing with the least total offset; nearest-pass + alone put Mankato's stop I at Full 20.6, behind J at 16.8. Anything new + that needs "how far along" for a place goes through one of those two, + never `locate`. - **Undo is not a reset.** `undo_last_sighting` removes one report, for a mis-tap mid-race. `clear_sightings` empties a race and division, for the morning of the event when the panel is carrying a rehearsal. Both are scoped @@ -1188,6 +1198,7 @@ Rules that keep this honest: Last 10 entries; full record in `CHANGELOG.md`. +- **2026-09-29** Fixed: a place's mile is measured on a race it serves (not the nearest line), at the pass the club's order means; lead runner pace/ETA measured on the leader's own race. - **2026-09-29** Starts at / Finishes at per race on the Courses tab: naming either turns the stored line to run from the start (the Mankato Half and 10K were drawn finish-first). - **2026-09-29** Fixed: the NCS stations list sorted posted stations by mile across races (10K mile 1.7 above Full 2.2); it now follows the club's place order, movers grouped by race. - **2026-09-29** Print roster (setup, Roster tab): callsign, operator, post, GPS, W3W in course order, unposted last; the browser's Save as PDF is the PDF. @@ -1197,4 +1208,3 @@ Last 10 entries; full record in `CHANGELOG.md`. - **2026-09-27** Archive an event (#4): tracking off, positions deleted, links 404, hidden behind Show archived; Download / `courseops export` writes one event to a SQLite file, `serve --db` reads it. - **2026-09-27** Runbook: a station whose age keeps climbing - check the phone app's send path (APRS-IS/TNC switches), aprs.fi raw, then the server log. - **2026-09-20** The role page's browser tab reads " | Course Ops" and setup " | Course Ops Setup", set beside the heading. -- **2026-09-15** Fixed: deleting a roster entry left its pin on the map; the delete now takes the stored position and status history with it. diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index df686a1..57e087b 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -164,6 +164,14 @@ nearest course line. That guess picks exactly ONE race, so a stop serving three gets dropped from two of them and the lead runner panel skips it: the "Passed X" button jumps from D to I with four stops missing. Tick the boxes. +The ticks also set the mile shown for the stop (on the Places tab and beside +whoever is posted there on the NCS panel): measured on a race it serves, the +highest on the Courses tab if it serves several. **Check the miles after +ticking:** each race's stops should read in rising order down the Places +table. One that jumps backwards or far ahead usually means the race runs the +wrong way (set *Starts at* on the Courses tab), the running order is wrong, +or the stop is ticked for a race whose line is nowhere near it. + ### 4b. Check the labels on the pins Each pin on a labelled layer carries one or two characters taken from its name, diff --git a/src/courseops/admin.py b/src/courseops/admin.py index 8c10d4a..7d1c400 100644 --- a/src/courseops/admin.py +++ b/src/courseops/admin.py @@ -595,13 +595,19 @@ def list_pois(conn: sqlite3.Connection, event_id: int) -> list[dict]: ).fetchall(): races.setdefault(row["poi_id"], []).append(row["course_id"]) out = [] - for row in index.order_along_course(rows): + ordered = index.order_along_course(rows) + place_at = index.place_positions( + ordered, {poi_id: set(ids) for poi_id, ids in races.items()}) + for row in ordered: entry = dict(row) layer = layers.get(row["poi_type"]) entry["layer_name"] = layer["name"] if layer else row["poi_type"] entry["layer_icon"] = layer["icon"] if layer else "pin" entry["layer_color"] = layer["color"] if layer else None - located = index.locate(row["lat"], row["lon"]) + # Measured on a race it serves (the ticks in this same table), at the + # pass the club's order says - the same figure the NCS panel shows + # for whoever is posted here. + located = place_at.get(row["id"]) entry["distance_along_m"] = located.distance_along_m if located else None # The mile never travels alone. Each place is snapped to whichever # course line is nearest, which is a coin flip where routes share diff --git a/src/courseops/geo.py b/src/courseops/geo.py index 8d16c3c..8dc4d59 100644 --- a/src/courseops/geo.py +++ b/src/courseops/geo.py @@ -293,6 +293,45 @@ def project(self, target: LonLat) -> Projection | None: point=(px / mx, py / my), ) + def passes(self, target: LonLat, within_m: float) -> list[Projection]: + """Every separate time the line goes by `target`, in course order. + + A pass is a run of consecutive segments within `within_m`; its + answer is the nearest point in that run. An out-and-back or a lap + goes by the same water stop twice, and `project` alone returns + whichever pass is a few metres nearer - on the Mankato Full, mile + 20.6 for a stop the club means at 16.3. + """ + if not self._segments: + return [] + mx, my = self._mx, self._my + tx, ty = target[0] * mx, target[1] * my + hypot = math.hypot + found: list[Projection] = [] + run = None # (offset, distance, index, point) of this run's best + last_i = None + for i, ax, ay, dx, dy, inv_seg_sq, total, length in self._segments: + t = ((tx - ax) * dx + (ty - ay) * dy) * inv_seg_sq + t = 0.0 if t < 0.0 else (1.0 if t > 1.0 else t) + px, py = ax + t * dx, ay + t * dy + offset = hypot(tx - px, ty - py) + if offset > within_m: + if run is not None: + found.append(run) + run = None + continue + # A skipped duplicate vertex does not break a run. + if run is not None and last_i is not None and i > last_i + 2: + found.append(run) + run = None + last_i = i + here = Projection(total + t * length, offset, i, (px / mx, py / my)) + if run is None or offset < run.offset_m: + run = here + if run is not None: + found.append(run) + return found + def project_onto_line( coords: list[LonLat], diff --git a/src/courseops/guides/setup.md b/src/courseops/guides/setup.md index a65c01a..7e52e5b 100644 --- a/src/courseops/guides/setup.md +++ b/src/courseops/guides/setup.md @@ -167,7 +167,15 @@ Every point on the map lives here. What each column is for: - **Races** - which races this stop serves. State it; do not let it be guessed. One water stop routinely serves three races - the organizer's file will literally say "WATER (ALL)" - and guessing drops it from every race whose line - happens to run further away. + happens to run further away. The ticks also decide **the mile** shown beside + the place here and on the Net Control panel: it is measured on a race the + stop serves - of several, the one highest on the Courses tab - rather than on + whichever line happens to be a few metres nearer. Each race's lead runner + pace and ETA are measured on that race. +- **Order and the mile together.** Where a race goes by the same stop twice (an + out-and-back, a lap), the running order says which pass is meant: the mile is + the one that keeps the stops in order. So if a mile looks wrong, check the + order first. - **Pin** - the one or two characters drawn on the pin. Derived from the name, and only worth typing when the guess comes out wrong. - **what3words** - optional, typed in by hand. Aid stations sit at park diff --git a/src/courseops/leaders.py b/src/courseops/leaders.py index 66e7f67..ab67a13 100644 --- a/src/courseops/leaders.py +++ b/src/courseops/leaders.py @@ -356,11 +356,9 @@ def for_event( serves = stated.get(row["id"]) if not serves: serves = {located.course_id} if located else set() - known[row["id"]] = ( - row["name"], - located.distance_along_m if located else None, - serves, - ) + # Where the stop is, not a distance: a distance belongs to one race, + # and `_leader_for` measures it on the race whose leader it is. + known[row["id"]] = (row["name"], None, serves, row["lat"], row["lon"]) # Ordered the way the club reads them: their own order where they set one, # distance along the course otherwise. This is what "the next station" @@ -375,13 +373,13 @@ def for_event( for division in divisions: results.append( _leader_for(conn, event_id, course, division, on_course, known, - division_label(division, labels)) + index, division_label(division, labels)) ) return results def _leader_for( - conn, event_id, course, division, stations, known, division_name=None + conn, event_id, course, division, stations, known, index, division_name=None ) -> Leader: base = dict( course_id=course["id"], @@ -396,8 +394,25 @@ def _leader_for( reports = sightings(conn, event_id, course["id"], division) # `known` covers every staffed place in the event, so a sighting recorded # at a station that snapped to another course is still named. - distance_by_poi = {poi_id: d for poi_id, (_, d, _) in known.items()} - name_by_poi = {poi_id: n for poi_id, (n, _, _) in known.items()} + # + # Distances are measured on THIS race. Each stop used to carry one + # distance, from whichever line was nearest, shared by every race it + # serves - so a Half leader's pace between two stops on shared road could + # come from Full miles, or run backwards and come out as nothing. A stop + # too far from this race's line has no distance on it, and no pace or ETA + # is built from it. + # + # And at the right pass: this race's stops, walked in the club's order, + # each measured at the first pass after the stop before it - a looped + # route goes by a water stop twice. A sighting at a stop this race does + # not serve is measured at its nearest pass. + along = index.progression(course["id"], stations) + distance_by_poi = {} + for poi_id, (_, _, _, lat, lon) in known.items(): + located = (along[poi_id] if poi_id in along + else index.locate_on(lat, lon, course["id"])) + distance_by_poi[poi_id] = located.distance_along_m if located else None + name_by_poi = {poi_id: entry[0] for poi_id, entry in known.items()} positions = [ (distance_by_poi.get(row["id"]), row) for row in stations ] diff --git a/src/courseops/progress.py b/src/courseops/progress.py index 3aa8341..58a25d9 100644 --- a/src/courseops/progress.py +++ b/src/courseops/progress.py @@ -102,6 +102,7 @@ def __init__(self, courses: list[_Course], # course. The index is built per request, so the memo needs no # invalidation: it dies with the request. self._located: dict[tuple[float, float], CoursePosition | None] = {} + self._located_on: dict[tuple[float, float, int], CoursePosition | None] = {} def __len__(self) -> int: return len(self._courses) @@ -175,6 +176,136 @@ def key(row): return sorted(rows, key=key) + def locate_on(self, lat: float, lon: float, + course_id: int) -> CoursePosition | None: + """The nearest point on ONE course, or None if it is not near it. + + What a distance along a particular race needs. `locate` answers + "which line is this nearest", and on shared road that is a coin flip + between races whose miles have nothing to do with each other. + """ + key = (lat, lon, course_id) + try: + return self._located_on[key] + except KeyError: + pass + answer = None + course = next((c for c in self._courses if c.id == course_id), None) + if course is not None: + projection = course.line.project((lon, lat)) + if projection is not None and projection.offset_m <= self.max_offset_m: + answer = CoursePosition( + course_id=course.id, + course_name=course.name, + distance_along_m=projection.distance_along_m, + remaining_m=max(0.0, course.length_m - projection.distance_along_m), + course_length_m=course.length_m, + offset_m=projection.offset_m, + ) + self._located_on[key] = answer + return answer + + def _position(self, course: _Course, projection) -> CoursePosition: + return CoursePosition( + course_id=course.id, + course_name=course.name, + distance_along_m=projection.distance_along_m, + remaining_m=max(0.0, course.length_m - projection.distance_along_m), + course_length_m=course.length_m, + offset_m=projection.offset_m, + ) + + def progression(self, course_id: int, + stops: list) -> dict[int, CoursePosition | None]: + """Each stop's position on ONE race, the stops in the club's order. + + Where the race goes by a stop more than once, which pass is meant + is chosen for the whole race at once: one pass per stop such that + the miles never run backwards in the club's order, and of those the + choice that puts the stops nearest the line in total. Nearest-pass + alone put the Mankato Full's water stop I at mile 20.6 (54 m off) + instead of 16.3 (80 m off), behind J at 16.8; "first pass after the + previous stop" alone took a pass 200 m away over one the stop sits + on. Together they are right in both cases. + + A stop that fits nowhere in order (an order that runs against the + route) is left out of the ordering and given its nearest pass, so it + cannot drag the stops after it. A stop no pass comes near is None. + """ + course = next((c for c in self._courses if c.id == course_id), None) + if course is None: + return {row["id"]: None for row in stops} + # A stop beside another on the same spot projects a few metres + # "before" it; this much backwards is still in order. + slack = 50.0 + skip = 1e6 # far above any sum of offsets + options = [course.line.passes((row["lon"], row["lat"]), self.max_offset_m) + for row in stops] + placeable = [j for j, found in enumerate(options) if found] + + # best[(j, k)] = (cost, previous (j, k) or None) for stop j at pass k, + # every placeable stop before it either placed in order or skipped. + best: dict[tuple[int, int], tuple[float, tuple[int, int] | None]] = {} + for n, j in enumerate(placeable): + for k, here in enumerate(options[j]): + cost, back = skip * n, None # everything before skipped + for m, i in enumerate(placeable[:n]): + for kk, there in enumerate(options[i]): + if there.distance_along_m > here.distance_along_m + slack: + continue + candidate = best[(i, kk)][0] + skip * (n - m - 1) + if candidate < cost: + cost, back = candidate, (i, kk) + best[(j, k)] = (cost + here.offset_m, back) + + chosen: dict[int, int] = {} + if best: + last = len(placeable) - 1 + end = min(best, key=lambda key: best[key][0] + + skip * (last - placeable.index(key[0]))) + node: tuple[int, int] | None = end + while node is not None: + chosen[node[0]] = node[1] + node = best[node][1] + + out: dict[int, CoursePosition | None] = {} + for j, row in enumerate(stops): + if not options[j]: + out[row["id"]] = None + elif j in chosen: + out[row["id"]] = self._position(course, options[j][chosen[j]]) + else: + nearest = min(options[j], key=lambda p: p.offset_m) + out[row["id"]] = self._position(course, nearest) + return out + + def place_positions(self, ordered_rows: list, + served: dict[int, set[int]]) -> dict[int, CoursePosition | None]: + """Every place's mile, as the club reads it. + + On a race it serves - the one highest on the Courses tab - at the + pass its place in the club's order says. A place serving no stated + race, or none within reach, keeps the nearest line as before. + `ordered_rows` must already be in club order (`order_along_course`). + """ + per_course: dict[int, dict[int, CoursePosition | None]] = {} + for course in self._courses: + on_it = [row for row in ordered_rows + if course.id in served.get(row["id"], ())] + if on_it: + per_course[course.id] = self.progression(course.id, on_it) + out: dict[int, CoursePosition | None] = {} + for row in ordered_rows: + found = None + ticked = served.get(row["id"]) or set() + for course in reversed(self._courses): # top of the stack first + if course.id in ticked: + found = per_course.get(course.id, {}).get(row["id"]) + if found is not None: + break + out[row["id"]] = found or self.locate(row["lat"], row["lon"]) + return out + def locate(self, lat: float, lon: float) -> CoursePosition | None: """Nearest point on the nearest course, or None if not near any. @@ -205,3 +336,17 @@ def locate(self, lat: float, lon: float) -> CoursePosition | None: ) self._located[key] = best return best + + +def served_courses(conn: sqlite3.Connection, event_id: int) -> dict[int, set[int]]: + """poi id -> the races the club ticked for it (`poi_course`). + + Stated, never guessed. A place with no entry serves nothing stated, and + `place_positions` measures it on the nearest line as before. + """ + served: dict[int, set[int]] = {} + for row in conn.execute( + "SELECT poi_id, course_id FROM poi_course WHERE event_id = ?", (event_id,) + ).fetchall(): + served.setdefault(row["poi_id"], set()).add(row["course_id"]) + return served diff --git a/src/courseops/snapshot.py b/src/courseops/snapshot.py index 7296e2b..2845d43 100644 --- a/src/courseops/snapshot.py +++ b/src/courseops/snapshot.py @@ -168,10 +168,18 @@ def build_state(conn: sqlite3.Connection, event_id: int) -> dict[str, Any]: poi_rows = conn.execute( "SELECT * FROM poi WHERE event_id = ?", (event_id,) ).fetchall() + ordered_pois = index.order_along_course(poi_rows) + # On a race the place serves, where the club ticked one, and at the pass + # the club's order says: on shared road the nearest line is a coin flip + # between unrelated miles, and a looped route goes by a stop twice. A + # posted operator inherits this below, so the NCS row reads it too. + place_at = index.place_positions( + ordered_pois, progress.served_courses(conn, event_id)) pois = [] - for row in index.order_along_course(poi_rows): + for row in ordered_pois: entry = dict(row) - entry["course_position"] = course_position(index, row["lat"], row["lon"]) + located = place_at.get(row["id"]) + entry["course_position"] = located.as_dict() if located else None # One or two characters for the pin itself. Derived unless the club # typed an override; the client never has to guess. entry["label_text"] = poi_labels.for_poi(row["name"], row["label"]) diff --git a/tests/test_place_miles.py b/tests/test_place_miles.py new file mode 100644 index 0000000..e452e47 --- /dev/null +++ b/tests/test_place_miles.py @@ -0,0 +1,245 @@ +"""A place's mile is measured on a race it serves, not the nearest line. + +The Mankato Full, Half and 10K share road for miles, and each place was +measured on whichever line happened to be a few metres nearer: the NCS +panel read WE "mile 2.4" (Half), WF "4.6" (10K) and WC "6.5" (Full), and +nothing a club could tick changed it. The races a place serves are already +stated (`poi_course`, the checkboxes on the Places tab); the mile is now +measured on one of them - the first in the Courses tab order, which the +club sets by dragging. + +The same flaw sat under the lead runners, where it mattered more: each +stop had ONE distance, from the nearest line, used for every race's pace +and ETA. A Half leader's pace between two stops could come from Full miles. +The leader's own race now measures every stop. +""" +from __future__ import annotations + +import json + +import pytest +from fastapi.testclient import TestClient + +from courseops import access, admin, db, geo, leaders, progress, users +from courseops.config import Settings +from courseops.web import create_app + +# Two 3 km routes on the same road, 4 m apart. The Full is drawn south to +# north, the Half north to south, so their miles along it disagree. +SOUTH, NORTH = 44.10, 44.10 + 3000 / 111_195 +FULL = [(-94.0, SOUTH + i * (NORTH - SOUTH) / 30) for i in range(31)] +HALF = [(-94.00005, NORTH - i * (NORTH - SOUTH) / 30) for i in range(31)] +LON_NEAR_HALF = -94.00006 # 1 m from the Half, 5 m from the Full + + +def _lat_at(metres_from_south): + return SOUTH + metres_from_south / 111_195 + + +def _course(conn, event_id, name, coords, sort_order): + return conn.execute( + "INSERT INTO course (event_id, name, geojson, distance_m, sort_order)" + " VALUES (?, ?, ?, ?, ?)", + (event_id, name, json.dumps(geo.to_geojson_linestring(coords)), + geo.line_length_m(coords), sort_order)).lastrowid + + +def _place(conn, event_id, name, metres_from_south, serves, order): + poi_id = conn.execute( + "INSERT INTO poi (event_id, name, poi_type, lat, lon, sort_order)" + " VALUES (?, ?, 'aid_station', ?, ?, ?)", + (event_id, name, _lat_at(metres_from_south), LON_NEAR_HALF, order) + ).lastrowid + for course_id in serves: + conn.execute("INSERT INTO poi_course (event_id, poi_id, course_id)" + " VALUES (?, ?, ?)", (event_id, poi_id, course_id)) + return poi_id + + +@pytest.fixture +def event(tmp_path): + db_path = tmp_path / "t.sqlite3" + conn = db.connect(db_path) + db.init_schema(conn) + event_id = db.create_event(conn, "m2026", "Spring Marathon") + # Full on top of the Courses tab (highest sort_order), as at Mankato. + full = _course(conn, event_id, "Full", FULL, 30) + half = _course(conn, event_id, "Half", HALF, 20) + return conn, db_path, event_id, full, half + + +def _position(conn, event_id, poi_id): + index = progress.CourseIndex.for_event(conn, event_id) + rows = index.order_along_course(conn.execute( + "SELECT * FROM poi WHERE event_id = ?", (event_id,)).fetchall()) + return index.place_positions( + rows, progress.served_courses(conn, event_id))[poi_id] + + +def test_nothing_ticked_keeps_the_nearest_line(event): + conn, _, event_id, _, _ = event + poi = _place(conn, event_id, "A", 1000, [], 1) + pos = _position(conn, event_id, poi) + assert pos.course_name == "Half" + assert pos.distance_along_m == pytest.approx(2000, abs=5) + + +def test_one_race_ticked_measures_on_that_race(event): + """Nearer the Half's line, but it serves the Full: Full mile.""" + conn, _, event_id, full, _ = event + poi = _place(conn, event_id, "A", 1000, [full], 1) + pos = _position(conn, event_id, poi) + assert pos.course_name == "Full" + assert pos.distance_along_m == pytest.approx(1000, abs=5) + + +def test_several_ticked_measures_on_the_top_of_the_courses_tab(event): + conn, _, event_id, full, half = event + poi = _place(conn, event_id, "A", 1000, [half, full], 1) + assert _position(conn, event_id, poi).course_name == "Full" + # Drag the Half to the top and it is the Half's mile. + conn.execute("UPDATE course SET sort_order = 40 WHERE id = ?", (half,)) + assert _position(conn, event_id, poi).course_name == "Half" + + +def test_a_ticked_race_far_from_the_place_falls_back_to_the_nearest(event): + """A tick for a race 2 km away is a wrong tick; the mile stays what it + was before rather than becoming none.""" + conn, _, event_id, _, _ = event + far = _course(conn, event_id, "10K", + [(-93.97, SOUTH), (-93.97, NORTH)], 10) + poi = _place(conn, event_id, "A", 1000, [far], 1) + assert _position(conn, event_id, poi).course_name == "Half" + + +def test_the_snapshot_and_a_posted_station_carry_the_served_race_mile(event): + conn, db_path, event_id, full, _ = event + poi = _place(conn, event_id, "A", 1000, [full], 1) + db.upsert_roster_entry(conn, event_id, "KD0AAA", "WA", "aid_station", + expects_aprs=False) + db.assign_station_to_poi(conn, event_id, "KD0AAA", poi) + tokens = access.ensure_tokens(conn, event_id) + conn.close() + app = create_app(Settings(callsign="KI4TST", passcode="-1", host="h", port=1, + db_path=db_path, log_level="WARNING")) + with TestClient(app) as client: + state = client.get(f"/api/m2026/{tokens['ncs']}/state").json() + place = next(p for p in state["pois"] if p["id"] == poi) + station = next(r for r in state["roster"] if r["station_key"] == "KD0AAA") + for where in (place, station): + assert where["course_position"]["course_name"] == "Full" + assert where["course_position"]["distance_along_m"] == pytest.approx(1000, abs=5) + + +def test_the_places_tab_shows_the_served_race_mile(event): + conn, _, event_id, full, _ = event + poi = _place(conn, event_id, "A", 1000, [full], 1) + row = next(p for p in admin.list_pois(conn, event_id) if p["id"] == poi) + assert row["course_name"] == "Full" + assert row["distance_along_m"] == pytest.approx(1000, abs=5) + + +def test_a_half_leaders_pace_is_measured_on_the_half(event): + """Both stops sit nearer the Half's line but serve both races, and the + Half runs north to south. Measured on the Full (the old single distance + per stop, for a stop nearest the Full) the leg ran backwards and the + pace came out as nothing; on the Half it is 1000 m in 5 minutes.""" + conn, _, event_id, full, half = event + # Nearer the FULL this time, so the old single distance was the Full's. + near_full = -93.99999 + p_north = _place(conn, event_id, "N", 2000, [full, half], 1) + p_south = _place(conn, event_id, "S", 1000, [full, half], 2) + conn.execute("UPDATE poi SET lon = ? WHERE event_id = ?", (near_full, event_id)) + leaders.record_sighting(conn, event_id, half, "male", p_north) + leaders.record_sighting(conn, event_id, half, "male", p_south) + conn.execute( + "UPDATE lead_sighting SET at = CASE poi_id WHEN ? THEN" + " '2026-10-17T13:00:00Z' ELSE '2026-10-17T13:05:00Z' END", + (p_north,)) + index = progress.CourseIndex.for_event(conn, event_id) + entry = next(l for l in leaders.for_event(conn, event_id, index) + if l.course_id == half and l.division == "male") + assert entry.last_distance_m == pytest.approx(2000, abs=5) + assert entry.pace_mps == pytest.approx(1000 / 300, rel=0.01) + + +# --- a stop passed more than once ---------------------------------------------- +# +# The Mankato Full passes water stop I at mile 16.3 and again at 20.6, and +# the nearest pass (54 m against 80) was the later one - so I read 20.6, +# after J at 16.8, and a Full leader's I -> J leg ran backwards: no pace, no +# ETA. The club's order settles it: one pass per stop, never running +# backwards in that order, and of those the nearest to the line in total. + +# An out-and-back: 3 km north, then back south on a road 56 m east. A point +# m metres north is at m on the way out and at 6056 - m on the way back. +NEARER_OUT, NEARER_BACK = -93.99990, -93.99940 # 8 m from one, 48 from the other + + +def _out_and_back(): + out = [(-94.0, _lat_at(i * 100)) for i in range(31)] + back = [(-93.9993, _lat_at(3000 - i * 100)) for i in range(31)] + return out + back + + +def _stop(conn, event_id, name, metres_north, lon, serves, order): + poi_id = _place(conn, event_id, name, metres_north, serves, order) + conn.execute("UPDATE poi SET lon = ? WHERE id = ?", (lon, poi_id)) + return poi_id + + +@pytest.fixture +def out_and_back(tmp_path): + conn = db.connect(tmp_path / "t.sqlite3") + db.init_schema(conn) + event_id = db.create_event(conn, "m2026", "Spring Marathon") + course = _course(conn, event_id, "Full", _out_and_back(), 30) + stops = { + # The Mankato shape: I sits nearer its LATER pass (4056), and taking + # it leaves J nowhere to go but backwards. + "H": _stop(conn, event_id, "H", 1000, NEARER_OUT, [course], 1), + "I": _stop(conn, event_id, "I", 2000, NEARER_BACK, [course], 2), + "J": _stop(conn, event_id, "J", 2500, NEARER_OUT, [course], 3), + # And the nearest pass is still taken where the order allows it: K is + # on the way back, where it sits 8 m from the line. + "K": _stop(conn, event_id, "K", 1500, NEARER_BACK, [course], 4), + } + return conn, event_id, course, stops + + +def _place_miles(conn, event_id): + index = progress.CourseIndex.for_event(conn, event_id) + rows = index.order_along_course(conn.execute( + "SELECT * FROM poi WHERE event_id = ?", (event_id,)).fetchall()) + found = index.place_positions(rows, progress.served_courses(conn, event_id)) + return {pid: pos.distance_along_m for pid, pos in found.items()} + + +def test_the_pass_is_the_one_the_clubs_order_allows(out_and_back): + conn, event_id, _, stops = out_and_back + miles = _place_miles(conn, event_id) + assert miles[stops["H"]] == pytest.approx(1000, abs=10) + assert miles[stops["I"]] == pytest.approx(2000, abs=10) # not 4056 + assert miles[stops["J"]] == pytest.approx(2500, abs=10) + assert miles[stops["K"]] == pytest.approx(4556, abs=10) # nearest, in order + + +def test_the_places_tab_uses_the_same_pass(out_and_back): + conn, event_id, _, stops = out_and_back + row = next(p for p in admin.list_pois(conn, event_id) if p["id"] == stops["I"]) + assert row["distance_along_m"] == pytest.approx(2000, abs=10) + + +def test_a_leader_across_a_twice_passed_stop_has_a_pace(out_and_back): + conn, event_id, course, stops = out_and_back + leaders.record_sighting(conn, event_id, course, "male", stops["I"]) + leaders.record_sighting(conn, event_id, course, "male", stops["J"]) + conn.execute( + "UPDATE lead_sighting SET at = CASE poi_id WHEN ? THEN" + " '2026-10-17T13:00:00Z' ELSE '2026-10-17T13:03:00Z' END", (stops["I"],)) + index = progress.CourseIndex.for_event(conn, event_id) + entry = next(l for l in leaders.for_event(conn, event_id, index) + if l.division == "male") + assert entry.last_distance_m == pytest.approx(2500, abs=10) + assert entry.pace_mps == pytest.approx(500 / 180, rel=0.02) + assert entry.next_poi_name == "K"