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
62 changes: 48 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ same match points, the program uses the tie-breaks in this order:
1. **Mutual score**: the score of each club in the matches between these clubs only.
2. **Best runners**: the number of groups where the club has the best runner among these clubs. If this is equal, the program counts the second best runners, then the third best runners, and so on.

A division has two or more rounds in a season. The season standing adds
the match points and the scores of all rounds. For clubs with the same
match points, the program uses the same tie-breaks. The tie-breaks use the
matches and the groups of all rounds.

## Check the calculation yourself

You can run the tests on your computer. The tests calculate a real race and
Expand All @@ -73,29 +78,51 @@ compare the result with the official result of that race.
Each division writes a line like this:

```
4 standing lines, 6 matches, 228 grid cells, 169 runner rows agree
4 standing lines, 6 matches, 228 grid cells, 169 runner rows, 4 season lines agree
```

This line tells you that the program and the official result give the
same standing, the same score in each match, the same score in each group
of each match, and the same place and points for each runner.
of each match, the same place and points for each runner, and the same
season standing with the same tie-break values.

### The golden races

Each folder in [testdata](testdata) is one race with its official result:

### The golden race
| Folder | Race | Divisions |
|--------|------|-----------|
| `2026-03-22-1-2-division-ost` | 1. and 2. division Øst, round 1 | 1, 2 |
| `2026-05-10-1-3-division-nord` | 1., 2. and 3. division Nord, round 1 | 1, 2, 3 |
| `2026-05-31-1-2-division-ost` | 1. and 2. division Øst, round 2 | 1, 2 |
| `2026-08-30-2-3-division-nord` | 2. and 3. division Nord, round 2 | 2, 3 |
| `2026-09-06-1-division-nord` | 1. division Nord, round 2 | 1 |
| `2026-10-04-dm-hold` | DM Hold, the final round, 6 clubs | DM Hold |
| `2026-10-04-op-ned-ost` | Op/Ned 3-6. division Øst | 3-4, 4-5, 5-6 |

The test race is "Op/Ned 3-6. division Øst" at Ganløse Ore on 2026-10-04.
Farum OK organised it and published the official result as three PDF files.
The folder [testdata/2026-10-04-op-ned-ost](testdata/2026-10-04-op-ned-ost)
contains these files:
Each folder contains these files:

| File | Content |
|------|---------|
| `results.xml` | The result list of the competition, as IOF XML 3.0. |
| `official/*.txt` | The text of the official PDF files, from `pdftotext -layout`. |
| `race.yml` | The divisions, the clubs in match order, and the standing and match scores, copied by hand from the PDF files. |
| `official/*.txt` | The official result of each division. |
| `race.yml` | The divisions, the clubs in match order, and the standing and match scores, copied by hand from the official result. |

Some official results are PDF files with text. For these races,
`official/*.txt` is the text of the PDF file, from `pdftotext -layout`.
Other official results are scanned PDF files without text. For these
races, `official/*.txt` is a transcription by hand of the first pages: the
season standing, the matches and the score of each group in each match.
These files have no runner rows, and `race.yml` sets `transcribed: true`.

For round 2, `race.yml` names round 1 with `before`. The test then
calculates the season standing of both rounds and compares it with the
standing at the top of the official result.

All runner names in these files are generic names, for example
"Runner 001". The same runner has the same generic name in all files. The
test does not use names. It finds each runner by club, class and time.
"Runner 001". A runner has the same generic name in `results.xml` and in
the official files of the same race. The test does not use names. It finds
each runner by club, class and time.

### Add a race

Expand All @@ -109,9 +136,15 @@ to write Go.
5. Convert each official PDF file to text with `pdftotext -layout`.
6. Replace the runner names in the text files with the same generic names.
7. Save the text files in a folder `official` in the new folder.
8. Copy `race.yml` from the golden race into the new folder.
8. Copy `race.yml` from a golden race into the new folder.
9. Change the divisions, clubs, standing and match scores to the values of the new race.
10. Run `go test ./...`.
10. If the race is round 2 or later, add the earlier rounds with `before`.
11. Run `go test ./...`.

