teecup/handicap_engine.py
Erol Haagenrud 9b7ff8e2ef Eclectic-format for org-individuelle turneringer (Del C) + tre UI-rettelser
Del C (ADR-068, migrasjon 072, siste del av den tredelte utvidelsen som
startet med Flaggturnering GPS/kart, se ADR-066/067): nytt format
eclectic_gross/eclectic_net/eclectic_stableford -- beste resultat per
hull på tvers av en turnerings egne runder, krever samme bane (avvist
tydelig ved rundeopprettelse ellers). Regnes ut ved lesing, ingen nye
tabeller. Bevisst avvik fra opprinnelig plan: integrert som en ny gren
i eksisterende individual-leaderboard-endepunkt fremfor et nytt eget
endepunkt -- se ADR-068 for begrunnelsen.

Tre ikke-relaterte, brukerrapporterte UI-rettelser tatt med i samme
runde: avstandsindikatoren brukte "grønn"/"Midt" i stedet for riktige
golf-uttrykk "green"/"senter", og "Oppdateres live"-badgen fjernet.
"Antall hull"-bryteren i Ny runde-veiviseren fikk samme grønne
aksent-valgt-stil som resten av samme skjerm (delt Segmented-primitiv).

Se ARCHITECTURE_DECISIONS.md (ADR-068) og CHANGELOG.md (punkt 84) for
full begrunnelse og verifiseringslogg.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 11:12:25 +02:00

