diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f02a20..8dd6c35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,18 @@ month, PATCH counting releases in that month from 0. Before that they were ## [Unreleased] +### Changed + +- **A stop ticked for a race reaches 500 m from that race's line, not 250 m.** + The Mankato organizer spaced the Full, Half and 10K lines apart on purpose + so each shows on the map, which put water stop A 284 m from the Half's + line: no Half mile, so no pace or ETA for the Half's first leg. It was not + a drawing error and no corrected file exists. A tick is the club saying the + stop is on that route, so `progression` (place miles and lead runner + distances) now lets ticked stops reach `progress.STATED_MAX_OFFSET_M`. A + stop that is only snapped, or ticked for no race, keeps the 250 m limit, so + a stop on a neighbouring road still gets no confident wrong mile. + ## [2026.9.20] - 2026-09-29 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index ddb19aa..dba6f4c 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 # 892 tests, no network +./.venv/Scripts/python.exe -m pytest -q # 896 tests, no network courseops init-db courseops add-event marathon2026 "Spring Marathon 2026" --lat 34.73 --lon -86.58 @@ -1205,6 +1205,7 @@ Rules that keep this honest: Last 10 entries; full record in `CHANGELOG.md`. +- **2026-10-01** A stop ticked for a race reaches 500 m from that race's line (`progress.STATED_MAX_OFFSET_M`), not 250: the organizer spaced the Mankato lines apart on purpose, so stop A is 284 m off the Half. Unticked stops keep 250. - **2026-09-29** Replace from an updated file: Import's assign box can put a staged line INTO an existing race or ONE point INTO an existing place (nearest first); only geometry changes, the line is re-turned to its start. - **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). @@ -1214,4 +1215,3 @@ Last 10 entries; full record in `CHANGELOG.md`. - **2026-09-29** Net info: the net's repeaters and tones as free text on the event form, shown to every role at the top of the panel (on a phone, above the lifted role sections). - **2026-09-27** Pre-event beacon check-in is optional when operators are not local; race morning's Needs attention and a glance per row at net check-in cover it. First real event recorded in `docs/PLAN.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. diff --git a/docs/PLAN.md b/docs/PLAN.md index efdf4ad..1791ee3 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -736,13 +736,17 @@ Things discovered but not yet acted on. Each is a real constraint, not a wish. - **A stop more than 250 m from a race's line has no mile on that race.** (2026-09-29, Mankato.) Water stop A serves the Half, but the organizer's - Half line never comes within 284 m of it - a drawing issue in the file, not - a wrong tick. The Half leader can still be reported at A; there is simply - no pace or ETA for the Half's first leg until E and G are reported. - Deliberately NOT fixed by raising `progress.DEFAULT_MAX_OFFSET_M`: that - limit is what stops a stop on a neighbouring road getting a confident wrong - mile. The fix is a corrected file from the organizer, applied with - **Replace** on the Import tab. There is no line editor in the app. + Half line never comes within 284 m of it. NOT a drawing error: the + organizer (a professional job) deliberately spaced the lines apart so each + race is visible on the map where they share road. So do not ask them for a + "corrected" file - the offset is the design, and the line is a picture of + the route, not the road's centreline. The Half leader can still be reported + at A; there is simply no pace or ETA for the Half's first leg until E and G + are reported. RESOLVED 2026-10-01: a stop ticked for a race (`poi_course`) + reaches `progress.STATED_MAX_OFFSET_M` (500 m) on that race, because the club + has already said it is on that route; everything else keeps + `DEFAULT_MAX_OFFSET_M` (250 m), which is what stops a stop on a neighbouring + road getting a confident wrong mile. The mile is as good as the offset line. - **A loop cannot be given a direction by its start and finish.** Starts at / Finishes at refuse a line whose ends are within 150 m. None of the races so far is a loop; a club with one would need a Reverse control. diff --git a/src/courseops/leaders.py b/src/courseops/leaders.py index ab67a13..456414e 100644 --- a/src/courseops/leaders.py +++ b/src/courseops/leaders.py @@ -406,7 +406,13 @@ def _leader_for( # 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) + # + # A stop the club ticked for this race reaches further than one only + # snapped to it: the organizer's lines are spaced apart on purpose. + ticked = {row["poi_id"] for row in conn.execute( + "SELECT poi_id FROM poi_course WHERE event_id = ? AND course_id = ?", + (event_id, course["id"])).fetchall()} + along = index.progression(course["id"], stations, ticked) distance_by_poi = {} for poi_id, (_, _, _, lat, lon) in known.items(): located = (along[poi_id] if poi_id in along diff --git a/src/courseops/progress.py b/src/courseops/progress.py index 58a25d9..9ea3736 100644 --- a/src/courseops/progress.py +++ b/src/courseops/progress.py @@ -38,6 +38,14 @@ # that should be revisited against a GIS-produced course. DEFAULT_MAX_OFFSET_M = 250.0 +# The reach for a stop the club TICKED for a race (`poi_course`). A tick is +# the club saying the stop is on that route, so the guard against a stop on a +# neighbouring road does not apply. And a line can sit well off the road by +# design: the Mankato organizer spaced the three races apart so each shows on +# the map, which put water stop A 284 m from the Half. The mile is then only +# as good as the line, which is accepted (see "Mile figures inherit..."). +STATED_MAX_OFFSET_M = 500.0 + @dataclass(frozen=True) class CoursePosition: @@ -215,8 +223,8 @@ def _position(self, course: _Course, projection) -> CoursePosition: offset_m=projection.offset_m, ) - def progression(self, course_id: int, - stops: list) -> dict[int, CoursePosition | None]: + def progression(self, course_id: int, stops: list, + stated: set[int] | None = None) -> 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 @@ -231,7 +239,11 @@ def progression(self, course_id: int, 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. + + `stated` names the stops (poi ids) the club ticked for this race; + they reach `STATED_MAX_OFFSET_M`, the rest `max_offset_m`. """ + stated = stated or set() 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} @@ -239,7 +251,10 @@ def progression(self, course_id: int, # "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) + options = [course.line.passes( + (row["lon"], row["lat"]), + max(self.max_offset_m, STATED_MAX_OFFSET_M) + if row["id"] in stated else self.max_offset_m) for row in stops] placeable = [j for j, found in enumerate(options) if found] @@ -293,7 +308,8 @@ def place_positions(self, ordered_rows: list, 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) + per_course[course.id] = self.progression( + course.id, on_it, {row["id"] for row in on_it}) out: dict[int, CoursePosition | None] = {} for row in ordered_rows: found = None diff --git a/tests/test_place_miles.py b/tests/test_place_miles.py index e452e47..1d5077b 100644 --- a/tests/test_place_miles.py +++ b/tests/test_place_miles.py @@ -243,3 +243,52 @@ def test_a_leader_across_a_twice_passed_stop_has_a_pace(out_and_back): 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" + + +# The organizer spaced the Mankato lines apart ON PURPOSE so each race shows +# on the map: stop A sits 284 m from the Half's line. A tick is the club +# saying it is on that route, so a ticked stop gets a wider reach on that +# race; an unticked one keeps the strict limit. +def _place_east(conn, event_id, name, metres_from_south, metres_east, serves, order): + lon = -94.00005 + metres_east / (111_195 * 0.7193) # cos(44.1 deg) + 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, 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 + + +def test_a_ticked_stop_beyond_the_strict_limit_still_gets_its_races_mile(event): + conn, _, event_id, _, half = event + poi = _place_east(conn, event_id, "A", 1000, 284, [half], 1) + pos = _position(conn, event_id, poi) + assert pos.course_name == "Half" + assert pos.distance_along_m == pytest.approx(2000, abs=5) + assert pos.offset_m == pytest.approx(284, abs=10) + + +def test_an_unticked_stop_that_far_still_has_no_mile(event): + conn, _, event_id, _, _ = event + poi = _place_east(conn, event_id, "A", 1000, 284, [], 1) + assert _position(conn, event_id, poi) is None + + +def test_even_a_ticked_stop_has_a_limit(event): + conn, _, event_id, _, half = event + poi = _place_east(conn, event_id, "A", 1000, 900, [half], 1) + assert _position(conn, event_id, poi) is None + + +def test_the_leader_progression_measures_a_ticked_far_stop(event): + conn, _, event_id, _, half = event + near = _place(conn, event_id, "B", 500, [half], 2) + far = _place_east(conn, event_id, "A", 1000, 284, [half], 1) + index = progress.CourseIndex.for_event(conn, event_id) + rows = conn.execute("SELECT * FROM poi WHERE event_id = ? ORDER BY sort_order", + (event_id,)).fetchall() + along = index.progression(half, rows, stated={near, far}) + assert along[far] is not None + assert along[far].distance_along_m < along[near].distance_along_m