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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand All @@ -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.
18 changes: 11 additions & 7 deletions docs/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion src/courseops/leaders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 20 additions & 4 deletions src/courseops/progress.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -231,15 +239,22 @@ 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}
# 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)
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]

Expand Down Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions tests/test_place_miles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading