Repository navigation
course model
What a course is made of, how it is measured and identified, and how a design becomes something a boat
can be handed. How a line is actually crossed is crossing-detection. The Java
records in model/ carry the detail; the files under data/config/clubs/ are working examples.
Everything a club races is in one YAML file per series, data/config/clubs/<club domain>/<series>.yaml.
The path is the identity: clubs/myc.org.au/2026-summer.yaml is club myc.org.au, series
2026-summer. A file is self-contained — its own points, lines, courses and races — so it can be read,
diffed and handed to another club whole.
# positions illustrative, not surveyed
points:
sow-and-pigs: { latitude: -33.8352, longitude: 151.2737 }
lines:
start:
port: { latitude: -33.8013, longitude: 151.2855 }
starboard: { latitude: -33.8013, longitude: 151.2881 }
reef-east:
port: { at: sow-and-pigs }
starboard: { at: reef-east-handle, infinite: true }
courses:
harbour:
sequence:
- { line: start, cross: forward }
- { line: reef-east, cross: forward }
- { line: start, cross: reverse }Positions are signed decimal degrees, WGS84, spelled latitude/longitude as in SignalK, so a
position off a SignalK stream needs no conversion. Degrees and minutes appear only on screen. Ids are
lowercase kebab-case, because they end up in file paths, URLs and YAML keys. An unknown field is ignored,
so a newer file loads on an older build. A problem — an unsurveyed point, a dangling reference — is
reported by the server, one sentence each, and never stops the rest of the file loading.
The editor splices the file rather than rewriting it, so its comments survive; see course-editor.
A line has two ends, each either a named point (at:) or an inline position, and each optionally
infinite. Lines live in their own section, not inside a course, because a club's start line is the
same line in every course that uses it.
A course is an ordered sequence of steps. A step is a line and a sense (cross: forward | reverse),
a rounding of a point, or a gate: a list of alternative lines, any one of which the boat crosses.
The leg into a gate is measured to the midpoint between its alternatives, so a course's length does not
depend on which side a boat took.
A step may round a named point instead of crossing a line: { point: sow-and-pigs, cross: forward }.
Forward is a port rounding (the mark left to port) and reverse a starboard one. Legs are measured
to the mark itself.
On the water a rounding is crossed as a line, which the server resolves when it takes the snapshot:
a half-infinite line from the mark, out of the turn — away from the bisector of the leg in and the leg out
— with its defining point 10 m from the mark (model/Rounding.java). Where the legs run straight past the
mark, it goes square to the leg on the side the boat passes. The mark is the line's port end, so a forward
crossing leaves it to port and the sense needs no translating. The snapshot marks the crossing
point: true, so the boat's overview draws the mark rather than the line, and the boat detects it like any
other line.
A rounding cannot be a start, a finish, a cycle's entry or a side of a gate: those are crossed as themselves. A virtual mark placed short of a real buoy is missed by a boat that rounds the buoy — see open questions 12.
Roles and letters are positional. The first step is the start and the last is the finish; there is no role field. The letters S, 1, 2, … F are derived from position, never stored, so they cannot disagree with the order. A windward/leeward gets S, 2 and F on its leeward line simply because that line appears three times. Neither the first nor the last step may be a gate.
A course marked closed is a cycle: a loop with no start or finish of its own. Steps marked entry
are where a lap may begin, and the line crossed to begin a lap must be crossed again, in the same
sense, to end it. So an entry line is a start and a finish at once, and a lap is bounded by one
crossing made twice. A cycle may have several entry lines; a boat begins at whichever it chooses and
crosses first. A cycle with no entry line is a problem and cannot be published.
A cycle's steps are numbered from 0 rather than lettered S…F, and a leg is named by the step it runs into, so on a cycle step 0 has a leg into it, from the last step back round.
A leg is measured between the midpoints of consecutive lines' two defined points, and a course's
length is the sum of its legs. A leg of zero is an error — two steps sharing a reference point — except
between two steps on the same line, which is the line crossed again after going round an end and counts
as nothing. A step's lengthNm overrides the leg into it.
On an infinite end, the defining point is a handle. Sliding it along the bearing leaves the line geometrically identical, so its position is free — and so it sets where the line is measured to. Place it where the fleet actually crosses and the leg lengths come out right.
Lengths are computed by the server only, so there is one implementation of the rule.
A course can take the handicap out of the time and put it into the distance: everyone starts together, each boat sails a course whose length is in proportion to its TCF, and the first boat home wins.
A line with handicapWidthM is a track. A step naming it does not ask a boat to cross the track;
each boat is given its own line, handicapWidthM wide, square to the track and centred on it,
crossed going out along it. Together the possible lines sweep a rectangle, which is drawn on the course.
The near end of the track — the end that makes the legs either side shortest — is the lowest TCF the
course can take, and the far end the highest.
lines:
windward-track:
port: { latitude: -33.816300, longitude: 151.273680 }
starboard: { latitude: -33.818000, longitude: 151.273680 }
handicapWidthM: 80A TCF becomes a distance linearly. A boat of TCF t sails t/m × D, where D is the course's
nominal length and m is the variant's midpoint TCF (tcfMid, default 1.000) — the boat that sails
the course exactly as drawn.
One slide for the whole course. Every handicapped step is placed at the same fraction u along its track: −1 at the near end, 0 at the midpoint, +1 at the far end. A boat is given the u at which the course length L(u) equals t/m × D. Every leg then runs between two points moving in straight lines, so L is convex in u and a single bisection finds it. This shares the correction between several tracks in proportion to their lengths, and handles a track passed twice, or two handicapped steps in a row, with no special case. The course takes TCFs from m × L(−1)/D to m × L(+1)/D, rounded inward to three places.
The midpoint matters because the range is a proportion of it. A track that stretches the course by ±30% takes 0.700–1.300 about 1.000, which suits most inshore fleets; about 1.400 the same track takes 0.980–1.820, which suits an offshore fleet of 1.8 and 2.0 boats.
A course that cannot be corrected is refused and cannot be published:
| Problem | Why |
|---|---|
| a track as the start, finish or a cycle entry | those are crossed as themselves |
| a track as a side of a gate | a boat's line is one line |
| a track with an infinite end | every line a boat could be given must be visible, and none may be on an island |
a lengthNm on a leg into or out of a handicapped step |
a fixed length cannot stretch |
| legs more than 90° apart at the track | past a right angle the step is a passage, and sliding it lengthens almost nothing |
| a leg that shortens as the track slides out | the track has to run away from both neighbours |
The server says which TCFs a course takes and the boat places its own lines. The snapshot carries
tcfMin, tcfMax and tcfMid, and the server refuses a join without a TCF in that range. The boat then
works out its own lines from the snapshot, with no network, because which line it must cross is part of
deciding its race. The record carries the lines it was given, which also tells the results page that its
elapsed time is already corrected.
Courses are edited live — the windward line gets dragged at nine on race morning — while a record has to name exactly the water a boat sailed. Three nouns keep those apart:
| course | what a club publishes and a boat joins. It has one or more variants and no geometry of its own |
| variant | one editable design belonging to exactly one course: Div 1, the short course, or simply the design |
| snapshot | an unchangeable capture of one variant with every name resolved to coordinates, identified by its revision |
The revision is a hash of what a boat must sail: twelve hex characters over the club, series and course ids, each step's line or point ids and senses, which steps round a point, every end's position (to six decimal places) and infinite flag, the entry steps, whether the course is a cycle, the handicap widths and the midpoint TCF. Not display names or notes — relabelling a mark does not change where a boat had to sail. It is computed from a canonical string rather than serialised JSON, so adding a field to the model cannot silently change every revision.
Snapshots resolve everything immediately. A snapshot that kept names and resolved them later would have a hash that could stop describing its own contents. Which courses a moved mark affects is still answerable, from the variants, which keep their named references.
State is derived, never stored. Hash the resolved variant and compare it with its latest snapshot: unpublished (no snapshot yet), current (they match), dirty (edited since), or incomplete (it will not resolve — a point with no position, a step with no line). No flag is written, so none can go stale.
Snapshot and publish are two steps. A snapshot captures a design; publishing chooses which snapshot boats are handed, and a course with nothing published cannot be joined. So nothing changes on the water until someone publishes, and rolling back is publishing an older snapshot. Publishing replaces the variant's previous publication. Several variants can be published together, atomically: a shared line moved on race morning means all three divisions change at once or none does. The publications are one JSON document per club, written atomically.
public, on the course, decides whether anyone outside the club can see what is published. The
public event log records every publication, withdrawal, and change
of visibility, in the same atomic write as the change itself.
A template is a variant that can never be snapshotted. So it can never be published or joined, and is never dirty. Races are made by cloning a template into a course and placing it for the day.
Snapshots outlive everything. Deleting a variant or a course, or replacing a publication, keeps the snapshots, and a forgotten snapshot is still readable by its revision: a record names a revision, and a result whose course cannot be read is a time with nothing attached.
A point or line is named (an id in the club's list, shared by every course that refers to it) or ad-hoc (belonging to one variant). A line's two ends are independent: one may be a surveyed reef and the other a handle dropped this morning.
Whether an edit lands on one course or many is decided by where it is made. The editor's Points and Lines tabs edit the club's named geometry, deliberately for every course that uses it, and report which variants that made dirty. The Courses tab edits one variant: from it you can never change the geometry of another course without an explicit answer. Dragging a shared point there asks whether to move it for this course's other variants, copy it for this variant only, or promote it to a new named point. Moving or rotating a whole course is the one place the answer can be for every course, because on race morning that is something somebody means.
Chosen when a boat joins, and carried on the record:
RACE |
goes to the club, which scores it |
ANONYMOUS |
practice: kept, but published to nobody. The only mode that may skip marks |
RECORD |
goes to the club and stands against every other attempt at the same revision |
The CourseRecord is the only thing that leaves this system, so it carries everything a scorer or a
protest committee could need: every crossing with its interpolated instant, every rejected fix and its
reason, the revision sailed, the boat's declared TCF and length, and, on a handicapped course, the lines it
was given.
A record names a revision, not just a course, because a course edited between two runs is two courses. It also names the race it was sailed in — the one field the boat is not the authority on. The server stamps it from the boat's session, because being in a race is an entry the committee accepted.
Records are filed by club, course and the club's local date, with the boat and its start time in the file name, so a boat can sail a course twice in a day and a resubmitted record replaces the first.
Results are computed when they are read, never stored: a stored result would be a second copy of the truth, free to disagree with the records.
- A race reads as a finishing order by elapsed time, with corrected time beside it where a boat declared a TCF. A boat that did not finish is listed without a place.
- Record attempts are grouped by variant and then by revision, and ranked within a revision only.
Nothing is scored: places, penalties, discards and protests belong to the club's software. See the results page.