If an official PDF file is a scan without text, write the first pages by
hand in the layout of `pdftotext`. Look at
[testdata/2026-10-04-dm-hold/official](testdata/2026-10-04-dm-hold/official)
for an example. Set `transcribed: true` for that division.

Do not put real runner names in the repository.

Expand Down Expand Up @@ -199,10 +232,11 @@ The public API:
| `Rulebooks()`, `Embedded(version)`, `Latest()` | The rulebooks in the module. |
| `Load(path)`, `Parse(yaml)` | A rulebook from a file or from bytes. |
| `(*Rulebook).Compute(Config, []Runner) Result` | The calculation. |
| `(*Rulebook).Season(name, []DivisionResult) Season` | The season standing of one division over its rounds. Give a club the same name in all rounds. |
| `(*Rulebook).GuessGroup(className)` | The group of a class name, or `""`. |
| `(*Rulebook).Group(name)` | A group and its scale. |
| `Runner`, `Status`, `Config`, `Division` | The input. |
| `Result`, `DivisionResult`, `ClubStanding`, `Placement`, `Match`, `Group`, `GroupRow` | The output. |
| `Result`, `DivisionResult`, `ClubStanding`, `Placement`, `Match`, `Group`, `GroupRow`, `Season`, `SeasonStanding` | The output. |

`Runner.Time` is the finish time in whole seconds. `Status` uses the MeOS
status codes. `GroupRow.Index` is the position of the runner in the slice
Expand Down
6 changes: 6 additions & 0 deletions divisionsmatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,12 @@ func (rb *Rulebook) standing(d Division, matches []Match, groups []Group) []Club
for _, club := range d.Clubs {
rows = append(rows, clubLine(club, slices.Contains(d.Absent, club), matches))
}
return rb.order(rows, matches, groups)
}

