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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 12 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 # 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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 "<event> | Course Ops" and setup "<event> | 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.
8 changes: 8 additions & 0 deletions docs/RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 8 additions & 2 deletions src/courseops/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions src/courseops/geo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
10 changes: 9 additions & 1 deletion src/courseops/guides/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 24 additions & 9 deletions src/courseops/leaders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"],
Expand All @@ -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
]
Expand Down
Loading
Loading