1120 lines
47 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
TeeCup handicap-motor
=====================
Frittstående regel- og matematikk-motor for golfhandicap (ADR-005).
Ingen avhengigheter til database, API eller web-rammeverk — kun standardbibliotek.
Designprinsipp (ADR-005):
"Allowances" (prosenttildelinger per format) er KONFIGURASJON, ikke hardkodet
logikk. Motoren vet ikke selv at four-ball = 90 %; den mottar en
AllowanceStrategy. Standardverdiene under følger R&A Appendix C (2026), men
kan overstyres per turnering — f.eks. 75 %, 3/4, eller lokale varianter.
Kilder for standardverdier: R&A Rules of Handicapping, Appendix C.
- Course Handicap = Index × Slope/113 + (Course Rating Par)
- Allowance påføres UAVRUNDET Course Handicap; resultatet avrundes til
Playing Handicap.
- Match play: laveste enhet spiller av 0, øvrige mottar differansen.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, timedelta
from decimal import Decimal, ROUND_FLOOR
from enum import Enum
from typing import Literal, Protocol, Sequence
# ---------------------------------------------------------------------------
# Grunnleggende matematikk
# ---------------------------------------------------------------------------
def round_half_up(value: float) -> int:
"""Avrund til nærmeste heltall, 0,5 alltid opp mot mer positivt tall.
Pythons innebygde round() bruker "banker's rounding" (0,5 -> nærmeste
partall), noe som gir feil handicap. WHS bruker vanlig 0,5-opp.
Merk om minus-handicap (plusspillere): "opp" tolkes her som mot +uendelig,
slik at -2,5 -> -2 (ikke -3). Dette er en sjelden kant og regelverket er
ikke entydig; endre bare denne funksjonen dersom en autoritativ kilde
tilsier noe annet. Bruk Decimal for å unngå flyttallsfeil.
"""
d = Decimal(str(value))
floor_part = d.to_integral_value(rounding=ROUND_FLOOR)
frac = d - floor_part
if frac >= Decimal("0.5"):
return int(floor_part) + 1
return int(floor_part)
def course_handicap_raw(
handicap_index: float,
slope_rating: float,
course_rating: float,
par: int,
) -> float:
"""Uavrundet Course Handicap.
Index × Slope/113 + (Course Rating Par). Beholdes uavrundet fordi
allowancen skal påføres den uavrundede verdien (Appendix C).
"""
return handicap_index * (slope_rating / 113.0) + (course_rating - par)
def course_handicap(
handicap_index: float,
slope_rating: float,
course_rating: float,
par: int,
) -> int:
"""Avrundet Course Handicap (til referanse/visning)."""
return round_half_up(course_handicap_raw(handicap_index, slope_rating, course_rating, par))
def round_half_up_decimal(value: float, ndigits: int) -> float:
"""Som `round_half_up`, men til `ndigits` desimaler i stedet for heltall.
Brukt for Score Differential og Handicap Index, som begge rundes til
nærmeste tidel med ,5 alltid oppover mot mer positivt tall — inkludert
for negative verdier (Rule 5.1c: -1,54 -> -1,5, -1,55 -> -1,5,
-1,56 -> -1,6 — verifisert mot alle tre eksemplene i regelboken).
"""
quant = Decimal(1).scaleb(-ndigits)
scaled = Decimal(str(value)) / quant
floor_part = scaled.to_integral_value(rounding=ROUND_FLOOR)
frac = scaled - floor_part
rounded_scaled = floor_part + 1 if frac >= Decimal("0.5") else floor_part
return float(rounded_scaled * quant)
# ---------------------------------------------------------------------------
# HCP-indeksberegning fra spilte runder (ADR-033, frittstående rundeføring)
# ---------------------------------------------------------------------------
#
# Kilde: WHS Rules of Handicapping, effektiv januar 2024 (USGA/R&A) — lastet
# opp av bruker 2026-07-22, lest i sin helhet. Alle formler/tall under er
# hentet direkte derfra, ikke antatt. Se ADR-033 for full sporing av hvert
# valg, inkl. hvor kilden var taus (Expected Score, PCC — se avvik under).
#
# Bevisst, kildebelagt avvik fra WHS: "Expected Score" for uspilte hull
# (Rule 3.2b) er eksplisitt beskrevet som automatisk beregnet av sertifisert
# WHS-programvare UTEN publisert formel. Etter eksplisitt instruks fra
# brukeren brukes WHS sin egen, presist DEFINERTE "Net Par"-term i stedet
# (Rule 3.2b/2 — normalt reservert for spesielle godkjente tilfeller, her
# vedtatt som TeeCups generelle policy). Konsekvens: en 9-hulls-runde
# behandles som en 18-hulls-runde med 9 Net-Par-fylte hull, gjennom SAMME
# formel som en ufullstendig 18-hulls-runde — Rule 5.1b sin egen separate
# 9-hulls-differensial-formel er derfor IKKE implementert, bevisst.
#
# Playing Conditions Calculation (Rule 5.6) og Exceptional Score-reduksjon
# (Rule 5.9) er begge forstått, men bevisst UTENFOR omfang i v1 (se ADR-033)
# — ikke implementert her.
def net_par(par: int, strokes_received: int) -> int:
"""Net Par (Rule 3.2b/2) — par + mottatte handicapslag på hullet.
TeeCups stand-in for et uspilt hull der WHS sin egen "Expected Score"
ikke har en publisert formel (se moduldoc over). Tilsvarer 2
Stableford-poeng.
"""
return par + strokes_received
def max_hole_score_for_handicap(
par: int,
strokes_received: int,
*,
index_established: bool = True,
course_handicap: int | None = None,
) -> int:
"""Maks hull-score for HCP-formål (Rule 3.1).
Med etablert indeks: Net Double Bogey = par + 2 + mottatte handicapslag
(Rule 3.1b). Før en indeks er etablert i det hele tatt (spillerens aller
første score(r)): par + 5 (Rule 3.1a) — enklere cap fordi ingen
handicapslag ennå er kjent å fordele.
Unntak (Rule 3.1b, verifisert direkte mot WHS Rules of Handicapping
2024, side 37 -- ikke bare koden sin egen gjenfortelling av regelen):
"Where a Course Handicap is calculated at more than 54 and a player
receives 4 or more strokes on a hole, the maximum hole score is par + 5
for handicap purposes." Overstyrer den vanlige par+2+slag-cappen for
akkurat denne kombinasjonen (høyt banehandicap + mange slag på ett
hull) -- uten `course_handicap` sendt inn anvendes IKKE unntaket
(bakoverkompatibelt, samme oppførsel som før for alle kall som ikke
oppgir det).
"""
if not index_established:
return par + 5
if course_handicap is not None and course_handicap > 54 and strokes_received >= 4:
return par + 5
return par + 2 + strokes_received
def adjusted_gross_score(
hole_scores: Sequence[int | None],
pars: Sequence[int],
strokes_received: Sequence[int],
*,
index_established: bool = True,
course_handicap: int | None = None,
) -> int:
"""18-hulls Adjusted Gross Score (Rule 3), grunnlaget for Score Differential.
- `hole_scores[i]` = spilt bruttoscore på hull i, eller `None` for et
uspilt hull (fylles med Net Par, se moduldoc).
- Spilte hull capped til `max_hole_score_for_handicap` -- `course_handicap`
videreført dit uendret, for Rule 3.1b sitt >54/4+-slag-unntak (se der).
- Forventer nøyaktig 18 hull i alle tre lister (bruk `None` for uspilte,
ikke kortere lister) — en 9-hulls-runde sendes inn som 18 elementer der
9 av dem er `None`.
"""
n = len(pars)
if n != 18:
raise ValueError(f"adjusted_gross_score forventer 18 hull, fikk {n}")
if len(hole_scores) != n or len(strokes_received) != n:
raise ValueError("hole_scores og strokes_received må ha samme lengde som pars (18)")
total = 0
for score, par, strokes in zip(hole_scores, pars, strokes_received):
if score is None:
total += net_par(par, strokes)
else:
cap = max_hole_score_for_handicap(
par, strokes, index_established=index_established, course_handicap=course_handicap
)
total += min(score, cap)
return total
def score_differential(
adjusted_gross_score_value: float,
course_rating: float,
slope_rating: float,
pcc_adjustment: float = 0.0,
) -> float:
"""Score Differential for en (18-hulls-ekvivalent) runde (Rule 5.1a).
(113 / Slope Rating) x (Adjusted Gross Score - Course Rating - PCC).
`pcc_adjustment` er 0,0 som default siden PCC ikke er implementert i v1
(se moduldoc) — kalleren kan sende inn en verdi hvis/når PCC bygges
senere uten at denne funksjonen må endres.
"""
raw = (113.0 / slope_rating) * (adjusted_gross_score_value - course_rating - pcc_adjustment)
return round_half_up_decimal(raw, 1)
# Rule 5.2a — antall Score Differentials som brukes og justering, for en
# scoring-record med FÆRRE enn 20 differensialer. Nøkkel = antall
# differensialer i historikken.
_INDEX_TABLE_UNDER_20: dict[int, tuple[int, float]] = {
3: (1, -2.0),
4: (1, -1.0),
5: (1, 0.0),
6: (2, -1.0),
7: (2, 0.0),
8: (2, 0.0),
9: (3, 0.0),
10: (3, 0.0),
11: (3, 0.0),
12: (4, 0.0),
13: (4, 0.0),
14: (4, 0.0),
15: (5, 0.0),
16: (5, 0.0),
17: (6, 0.0),
18: (6, 0.0),
19: (7, 0.0),
}
_INDEX_TABLE_20_OR_MORE: tuple[int, float] = (8, 0.0) # Rule 5.2b
def handicap_index_from_differentials(differentials: Sequence[float]) -> float | None:
"""Handicap Index fra en scoring-record sine Score Differentials (Rule 5.2).
`differentials` skal være de N NYESTE differensialene (Rule 5.5 —
ageing/lapsing er kallerens ansvar å trimme til før denne kalles; denne
funksjonen bruker aldri mer enn de 20 siste selv om flere sendes inn).
Returnerer `None` hvis færre enn 3 — ingen indeks kan etableres ennå
(implisitt nedre grense i Rule 5.2a sin tabell).
Bygger IKKE inn soft/hard cap (Rule 5.8) — det krever Low Handicap
Index-historikk (se `low_handicap_index`) og gjøres separat med
`apply_index_caps`, av en kaller som har tilgang til den historikken.
"""
trimmed = list(differentials)[-20:]
n = len(trimmed)
if n < 3:
return None
count, adjustment = _INDEX_TABLE_UNDER_20.get(n, _INDEX_TABLE_20_OR_MORE)
lowest = sorted(trimmed)[:count]
avg = sum(lowest) / count
return round_half_up_decimal(avg + adjustment, 1)
def low_handicap_index(index_history: Sequence[tuple[date, float]], as_of: date) -> float | None:
"""Low Handicap Index (Rule 5.7) — laveste indeks i de 365 dagene FØR og
MED `as_of` (typisk datoen siste runde i scoring-record ble spilt).
`index_history` er (dato, indeks)-par for hver historisk indeks-verdi —
en kaller-eid historikk (f.eks. en database-tabell), ikke noe denne
rene motoren selv holder styr på. Returnerer `None` hvis historikken er
tom i vinduet (f.eks. en helt fersk spiller).
"""
cutoff = as_of - timedelta(days=365)
eligible = [idx for d, idx in index_history if cutoff <= d <= as_of]
if not eligible:
return None
return min(eligible)
def apply_index_caps(new_index: float, low_handicap_index_value: float) -> float:
"""Soft cap / hard cap på OPPADGÅENDE bevegelse (Rule 5.8).
- Soft cap: økning over 3,0 slag over Low Handicap Index halveres (kun
den DELEN som overstiger 3,0, ikke hele økningen).
- Hard cap: total økning kan uansett aldri overstige 5,0 slag over Low
Handicap Index.
- Ingen nedre grense — indeksen kan alltid synke fritt.
"""
increase = new_index - low_handicap_index_value
if increase <= 3.0:
return round_half_up_decimal(new_index, 1)
capped_increase = min(3.0 + (increase - 3.0) * 0.5, 5.0)
return round_half_up_decimal(low_handicap_index_value + capped_increase, 1)
def course_handicap_9_raw(
handicap_index: float,
slope_rating_9: float,
course_rating_9: float,
par_9: int,
) -> float:
"""9-hulls Course Handicap, uavrundet (Rule 6.1b).
AVVIKER fra 18-hulls-formelen (`course_handicap_raw`): indeksen HALVERES
først (avrundet til nærmeste tidel), FØR den ganges med 9-hulls
slope/113. Gjelder frittstående 9-hulls-RUNDER (ADR-033) — IKKE
turnering-øktenes front_9/back_9-oppsett i `app/handicap.py`, som
bevisst bruker full_18-rating av en helt annen grunn (slagfordeling
internt i en turneringsmatch, se 2026-07-19-fiksen i CLAUDE.md-status).
De to må ikke forveksles eller slås sammen uten en egen vurdering.
"""
half_index = round_half_up_decimal(handicap_index / 2.0, 1)
return half_index * (slope_rating_9 / 113.0) + (course_rating_9 - par_9)
def course_handicap_9(
handicap_index: float,
slope_rating_9: float,
course_rating_9: float,
par_9: int,
) -> int:
"""Avrundet 9-hulls Course Handicap (Rule 6.1b)."""
return round_half_up(course_handicap_9_raw(handicap_index, slope_rating_9, course_rating_9, par_9))
def round_counts_for_handicap(played_holes_count: int, holes_planned: int) -> bool:
"""Er en runde HCP-tellende ut fra hvor mange av 18 hull som ble spilt?
Kilde: Rule 2.2. To distinkte terskler avhengig av spillerens ERKLÆRTE
INTENSJON (`holes_planned` — 9 eller 18, ADR-033 Beslutning E):
- Intensjon 18 hull (Rule 2.2a): minst 10 av 18 må være spilt (resten
fylles med Net Par, se `adjusted_gross_score`-moduldocen).
- Intensjon 9 hull (Rule 2.2b): ALLE 9 må være spilt — ingen "minst 9",
færre enn 9 gjør scoren helt ugyldig for HCP-formål.
Merk: Rule 2.2b sitt krav om at de 9 hullene må tilhøre et faktisk
RATET 9-hulls-sett (front/back) er ikke lenger relevant her — TeeCups
Net-Par-tilnærming (Beslutning G) bruker alltid banens fulle 18-hulls
rating, uansett hvilke konkrete hull som ble spilt.
"""
if holes_planned == 9:
return played_holes_count == 9
return played_holes_count >= 10
# ---------------------------------------------------------------------------
# Allowance-strategier (konfigurasjon per format)
# ---------------------------------------------------------------------------
#
# En strategi tar en liste av UAVRUNDEDE course handicaps for de spillerne som
# hører til én "handicap-bærende enhet" og returnerer den enhetens Playing
# Handicap (avrundet).
#
# - Singles/four-ball: hver spiller er sin egen enhet -> kall strategien én
# gang per spiller (liste med ett element).
# - Foursomes/greensomes/scramble: hele laget er én enhet -> kall strategien
# med lagets spillere.
class AllowanceStrategy(Protocol):
def playing_handicap(self, course_handicaps: Sequence[float]) -> int:
"""Returner avrundet Playing Handicap for enheten."""
...
@dataclass(frozen=True)
class PerPlayerPercentage:
"""Én prosent av hver enkelt spillers course handicap.
Singles match play = 100 %, four-ball match play = 90 %.
Forventer nøyaktig én spiller per kall (enheten er individet).
"""
percentage: float
def playing_handicap(self, course_handicaps: Sequence[float]) -> int:
if len(course_handicaps) != 1:
raise ValueError("PerPlayerPercentage forventer nøyaktig én spiller per enhet")
return round_half_up(self.percentage * course_handicaps[0])
@dataclass(frozen=True)
class CombinedPercentage:
"""Prosent av lagets SAMLEDE course handicap. Foursomes = 50 %."""
percentage: float
def playing_handicap(self, course_handicaps: Sequence[float]) -> int:
return round_half_up(self.percentage * sum(course_handicaps))
@dataclass(frozen=True)
class WeightedLowHigh:
"""Vektet lav/høy. Greensomes = 60 % laveste + 40 % høyeste."""
low_weight: float
high_weight: float
def playing_handicap(self, course_handicaps: Sequence[float]) -> int:
if len(course_handicaps) != 2:
raise ValueError("WeightedLowHigh forventer nøyaktig to spillere")
low, high = sorted(course_handicaps)
return round_half_up(self.low_weight * low + self.high_weight * high)
@dataclass(frozen=True)
class RankedSplit:
"""Rangert splitt fra laveste til høyeste handicap.
Scramble (4 spillere) = 25/20/15/10. Scramble (2) = 35/15.
Antall vekter må matche antall spillere i laget.
"""
weights: tuple[float, ...]
def playing_handicap(self, course_handicaps: Sequence[float]) -> int:
if len(course_handicaps) != len(self.weights):
raise ValueError(
f"RankedSplit forventer {len(self.weights)} spillere, fikk {len(course_handicaps)}"
)
ordered = sorted(course_handicaps) # laveste først
total = sum(w * ch for w, ch in zip(self.weights, ordered))
return round_half_up(total)
@dataclass(frozen=True)
class TeamAverage:
"""Enkelt gjennomsnitt av ALLE lagmedlemmers course handicap, avrundet.
Brukt for `scramble_solo` (scramble-lag mot enkeltspiller, variabel
lagstørrelse N>=2) -- bevisst UAVHENGIG av RankedSplit (scramble_2/
scramble_4 sine faste 35/15-/25/20/15/10-tabeller), som forutsetter
FAST lagstørrelse og ikke er kildebelagt for vilkårlig N. Ingen
rangering/vekting her, rent snitt -- bekreftet med bruker 2026-08-05
som en bevisst ny, enkel TeeCup-regel, ikke hentet fra HCP-kildene.
"""
def playing_handicap(self, course_handicaps: Sequence[float]) -> int:
if len(course_handicaps) < 2:
raise ValueError("TeamAverage forventer minst to spillere")
return round_half_up(sum(course_handicaps) / len(course_handicaps))
# ---------------------------------------------------------------------------
# Format-register (STANDARDVERDIER — kan overstyres per turnering)
# ---------------------------------------------------------------------------
class Format(str, Enum):
SINGLES = "singles"
FOURBALL = "fourball"
FOURSOME = "foursome"
GREENSOME = "greensome"
SCRAMBLE_2 = "scramble_2"
SCRAMBLE_4 = "scramble_4"
# Standard match-play-allowances per R&A Appendix C (2026).
# VIKTIG: Dette er defaults. En turnering kan levere sitt eget register
# (f.eks. four-ball 85 %, eller lokale satser) uten å endre motoren.
DEFAULT_MATCHPLAY_ALLOWANCES: dict[Format, AllowanceStrategy] = {
Format.SINGLES: PerPlayerPercentage(1.00),
Format.FOURBALL: PerPlayerPercentage(0.90),
Format.FOURSOME: CombinedPercentage(0.50),
Format.GREENSOME: WeightedLowHigh(0.60, 0.40),
Format.SCRAMBLE_2: RankedSplit((0.35, 0.15)),
Format.SCRAMBLE_4: RankedSplit((0.25, 0.20, 0.15, 0.10)),
}
# ---------------------------------------------------------------------------
# Match play: relativ slagtildeling
# ---------------------------------------------------------------------------
def match_play_strokes(playing_handicaps: Sequence[int]) -> list[int]:
"""Gjør absolutte playing handicaps om til relative slag i match play.
Laveste enhet spiller av 0; øvrige mottar differansen fra laveste.
Rekkefølgen i output samsvarer med input.
"""
if not playing_handicaps:
return []
lowest = min(playing_handicaps)
return [ph - lowest for ph in playing_handicaps]
# ---------------------------------------------------------------------------
# Slagfordeling på hull (Stroke Index)
# ---------------------------------------------------------------------------
def allocate_strokes_by_index(total_strokes: int, stroke_indexes: Sequence[int]) -> list[int]:
"""Fordel `total_strokes` mottatte slag på hull etter deres Stroke Index.
- stroke_indexes: liste med SI per hull (typisk 1..18, hardeste = 1).
- Håndterer total > antall hull: da får hvert hull et grunnslag, og de
laveste SI-hullene får et ekstra (spiller med 20 slag på 18 hull får 1
slag overalt + 1 ekstra på SI 1 og 2 -> 2 slag der).
- Håndterer minus-handicap (total < 0): spilleren GIR slag tilbake,
med start på de letteste hullene (høyest SI).
Returnerer liste med slag per hull i samme rekkefølge som stroke_indexes.
"""
n = len(stroke_indexes)
if n == 0:
return []
sign = 1 if total_strokes >= 0 else -1
magnitude = abs(total_strokes)
base, extra = divmod(magnitude, n)
# Ved positive slag går ekstraslag til laveste SI (hardeste hull).
# Ved negative (gir tilbake) går de til høyeste SI (letteste hull).
if sign >= 0:
gets_extra = {si for si in stroke_indexes if si <= extra}
else:
# de `extra` høyeste SI-verdiene
threshold = n - extra
gets_extra = {si for si in stroke_indexes if si > threshold}
result = []
for si in stroke_indexes:
strokes = base + (1 if si in gets_extra else 0)
result.append(sign * strokes)
return result
def allocate_over_played_holes(
total_strokes: int,
all_18_stroke_indexes: Sequence[int],
played_hole_numbers: Sequence[int],
) -> list[int]:
"""Slagfordeling når bare et delsett av hullene spilles (f.eks. front/back 9).
VIKTIG: Fordel ALLTID over hele 18-hulls stroke index, og ta så ut de hullene
som faktisk spilles. Å sende bare de spilte hullene inn i
`allocate_strokes_by_index` gir feil ved høye slagtall, fordi den da ville
fordelt slagene som om totalen gjaldt kun de ni hullene (grunnslag på hvert
hull). Eksempel: 12 slag, back-9 -> riktig 6 slag, naivt 10.
- all_18_stroke_indexes: SI for hull 1..18 i hullrekkefølge (indeks 0 = hull 1).
- played_hole_numbers: hullnumrene som spilles (f.eks. [10..18] for back-9).
Returnerer slag per spilt hull, i samme rekkefølge som played_hole_numbers.
"""
full = allocate_strokes_by_index(total_strokes, all_18_stroke_indexes)
return [full[hole - 1] for hole in played_hole_numbers]
# ---------------------------------------------------------------------------
# Individuelle scoringsmetoder (ADR-037): bruttoslagspill/nettoslagspill/
# Stableford for et flatt felt av spillere, ingen motpart/side involvert.
# `strokes_received` her kommer ALLTID fra allocate_strokes_by_index/
# allocate_over_played_holes over -- disse funksjonene fordeler ingen slag
# selv, de bare summerer et allerede fordelt resultat.
# ---------------------------------------------------------------------------
def stroke_play_gross_total(gross_strokes: Sequence[int]) -> int:
"""Sum av rå bruttoslag over de spilte hullene. Ingen handicap involvert."""
return sum(gross_strokes)
def stroke_play_net_total(gross_strokes: Sequence[int], strokes_received: Sequence[int]) -> int:
"""Sum av netto slag (brutto minus mottatte slag) over de spilte hullene."""
return sum(g - s for g, s in zip(gross_strokes, strokes_received))
def stableford_points_for_hole(par: int, gross_strokes: int, strokes_received: int) -> int:
"""Stableford-poeng for ETT hull: 2 poeng for netto par, +/-1 poeng per
slag avvik, gulvet på 0 (netto dobbel bogey eller dårligere gir 0 poeng).
"""
net = gross_strokes - strokes_received
return max(0, par - net + 2)
def stableford_total(pars: Sequence[int], gross_strokes: Sequence[int], strokes_received: Sequence[int]) -> int:
"""Sum av Stableford-poeng over de spilte hullene."""
return sum(
stableford_points_for_hole(par, g, s)
for par, g, s in zip(pars, gross_strokes, strokes_received)
)
# ---------------------------------------------------------------------------
# To-sidet netto-slagspill-sammenligning (scramble_solo): "laveste totalsum
# vinner", INGEN hull-for-hull-tilstand -- eksplisitt SØSKEN til, ikke del
# av, match-status-seksjonen under (som er for ekte match play). Hver sides
# nettosum beregnes med de allerede eksisterende allocate_strokes_by_index/
# stroke_play_net_total over -- ingen ny funksjon trengs for selve summen.
# ---------------------------------------------------------------------------
def net_stroke_play_margin(net_total_a: int, net_total_b: int) -> int:
"""Positiv = A leder (A har LAVEST nettosum), negativ = B leder, 0 = delt."""
return net_total_b - net_total_a
# ---------------------------------------------------------------------------
# Match-status ("2 UP", "dormie", "3&2", "AS")
# ---------------------------------------------------------------------------
class HoleResult(int, Enum):
SIDE_A = 1 # side A vant hullet
HALVED = 0 # delt
SIDE_B = -1 # side B vant hullet
@dataclass(frozen=True)
class MatchState:
lead: int # positiv = A leder, negativ = B leder, 0 = all square
holes_played: int
holes_remaining: int
is_closed: bool # matchen er avgjort (ledelse > gjenstående hull)
is_dormie: bool # ledelse == gjenstående hull (motstander kan ikke vinne)
def describe(self) -> str:
"""Kort tekstlig status, f.eks. '2 UP (A)', 'AS', '3&2 (A)', 'dormie 2 (B)'."""
if self.is_closed:
# Avgjort: "X&Y" der Y = hull som gjensto da matchen ble vunnet.
winner = "A" if self.lead > 0 else "B"
margin = abs(self.lead)
if self.holes_remaining == 0 and margin > 0:
# Vunnet på siste hull
return f"{margin} UP ({winner})"
return f"{margin}&{self.holes_remaining} ({winner})"
if self.lead == 0:
return "AS"
leader = "A" if self.lead > 0 else "B"
status = f"{abs(self.lead)} UP ({leader})"
if self.is_dormie:
status = f"dormie {abs(self.lead)} ({leader})"
return status
def compute_match_state(
hole_results: Sequence[HoleResult],
total_holes: int = 18,
) -> MatchState:
"""Beregn løpende match play-status fra hull-for-hull-resultater.
hole_results: resultatene så langt (kan være færre enn total_holes).
total_holes: antall hull i matchen (18 som standard, men støtter 9 osv.).
"""
lead = sum(int(r) for r in hole_results)
holes_played = len(hole_results)
holes_remaining = total_holes - holes_played
is_closed = abs(lead) > holes_remaining
is_dormie = (not is_closed) and holes_remaining > 0 and abs(lead) == holes_remaining
return MatchState(
lead=lead,
holes_played=holes_played,
holes_remaining=holes_remaining,
is_closed=is_closed,
is_dormie=is_dormie,
)
# ---------------------------------------------------------------------------
# Bekvemmelighets-API: fra rådata til relative slag for en match
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class Player:
name: str
handicap_index: float
slope_rating: float
course_rating: float
par: int
def course_handicap_raw(self) -> float:
return course_handicap_raw(
self.handicap_index, self.slope_rating, self.course_rating, self.par
)
def unit_playing_handicap(players: Sequence[Player], strategy: AllowanceStrategy) -> int:
"""Playing Handicap for én enhet (spiller eller lag) gitt en strategi."""
return strategy.playing_handicap([p.course_handicap_raw() for p in players])
# ---------------------------------------------------------------------------
# Skins (ADR-039 Beslutning D) -- ikke et WHS/R&A-regulert format, ingen
# tilsvarende motorstøtte fantes fra før (ulikt match play over).
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class SkinsHoleLog:
"""Ett hulls bidrag til skins-oppgjøret -- brukt til å vise SELVE
hull-for-hull-forløpet (ikke bare sluttsummen), se compute_skins_detail."""
values: dict[str, int] # deltaker_id -> score (netto ELLER brutto, avhengig av kalleren)
pot_before: float # potten FØR dette hullets egen andel legges til
awarded: dict[str, float] # deltaker_id -> andel vunnet PÅ DETTE hullet (tom hvis carried)
carried: bool # sann kun ved uavgjort + tie_handling="carry" (potten ruller videre)
def compute_skins_detail(
scores_by_hole: Sequence[Sequence[tuple[str, int]]],
tie_handling: Literal["carry", "split"] = "carry",
) -> tuple[dict[str, float], list[SkinsHoleLog]]:
"""Samme beregning som compute_skins, men returnerer OGSÅ et hull-for-hull-
forløp (verdier/pott/vinner per hull) -- til bruk i en hull-for-hull-
visning der spilleren skal se HVORFOR potten endte som den gjorde, ikke
bare den ferdige totalen."""
winnings: dict[str, float] = {}
pot = 0.0
log: list[SkinsHoleLog] = []
for hole_scores in scores_by_hole:
if not hole_scores:
log.append(SkinsHoleLog(values={}, pot_before=pot, awarded={}, carried=False))
continue
pot_before = pot
pot += 1.0
lowest = min(score for _, score in hole_scores)
winners = [pid for pid, score in hole_scores if score == lowest]
awarded: dict[str, float] = {}
carried = False
if len(winners) == 1:
winnings[winners[0]] = winnings.get(winners[0], 0.0) + pot
awarded = {winners[0]: pot}
pot = 0.0
elif tie_handling == "split":
share = pot / len(winners)
for pid in winners:
winnings[pid] = winnings.get(pid, 0.0) + share
awarded = {pid: share for pid in winners}
pot = 0.0
else:
carried = True
# "carry": potten står urørt og tas med videre til neste hull.
log.append(SkinsHoleLog(values=dict(hole_scores), pot_before=pot_before, awarded=awarded, carried=carried))
return winnings, log
def compute_skins(
scores_by_hole: Sequence[Sequence[tuple[str, int]]],
tie_handling: Literal["carry", "split"] = "carry",
) -> dict[str, float]:
"""Skins vunnet per deltaker, gitt scorer hull for hull.
`scores_by_hole[i]` er (deltaker_id, score)-par for hull i+1 -- KUN for
deltakere som faktisk har registrert en score på det hullet. Scoren skal
allerede være avgjort NETTO eller BRUTTO av kalleren FØR denne funksjonen
kalles (`round.skins_scoring`) -- motoren selv vet ikke, og bryr seg ikke
om, hvilket.
- "carry": et uavgjort hull sitt "skinn" (og alt tidligere oppsamlet)
ruller videre til neste hull -- førstemann til en ENTYDIG laveste
score der tar HELE potten.
- "split": et uavgjort hull sitt skinn (og alt oppsamlet) deles LIKT
mellom de tied spillerne i stedet for å rulle videre -- kan gi
brøkdeler, derav float i returtypen.
Et hull uten noen registrerte scorer hopper stille over (verken potten
eller noen vinnere endres) -- ikke en feiltilstand.
"""
winnings, _log = compute_skins_detail(scores_by_hole, tie_handling)
return winnings
# ---------------------------------------------------------------------------
# Kobenhavner/Copenhagen (2026-07-30) -- flatt felt, noyaktig 3 spillere,
# INGEN sider (passer ikke ADR-011s to-lags-modell, se ADR-011s egen
# 2026-07-19-notat). Kilde: spilletyper-og-spilleformer-2023.pdf s.4 --
# 6 poeng deles per hull etter relativ rangering. Samme
# "(deltaker_id, verdi)-per-hull"-inputform som compute_skins_detail.
# ---------------------------------------------------------------------------
def copenhagen_points_for_hole(
values: Sequence[tuple[str, float]], higher_is_better: bool = False
) -> dict[str, int]:
"""Nøyaktig 3 (deltaker_id, verdi)-par for ETT hull. Returnerer
deltaker_id -> poeng, summerer ALLTID til 6 (verifisert av testene).
Fire rangeringsmønstre (verbatim fra kilden):
- alle tre ulike -> 4-2-0
- vinner alene, de to andre likt sist -> 4-1-1
- to delt best, én sist -> 3-3-0
- alle tre likt -> 2-2-2
`higher_is_better`: False (standard) rangerer LAVEST verdi som best --
rå/netto slagtall. True rangerer HØYEST verdi som best -- Stableford-
poeng. Motoren selv vet ikke, og bryr seg ikke om, hvilket kalleren gir
den (samme prinsipp som compute_skins).
"""
if len(values) != 3:
raise ValueError("copenhagen_points_for_hole krever nøyaktig 3 (deltaker_id, verdi)-par")
best_value = max(v for _, v in values) if higher_is_better else min(v for _, v in values)
worst_value = min(v for _, v in values) if higher_is_better else max(v for _, v in values)
best_ids = [pid for pid, v in values if v == best_value]
worst_ids = [pid for pid, v in values if v == worst_value]
if len(best_ids) == 3:
return {pid: 2 for pid, _ in values}
if len(best_ids) == 1 and len(worst_ids) == 2:
points = {best_ids[0]: 4}
points.update({pid: 1 for pid in worst_ids})
return points
if len(best_ids) == 2 and len(worst_ids) == 1:
points = {pid: 3 for pid in best_ids}
points[worst_ids[0]] = 0
return points
# Alle tre ulike -- nøyaktig én best, én verst, én i midten.
mid_id = next(pid for pid, _ in values if pid not in best_ids and pid not in worst_ids)
return {best_ids[0]: 4, mid_id: 2, worst_ids[0]: 0}
@dataclass(frozen=True)
class CopenhagenHoleLog:
"""Ett hulls poengfordeling -- til en hull-for-hull-visning (samme
idé som SkinsHoleLog)."""
values: dict[str, float]
points: dict[str, int] # tomt hvis hullet hoppes over (ikke alle 3 klare)
def compute_copenhagen_detail(
scores_by_hole: Sequence[Sequence[tuple[str, float]]], higher_is_better: bool = False
) -> tuple[dict[str, int], list[CopenhagenHoleLog]]:
"""Samme beregning som compute_copenhagen, men returnerer OGSÅ et
hull-for-hull-forløp. `scores_by_hole[i]` er (deltaker_id, verdi)-par
for hull i+1 -- et hull med færre/flere enn 3 par (noen har ikke
registrert ennå) hoppes stille over, ikke en feiltilstand."""
totals: dict[str, int] = {}
log: list[CopenhagenHoleLog] = []
for hole_values in scores_by_hole:
if len(hole_values) != 3:
log.append(CopenhagenHoleLog(values=dict(hole_values), points={}))
continue
points = copenhagen_points_for_hole(hole_values, higher_is_better)
for pid, p in points.items():
totals[pid] = totals.get(pid, 0) + p
log.append(CopenhagenHoleLog(values=dict(hole_values), points=points))
return totals, log
def compute_copenhagen(
scores_by_hole: Sequence[Sequence[tuple[str, float]]], higher_is_better: bool = False
) -> dict[str, int]:
"""Totale Københavner-poeng per deltaker, gitt verdier hull for hull."""
totals, _log = compute_copenhagen_detail(scores_by_hole, higher_is_better)
return totals
# ---------------------------------------------------------------------------
# Bingo Bango Bongo (2026-07-30) -- flatt felt, INGEN sider, poengbasert.
# Alle tre kategoriene registreres MANUELT av den som fører score, samme
# mønster som "valgt utslag" i scramble/greensome (migrasjon 036/039) --
# INGEN GPS/live-sporing, ingen ny interaksjons-modell.
# ---------------------------------------------------------------------------
def bbb_points_for_hole(
bingo_id: str | None,
bango_id: str | None,
bongo_id: str | None,
sweep_bonus_enabled: bool = False,
) -> dict[str, int]:
"""1 poeng hver til bingo (først på green), bango (nærmest hull når alle
er på green), bongo (først i hull) -- en kategori som er None (ikke
registrert) gir ingen poeng til noen. Hvis `sweep_bonus_enabled` og
samme deltaker vant alle tre (ikke-None og like) gis 6 poeng i stedet
for 3 (kilden selv nevner dette som en "for variasjon"-valgfri
variant -- bekreftet av bruker at det skal være en organisator-
innstilling, ikke fast påslått)."""
if sweep_bonus_enabled and bingo_id is not None and bingo_id == bango_id == bongo_id:
return {bingo_id: 6}
points: dict[str, int] = {}
for pid in (bingo_id, bango_id, bongo_id):
if pid is not None:
points[pid] = points.get(pid, 0) + 1
return points
def compute_bbb(
hole_log: Sequence[tuple[str | None, str | None, str | None]],
sweep_bonus_enabled: bool = False,
) -> dict[str, int]:
"""Totale Bingo Bango Bongo-poeng per deltaker. `hole_log[i]` er
(bingo_id, bango_id, bongo_id) for hull i+1 -- hvilken som helst av de
tre kan være None (ikke registrert ennå, eller aldri registrert for et
upsilt hull)."""
totals: dict[str, int] = {}
for bingo_id, bango_id, bongo_id in hole_log:
for pid, p in bbb_points_for_hole(bingo_id, bango_id, bongo_id, sweep_bonus_enabled).items():
totals[pid] = totals.get(pid, 0) + p
return totals
# ---------------------------------------------------------------------------
# Flaggturnering/Flag tournament (2026-07-30) -- flatt felt, individuelt,
# INGEN sider. Kilde: spilletyper-og-spilleformer-2023.pdf s.3-4. v1:
# hull-granularitet (ikke sub-hull/GPS -- bevisst utenfor omfang, se
# FEATURE_BACKLOG.md), ingen fortsettelse på hull 19+.
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class FlagResult:
holes_completed: int # antall hull FULLFØRT innenfor slag-budsjettet
ran_out: bool # sann hvis budsjettet tok slutt FØR alle spilte hull var brukt opp
strokes_remaining: int # slag igjen etter siste fullførte hull (0 hvis brukt helt opp)
def flag_result(gross_strokes: Sequence[int], total_strokes_budget: int) -> FlagResult:
"""Par + spillehandicap gir et FAST antall slag for hele runden
(`total_strokes_budget`) -- flagget plantes der spilleren går tom.
`gross_strokes[i]` er brutto slag brukt på hull i+1 (kun FAKTISK
spilte/registrerte hull, i rekkefølge). v1-granularitet er PER HULL:
et hull som ville krevd flere slag enn det som er igjen i budsjettet
telles IKKE som fullført -- vi vet ikke nøyaktig hvor på det hullet
spilleren faktisk gikk tom (det ville krevd sub-hull/GPS-sporing)."""
remaining = total_strokes_budget
for i, score in enumerate(gross_strokes):
if score > remaining:
return FlagResult(holes_completed=i, ran_out=True, strokes_remaining=remaining)
remaining -= score
return FlagResult(holes_completed=len(gross_strokes), ran_out=False, strokes_remaining=remaining)
def flag_lap_and_hole(holes_completed: int, play_order: Sequence[int]) -> tuple[int, int]:
"""Runde 2+-utvidelse (2026-08-14, GPS-flaggplanting) -- "fortsettelse på
hull 19+" er nå støttet: går budsjettet IKKE tomt innen 18 hull, spilles
play_order på nytt fra begynnelsen med gjenværende slag (lap 2, lap 3,
...). `flag_result()` selv trenger INGEN endring for dette -- den er
allerede lap-agnostisk, kalleren limer ganske enkelt sammen lap 1 sin
FULLE 18-lange prefiks med lap 2 sin (kun hvis lap 1 faktisk ble
fullført) osv. før den kalles, og `holes_completed` blir da naturlig
et tall som kan overstige `len(play_order)`.
Denne funksjonen oversetter et slikt flatt `holes_completed`-tall
(fra `flag_result()`) til (lap, hullnummer) -- lap 1 = `play_order`
som den er, lap 2 = samme rekkefølge på nytt, osv. Brukes til å
beregne hvilket hull en spiller reelt er "i gang med" (der flagget
skal plantes), og til server-side å validere at et innsendt
flagg-plant-forsøk faktisk stemmer med registrert scoredata."""
n = len(play_order)
lap = holes_completed // n + 1
hole_index_in_lap = holes_completed % n
return lap, play_order[hole_index_in_lap]
# ---------------------------------------------------------------------------
# Shamble (2026-07-30) -- team av 2-4 spillere (fleksibel størrelse,
# bekreftet av bruker). Alle slår ut, laget velger beste utslag, alle
# spiller egen ball resten av hullet. Lagets hullscore = sum av de N
# beste individuelle resultatene ("beste N av M", N organisator-
# konfigurerbart -- kilde: livetourney.com, "2 Best Balls of 4" mest
# vanlig, men N er ikke fast).
# ---------------------------------------------------------------------------
def shamble_hole_score(individual_scores: Sequence[int], best_n: int) -> int:
"""Summen av de `best_n` LAVESTE individuelle (netto eller brutto,
kalleren avgjør) scorene blant lagets spillere for ETT hull.
`individual_scores` har én oppføring per lagkamerat som spilte hullet.
"""
if not (1 <= best_n <= len(individual_scores)):
raise ValueError(
f"best_n må være mellom 1 og antall spillere ({len(individual_scores)}), fikk {best_n}"
)
return sum(sorted(individual_scores)[:best_n])
# ---------------------------------------------------------------------------
# Money Ball/Lone Ranger (2026-07-30) -- FAST 4-manns lag. Én spiller er
# "money ball" per hull, roterer deterministisk gjennom en FAST
# spillerrekkefølge. Lagets hullscore = money-ball-spillerens score +
# laveste av de tre andre (2 av 4 scorer teller alltid).
# ---------------------------------------------------------------------------
def money_ball_hole_score(scores_in_fixed_order: Sequence[int], hole_number: int) -> int:
"""`scores_in_fixed_order`: nøyaktig 4 individuelle scorer, indeks i =
spilleren som er money-ball på hull i+1 (rotasjonsindeks =
(hole_number - 1) % 4 -- spiller 0 på hull 1, spiller 1 på hull 2, osv,
tilbake til spiller 0 på hull 5). Returnerer money-ball-spillerens
score PLUSS laveste av de tre andre."""
if len(scores_in_fixed_order) != 4:
raise ValueError(
f"money_ball_hole_score krever nøyaktig 4 scorer, fikk {len(scores_in_fixed_order)}"
)
mb_index = (hole_number - 1) % 4
others = [s for i, s in enumerate(scores_in_fixed_order) if i != mb_index]
return scores_in_fixed_order[mb_index] + min(others)
# ---------------------------------------------------------------------------
# High-low-high (2026-07-30) -- 2 lag à 2 spillere. Per hull rangeres
# lagkameratenes Stableford-poeng til "high"/"low" ETT HULL OM GANGEN (ikke
# faste roller), to uavhengige duelløer (high mot high, low mot low). Hver
# duell gir 1 poeng til vinneren, UAVGJORT gir NULL poeng til begge ("å
# dele et hull" = halvere, IKKE en 0,5-splitt -- verifisert tall for tall
# mot brukerens eget eksempel 2026-07-30, se ADR/plan for utregningen).
# ---------------------------------------------------------------------------
def high_low_high_points_for_hole(
team_a_points: tuple[int, int], team_b_points: tuple[int, int]
) -> tuple[int, int]:
"""`team_a/b_points`: Stableford-poeng for de to lagkameratene på DETTE
hullet (rekkefølge i tuppelet er irrelevant -- rangeres internt til
high/low). Returnerer (poeng_a, poeng_b), til sammen 0, 1 eller 2."""
high_a, low_a = max(team_a_points), min(team_a_points)
high_b, low_b = max(team_b_points), min(team_b_points)
points_a = points_b = 0
if high_a > high_b:
points_a += 1
elif high_b > high_a:
points_b += 1
if low_a > low_b:
points_a += 1
elif low_b > low_a:
points_b += 1
return points_a, points_b
def high_low_high_running_score(hole_points: Sequence[tuple[int, int]]) -> tuple[int, int]:
"""Løpende STILLING -- literal poengsum per lag (IKKE match-play "up"/
"down"-terminologi), summert over alle spilte hull."""
return sum(p[0] for p in hole_points), sum(p[1] for p in hole_points)
# ---------------------------------------------------------------------------
# Order of Merit (sesong-sammenlagt rangering på tvers av flere individuelle
# turneringer) -- ADR-043 [OOM]. To uavhengige akser: HVA telles per lenket
# turnering (result_type -- håndtert av kalleren, som utleder tallet fra en
# turnerings egen leaderboard/plassering) og HVORDAN det summeres over flere
# turneringer/spillere (aggregation_mode -- håndtert her).
# ---------------------------------------------------------------------------
def order_of_merit_points_for_position(position_label: str, points_table: Sequence[int]) -> int:
"""Poeng for en gitt plassering i en poeng-etter-plassering-OOM.
`position_label` er samme form som individual_leaderboard allerede
produserer ("1", "T2", "T3" osv.) -- en ledende "T" strippes før
oppslag. `points_table[0]` er poeng for 1. plass, `points_table[1]`
for 2. plass, osv. (1-indeksert plassering, 0-indeksert liste).
Uavgjorte deler SAMME poengverdi ved sin felles (delte) plassering --
IKKE gjennomsnittet av de "oppbrukte" plassene. Enkleste, mest
forutsigbare regel, og matcher hvordan GolfBox sin egen poeng-etter-
plassering-modell oppfører seg.
Plassering utenfor tabellen (feltet er større enn poeng-tabellen
dekker) gir 0 poeng, ikke en feil -- en kortere poeng-tabell enn
feltstørrelsen er en gyldig, vanlig konfigurasjon (kun topp-N får
poeng)."""
position = int(position_label.lstrip("T"))
index = position - 1
if index < 0 or index >= len(points_table):
return 0
return points_table[index]
def order_of_merit_aggregate(
values: Sequence[float], mode: Literal["sum", "average"], best_n: int | None
) -> float | None:
"""Slår sammen en spillers (eller et lags) tellende resultater til ett
OOM-sammenlagt tall.
`None` hvis `values` er tom (ingen tellende resultater ennå -- kalleren
avgjør om det betyr "sist" eller "ikke rangert i det hele tatt").
`best_n=None` betyr "tell alle" -- ellers beholdes kun de `best_n`
STØRSTE verdiene før summering/snitt (dropp-dårligst).
"Best" er alltid STØRRE=bedre for denne funksjonens formål -- for
brutto/nettoslag (der LAVERE er bedre i golf) må kalleren sende inn
NEGERTE verdier, samme triks som allerede brukt i
individual_tournaments.py sin til-par-normaliserte rangeringsnøkkel
(2026-08-04)."""
if not values:
return None
kept = sorted(values, reverse=True)[:best_n] if best_n is not None else list(values)
if mode == "sum":
return sum(kept)
return sum(kept) / len(kept)
# ---------------------------------------------------------------------------
# Eclectic (org individuell turnering, ADR-067-tillegget "Del C") -- en
# "drømmerunde" satt sammen av spillerens BESTE resultat per hullnummer, på
# tvers av ALLE turneringens runder (som må være spilt på samme bane --
# håndheves i app-laget ved rundeopprettelse, ikke her). Tre varianter
# (brutto/netto/Stableford) deler samme plukke-logikk, kun hva som telles
# som "best" (lavest for brutto/netto, høyest for Stableford) og selve
# verdien (rå slag/nettoslag/poeng) skiller dem -- kalleren regner ut disse
# per-hull-per-runde-verdiene på forhånd (samme "motoren kjenner ingen
# db/API"-prinsipp som resten av filen).
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class EclecticHoleValue:
"""Én runde sitt resultat for ETT hull -- `round_index` er kallerens egen
indeksering av rundene (f.eks. inn i en liste den selv holder styr på),
brukt kun til å spore HVILKEN runde det beste resultatet kom fra."""
round_index: int
value: int
@dataclass(frozen=True)
class EclecticHolePick:
hole_number: int
value: int
round_index: int
@dataclass(frozen=True)
class EclecticResult:
total: int
holes: list[EclecticHolePick] # kun hull med minst én registrering, stigende hullnummer
def eclectic_best_per_hole(
values_by_hole: Sequence[Sequence[EclecticHoleValue]],
result_type: Literal["gross", "net", "stableford"],
) -> EclecticResult:
"""`values_by_hole[i]` = ALLE registrerte verdier for hull i+1 (én per
runde der spilleren faktisk har ført score på det hullet) -- tom liste
betyr "ikke spilt i noen runde ennå". Plukker BESTE verdi (lavest for
gross/net, høyest for stableford) per hull, UAVHENGIG av hvilken runde
den kom fra -- summerer de plukkede verdiene til én total.
Ved uavgjort mellom flere runder for samme hull vinner den FØRSTE
(laveste `round_index`) -- vilkårlig men deterministisk, ingen
golfregel avgjør dette (identisk resultat uansett hvilken velges)."""
pick = max if result_type == "stableford" else min
holes: list[EclecticHolePick] = []
for i, vals in enumerate(values_by_hole):
if not vals:
continue
best = pick(vals, key=lambda v: v.value)
holes.append(EclecticHolePick(hole_number=i + 1, value=best.value, round_index=best.round_index))
return EclecticResult(total=sum(h.value for h in holes), holes=holes)