// order sorts club lines by match points, then separates the clubs level
// on them with the rulebook's tie-breaks in its order.
func (rb *Rulebook) order(rows []ClubStanding, matches []Match, groups []Group) []ClubStanding {
slices.SortStableFunc(rows, func(a, b ClubStanding) int { return cmp.Compare(b.MatchPoints, a.MatchPoints) })
out := make([]ClubStanding, 0, len(rows))
for i := 0; i < len(rows); {
Expand Down
176 changes: 160 additions & 16 deletions golden_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,20 @@ type goldenDivision struct {
// Standing and Matches are copied by hand from the printout.
Standing []goldenStanding `yaml:"standing"`
Matches [][2]float64 `yaml:"matches"`
// Transcribed is set when the official file is a hand transcription
// of a scanned printout: the summary pages only, without runner rows.
Transcribed bool `yaml:"transcribed"`
// Before is the division's earlier rounds of the season, in order.
// The printout's season standing is checked against them and this
// round together.
Before []goldenRound `yaml:"before"`
}

// goldenRound is a division of another golden race, by its folder and
// the division's name there.
type goldenRound struct {
Race string `yaml:"race"`
Division string `yaml:"division"`
}

// goldenClub is a club as the result list names it and as the official
Expand Down Expand Up @@ -77,6 +91,29 @@ func testGoldenRace(t *testing.T, file string) {
t.Helper()
dir := filepath.Dir(file)
race := readRace(t, file)
rb := rulebook(t)
res := computeRace(t, dir, race, rb)
for i, g := range race.Divisions {
t.Run(filepath.Base(g.Official), func(t *testing.T) {
raw, readErr := os.ReadFile(filepath.Join(dir, g.Official))
if readErr != nil {
t.Fatal(readErr)
}
official := parsePrintout(t, string(raw), g, rb)
checkTranscription(t, g, official)
n := checkDivision(t, g, official, res.Divisions[i], rb)
season := rb.Season(g.Name, append(roundsBefore(t, g, rb), printedNames(res.Divisions[i], g)))
checkSeason(t, official.season, season)
t.Logf("%d standing lines, %d matches, %d grid cells, %d runner rows, %d season lines agree",
len(g.Standing), len(g.Matches), n.cells, n.rows, len(season.Standing))
})
}
}

// computeRace decides every division of a golden race from its result
// list.
func computeRace(t *testing.T, dir string, race goldenRace, rb *dm.Rulebook) dm.Result {
t.Helper()
xmlFile, err := os.Open(filepath.Join(dir, race.Results))
if err != nil {
t.Fatal(err)
Expand All @@ -87,7 +124,6 @@ func testGoldenRace(t *testing.T, file string) {
t.Fatal(err)
}
must(t, doc.ResultList != nil, "results is no ResultList: ", doc.Kind)
runners := runnersOf(doc.ResultList)
cfg := dm.Config{}
for _, g := range race.Divisions {
d := dm.Division{Name: g.Name}
Expand All @@ -96,22 +132,53 @@ func testGoldenRace(t *testing.T, file string) {
}
cfg.Divisions = append(cfg.Divisions, d)
}
rb := rulebook(t)
res := rb.Compute(cfg, runners)
res := rb.Compute(cfg, runnersOf(doc.ResultList))
must(t, len(res.Divisions) == len(race.Divisions))
for i, g := range race.Divisions {
t.Run(filepath.Base(g.Official), func(t *testing.T) {
raw, readErr := os.ReadFile(filepath.Join(dir, g.Official))
if readErr != nil {
t.Fatal(readErr)
return res
}

// roundsBefore computes the division's earlier rounds from their golden
// races, with the clubs under their printed names.
func roundsBefore(t *testing.T, g goldenDivision, rb *dm.Rulebook) []dm.DivisionResult {
t.Helper()
var out []dm.DivisionResult
for _, r := range g.Before {
dir := filepath.Join("testdata", r.Race)
race := readRace(t, filepath.Join(dir, "race.yml"))
i := slices.IndexFunc(race.Divisions, func(d goldenDivision) bool { return d.Name == r.Division })
must(t, i >= 0, "no division ", r.Division, " in ", r.Race)
out = append(out, printedNames(computeRace(t, dir, race, rb).Divisions[i], race.Divisions[i]))
}
return out
}

// printedNames renames the clubs of a decided division to their printed
// names, the names a club has in every round of the season. The result
// list names a club differently from race to race.
func printedNames(d dm.DivisionResult, g goldenDivision) dm.DivisionResult {
names := map[string]string{}
for _, c := range g.Clubs {
names[c.Name] = c.Printed
}
d.Standing = slices.Clone(d.Standing)
for i := range d.Standing {
d.Standing[i].Club = names[d.Standing[i].Club]
}
d.Matches = slices.Clone(d.Matches)
for i := range d.Matches {
d.Matches[i].Home, d.Matches[i].Away = names[d.Matches[i].Home], names[d.Matches[i].Away]
}
d.Groups = slices.Clone(d.Groups)
for i := range d.Groups {
rows := slices.Clone(d.Groups[i].Rows)
for j := range rows {
if n, ok := names[rows[j].Club]; ok {
rows[j].Club = n
}
official := parsePrintout(t, string(raw), g, rb)
checkTranscription(t, g, official)
n := checkDivision(t, g, official, res.Divisions[i], rb)
t.Logf("%d standing lines, %d matches, %d grid cells, %d runner rows agree",
len(g.Standing), len(g.Matches), n.cells, n.rows)
})
}
d.Groups[i].Rows = rows
}
return d
}

// readRace reads a race.yml, with a club's printed name defaulting to its
Expand Down Expand Up @@ -148,6 +215,18 @@ type printout struct {
grid map[string][][2]float64
ialt [][2]float64
rows []printedRow
// season is the standing table at the top, printed club names.
season []seasonLine
}

// seasonLine is a row of the season standing table: the club's score,
// score against and match points per round, the totals, and the
// tie-break comment.
type seasonLine struct {
club string
rounds [][3]float64
total [3]float64
comment string
}

type printedMatch struct {
Expand All @@ -173,15 +252,26 @@ var (
reGroupHeader = regexp.MustCompile(`^(\S+) - \[[\d, ]+\]$`)
rePlaced = regexp.MustCompile(`^(\d+)\s+(.*?)\s(\S+)\s+(\d\d):(\d\d):(\d\d)((?:\s+\d+(?:\.\d)?)*)\s*$`)
reUnplaced = regexp.MustCompile(`^\s+(.*?)\s+(Fejlst|Ej start|Ej slut|Disk|Maxtid)\s*$`)
reSeason = regexp.MustCompile(`^\|\s*\d+\s*\|(.*)\|\s*$`)
reScorePair = regexp.MustCompile(`^(\d+(?:\.\d)?)\s*-\s*(\d+(?:\.\d)?)$`)
// reThisRound starts the block of the round the printout is for; the
// blocks of earlier rounds come before it.
reThisRound = regexp.MustCompile(`^Divisionsmatch \(`)
)

func parsePrintout(t *testing.T, text string, g goldenDivision, rb *dm.Rulebook) printout {
t.Helper()
p := printout{grid: map[string][][2]float64{}}
inStanding, group := false, ""
inStanding, group, thisRound := false, "", false
for line := range strings.SplitSeq(text, "\n") {
line = strings.TrimLeft(line, "\f")
switch {
case reThisRound.MatchString(line):
thisRound = true
case !thisRound && reSeason.MatchString(line):
p.season = append(p.season, parseSeasonLine(t, reSeason.FindStringSubmatch(line)[1]))
case !thisRound:
// An earlier round's block: the season test covers it.
case reStandingHeader.MatchString(line):
inStanding = true
case inStanding && strings.TrimSpace(line) == "":
Expand Down Expand Up @@ -228,6 +318,58 @@ func parsePrintout(t *testing.T, text string, g goldenDivision, rb *dm.Rulebook)
return p
}

// parseSeasonLine reads the cells of a season standing row after the
// place: the club, a score pair and match points per round, the same for
// the totals, and the comment.
func parseSeasonLine(t *testing.T, cells string) seasonLine {
t.Helper()
parts := strings.Split(cells, "|")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
must(t, len(parts) >= 6 && len(parts)%2 == 0, "season row: ", cells)
line := seasonLine{club: parts[0], comment: parts[len(parts)-1]}
var pairs [][3]float64
for i := 1; i+1 < len(parts); i += 2 {
m := reScorePair.FindStringSubmatch(parts[i])
must(t, m != nil, "season score: ", parts[i])
pairs = append(pairs, [3]float64{num(t, m[1]), num(t, m[2]), num(t, parts[i+1])})
}
line.rounds, line.total = pairs[:len(pairs)-1], pairs[len(pairs)-1]
return line
}

// checkSeason compares the season standing with the printout's table:
// order, every round's numbers, the totals, and the tie-break comment.
func checkSeason(t *testing.T, want []seasonLine, got dm.Season) {
t.Helper()
must(t, len(want) == len(got.Standing), "season lines")
for i, w := range want {
g := got.Standing[i]
equal(t, w.club, g.Club, "season place ", i+1)
equal(t, len(w.rounds), len(g.Rounds), "rounds of ", w.club)
for r, wr := range w.rounds {
gr := g.Rounds[r]
equal(t, wr, [3]float64{gr.ScoreFor, gr.ScoreAgainst, float64(gr.MatchPoints)}, w.club, " round ", r+1)
}
equal(t, w.total, [3]float64{g.ScoreFor, g.ScoreAgainst, float64(g.MatchPoints)}, w.club, " total")
equal(t, w.comment, seasonComment(g.ClubStanding), "comment of ", w.club)
}
}

// seasonComment is the printout's comment for a tie-break: the mutual
// score in brackets, then the counts of best placements, "(152), 16 1. pl".
func seasonComment(s dm.ClubStanding) string {
var parts []string
if s.Tied {
parts = append(parts, "("+strconv.FormatFloat(s.TiedScore, 'f', -1, 64)+")")
}
for _, p := range s.Placements {
parts = append(parts, fmt.Sprintf("%d %d. pl", p.Count, p.Place))
}
return strings.Join(parts, ", ")
}

// checkTranscription holds the hand-transcribed numbers against the
// printout they were transcribed from.
func checkTranscription(t *testing.T, g goldenDivision, p printout) {
Expand Down Expand Up @@ -280,7 +422,9 @@ func checkDivision(t *testing.T, g goldenDivision, p printout, d dm.DivisionResu
equal(t, p.ialt[mi], [2]float64{m.HomeScore, m.AwayScore}, "Ialt m", mi+1)
n.cells++
}
n.rows = checkRows(t, p, d)
if !g.Transcribed {
n.rows = checkRows(t, p, d)
}
return n
}

Expand Down
Loading
Loading