From 80e293cfd43bd3099970651c5a61d296853204c9 Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Wed, 22 Jul 2026 10:28:01 +0200 Subject: [PATCH] =?UTF-8?q?F=C3=B8rste=20byggesteg=20ferdig:=20HCP-indeks-?= =?UTF-8?q?motoren.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bygget og testet i handicap_engine.py — ren Python, ingen database/API rørt ennå (samme "test i isolasjon først"-prinsipp som resten av motoren). 9 nye funksjoner dekker hele kjeden fra rå hullscore til en ferdig, cap-justert HCP-indeks: Net Par/Net Double Bogey, Adjusted Gross Score, Score Differential, Handicap Index (beste-8-av-20 med opptrappingstabell for nye spillere), Low Handicap Index, soft/hard cap, og den avvikende 9-hulls Course Handicap-formelen. 41/41 tester bestått, flere verifisert direkte mot regelbokens egne tallregneeksempler (ikke bare intern konsistens) — blant annet begge Rule 5.2a-eksemplene (13,2 / 34,1 / 37,4), alle tre Rule 5.1c-avrundingseksemplene, og soft/hard cap-oppførselen fra Diagram 5.8. For Diagram 3.1b (Net Double Bogey-eksempelet) var jeg åpen om en reell begrensning: front-9 stemte eksakt mot bildet, men jeg kunne ikke garantere hvert enkelt siffer i back-9-scorerekken fra bilde-oppløsningen — testen bruker derfor kun det ene tydelig annoterte, kildebelagte tallet (hull 17: brutto 9, capped til 7) og egenkomponerte tall for resten, dokumentert i kommentaren. Dokumentasjonen (ADR-033, CLAUDE.md, FEATURE_BACKLOG.md) er oppdatert til å reflektere at bygging er påbegynt. --- .claude/settings.local.json | 3 +- ARCHITECTURE_DECISIONS.md | 77 +++++-- CLAUDE.md | 18 +- FEATURE_BACKLOG.md | 2 +- __pycache__/handicap_engine.cpython-312.pyc | Bin 14492 -> 23731 bytes handicap_engine.py | 228 ++++++++++++++++++++ test_handicap_engine.py | 199 +++++++++++++++++ 7 files changed, 506 insertions(+), 21 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 2fbe7d1..61cb050 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -347,7 +347,8 @@ "Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" https://teecup.teeoff.no/account)", "Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" https://teeoff.no/)", "Bash(sudo apt-get install -y poppler-utils)", - "Bash(sudo -n apt-get install -y poppler-utils)" + "Bash(sudo -n apt-get install -y poppler-utils)", + "Bash(python3 -m pytest test_handicap_engine.py -q)" ], "additionalDirectories": [ "/opt/teeoff/deploy", diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index 3f46fa6..9ea78aa 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -1730,12 +1730,24 @@ ikke hukommelse: 2 + spillerens handicapslag på det hullet (Rule 3.1b) — allerede dekket av eksisterende `allocate_strokes_by_index`/ `allocate_over_played_holes`, ingen endring. -2. **Uspilte hull** (Rule 3.2b, NY metode i 2024): en "expected score" - beregnes for hvert uspilt hull ut fra spillerens HCP-indeks og banens - standard vanskelighetsgrad, og kombineres med differensialen fra de - faktisk spilte hullene. Kun gyldig når minimumsantallet (Beslutning F) - er oppfylt og grunnen er gyldig (Rule 3.2a — vær/skade/mørke/hull satt - ut av spill; IKKE "unngå en høy score"). +2. **Uspilte hull — LØST 2026-07-22 med et bevisst, kildebelagt avvik + fra "Expected Score":** WHS sin offisielle "Expected Score"-mekanisme + (Rule 3.2b) er eksplisitt beskrevet som automatisk beregnet av + sertifisert WHS-programvare, UTEN at selve formelen er publisert i + regelboken (samme mønster som PCC) — kan derfor ikke bygges presist. + **Brukeren instruerte eksplisitt** å bruke WHS sin egen, presist + DEFINERTE "Net Par"-term i stedet (Rule 3.2b/2 — normalt reservert for + spesielle godkjente tilfeller, men her vedtatt som TeeCups generelle + policy): for hvert uspilt hull antas spilleren å ha skåret sin Net Par + = hullets par + mottatte handicapslag på det hullet (samme formel som + Net Double Bogey, uten +2-leddet) — tilsvarer 2 Stableford-poeng per + uspilt hull. Summeres inn i Adjusted Gross Score FØR standard 18-hulls + Score Differential-formelen brukes. **Viktig konsekvens:** dette gjør + at en 9-hulls-runde nå KAN telle fullt mot HCP-indeksen (de resterende + 9 hullene fylles med Net Par, hele runden går gjennom SAMME 18-hulls- + formel) — det tidligere spørsmålet om en egen 9-hulls-differensial- + formel (Rule 5.1b) er dermed ikke lenger nødvendig å bygge separat. + Fortsatt gyldig kun når minimumsantallet (Beslutning F) er oppfylt. 3. **Ikke fullført hull (spilleren plukker opp)** (Rule 3.3): laveste av "most likely score" (allerede tatte slag + sannsynlig antall til fullføring, tabell basert på ballens avstand fra hullet + eventuelle @@ -1743,11 +1755,11 @@ ikke hukommelse: 4. **18-hulls Score Differential** (Rule 5.1a) = `(113 ÷ Slope Rating) × (Adjusted Gross Score − Course Rating − PCC-justering)`, avrundet til nærmeste tidel (,5 rundes opp). -5. **9-hulls Score Differential** (Rule 5.1b) = `(113 ÷ 9-hulls Slope - Rating) × (9-hulls Adjusted Gross Score − 9-hulls Course Rating − - (0,5 × PCC-justering))` — holdes UAVRUNDET til den er kombinert med - spillerens forventede score over de andre 9 hullene til én 18-hulls- - ekvivalent differensial (avrundes FØRST da). +5. **(Rule 5.1b, WHS sin egen 9-hulls-differensial-formel) — IKKE brukt.** + Erstattet av Net-Par-tilnærmingen i punkt 2: en 9-hulls-runde regnes nå + som en 18-hulls-runde med 9 Net-Par-fylte hull, gjennom SAMME formel + som punkt 4. Nevnt her kun for å dokumentere at det bevisst er valgt + bort, ikke oversett. 6. **Handicap Index** (Rule 5.2) = gjennomsnitt av de beste 8 av de siste 20 Score Differentials, avrundet til nærmeste tidel. For færre enn 20 runder i historikken brukes en egen opptrappingstabell (f.eks. 3 @@ -1817,6 +1829,34 @@ finnes? Påvirker om `handicap_history` (018) skal gjenbrukes uendret eller trenger en ny kolonne som skiller "manuelt satt" fra "beregnet fra runde". +**Motor-komponenten (punkt 1-11) er ✅ BYGGET OG TESTET 2026-07-22,** +som første, isolerte byggesteg (ren Python, ingen DB/API/frontend ennå — +matcher ADR-005s "test i isolasjon FØR resten"). Nye funksjoner i +`handicap_engine.py`: `net_par`, `max_hole_score_for_handicap`, +`adjusted_gross_score`, `round_half_up_decimal`, `score_differential`, +`handicap_index_from_differentials`, `low_handicap_index`, +`apply_index_caps`, `course_handicap_9_raw`/`course_handicap_9`. +**Bevisst avvik fra opprinnelig plan, instruert av bruker 2026-07-22:** +"Expected Score" (Rule 3.2b) sin upubliserte formel erstattes gjennomgående +av WHS sin egen, presist definerte "Net Par"-term (par + mottatte +handicapslag = 2 Stableford-poeng) for uspilte hull — gjelder BÅDE +ufullstendige 18-hulls-runder og konvertering av en 9-hulls-runde til +18-hulls-ekvivalent (Rule 5.1b sin egen separate 9-hulls-differensial- +formel er dermed bevisst IKKE implementert, se punkt 5 over). +**41/41 tester bestått** (`test_handicap_engine.py`, kjørt uten pytest — +ikke installert i miljøet, kun den innebygde selvsjekk-runneren), 17 nye +i tillegg til de 24 eksisterende. Flere verifisert mot regelbokens EGNE +tallregneeksempler, ikke bare intern konsistens: Rule 5.2a sine to +initial-indeks-eksempler (13,2 og 34,1, samt oppfølgingen til 37,4), +Rule 5.1c sine tre avrundingseksempler, Diagram 5.8 sin soft-/hard-cap- +oppførsel, og Diagram 3.1b sin Net-Double-Bogey-capping (der front-9 ble +verifisert eksakt mot diagrammet, mens back-9 sine åtte ikke-annoterte +scorer bevisst ble egenkomponert pga. usikker bilde-lesing av akkurat de +sifrene — se testens egen kommentar for full transparens om hva som er +kildebelagt og hva som ikke er det). +**Gjenstår:** migrasjon (nye tabeller for runde/deltaker/statistikk), +API-lag, frontend — ingen av disse er startet. + ### Beslutning H — Shotgun- vs. fortløpende start: EGEN, separat ADR (ADR-034) Bekreftet med bruker: dette er et turnering/økt-konsept (start_hole per @@ -1839,14 +1879,17 @@ videre her. `round_hole_stat` er arbeidsnavn i denne ADR-en, ikke endelig fastlagt) — avgjøres ved migrasjonsskriving. -**Status: 📋 ADR skrevet OG kildebelagt 2026-07-22, IKKE bygget.** Alle -tre store åpne punktene fra første utkast (banedata, WHS 9-hulls-regel, -full HCP-indeksformel) er nå enten eksplisitt bekreftet med bruker +**Status: 🔨 ADR skrevet OG kildebelagt 2026-07-22, BYGGING PÅBEGYNT.** +Alle tre store åpne punktene fra første utkast (banedata, WHS 9-hulls- +regel, full HCP-indeksformel) er enten eksplisitt bekreftet med bruker (Beslutning C) eller presist kildebelagt fra den offisielle WHS Rules of Handicapping 2024 (Beslutning F/G) — ikke lenger antatt eller tilnærmet. -Neste steg: migrasjon + `handicap_engine.py`-utvidelse (Beslutning G, -punkt 4-10) + API + frontend, med samme inkrementelle -scratch-verifiserte rytme som resten av prosjektet. +**Første byggesteg ferdig samme dag:** hele HCP-indeks-motor-komponenten +(Beslutning G, punkt 1-11) er bygget og testet i `handicap_engine.py` +(41/41 tester, se detalj under Beslutning G). Gjenstår: migrasjon (nye +tabeller for runde/deltaker/statistikk), API-lag, frontend — samme +inkrementelle, scratch-verifiserte rytme som resten av prosjektet, ingen +av disse er startet ennå. --- diff --git a/CLAUDE.md b/CLAUDE.md index 15ed8b4..0664543 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2293,8 +2293,22 @@ Neste steg: 6.1b) — bevisst holdt atskilt fra det eksisterende front_9/back_9- øktoppsettet i turnering-flyten (ADR-008), som løser et annet problem av andre grunner. ADR-033 er dermed fullt kildebelagt, ingen store - åpne HCP-regelspørsmål gjenstår. Se ADR-033 i ARCHITECTURE_DECISIONS.md - for full detalj. + åpne HCP-regelspørsmål gjenstår. + **Bygging påbegynt samme dag:** brukeren instruerte at "Expected + Score" (Rule 3.2b, upublisert WHS-formel) erstattes gjennomgående av + WHS sin egen "Net Par"-term for uspilte hull — løser samtidig hele + 9-hulls-runde-spørsmålet uten en egen separat formel (Rule 5.1b droppet + bevisst). Hele HCP-indeks-motor-komponenten (Net Double Bogey/Net Par, + Adjusted Gross Score, Score Differential, Handicap Index fra beste- + 8-av-20 m/opptrappingstabell for <20 runder, Low Handicap Index, soft/ + hard cap, 9-hulls Course Handicap) er BYGGET og TESTET i + `handicap_engine.py` — 41/41 tester (`test_handicap_engine.py`, kjørt + uten pytest da det ikke er installert i miljøet), flere verifisert mot + regelbokens egne tallregneeksempler (Rule 5.2a, Rule 5.1c, Diagram 5.8, + Diagram 3.1b). Ren Python, ingen DB/API/frontend rørt ennå — matcher + ADR-005s "test i isolasjon FØR resten". Se ADR-033 i + ARCHITECTURE_DECISIONS.md for full detalj og gjenstående steg + (migrasjon, API, frontend — ingen påbegynt). 4. **Del 1 (fri, ukrevd sekundær-e-post) er nå BYGGET OG LIVE** (2026-07-21, se status over). **Del 2 (ekte konto-sammenslåing) fortsatt IKKE designet:** hva skjer hvis den ønskede adressen ALLEREDE tilhører en diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index 6d51649..913a0ec 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -1366,7 +1366,7 @@ tom-skjermens endelige form kan bestemmes. --- -## Frittstående rundeføring + detaljert statistikk (uten turnering/organisasjon) — 📋 ADR-033 SKREVET OG KILDEBELAGT 2026-07-22, IKKE bygget +## Frittstående rundeføring + detaljert statistikk (uten turnering/organisasjon) — 🔨 ADR-033 SKREVET OG KILDEBELAGT, BYGGING PÅBEGYNT 2026-07-22 **Se ADR-033 i ARCHITECTURE_DECISIONS.md for den fulle, besluttede arkitekturen** (eierskapsmønster, statistikk-datamodell, HCP-indeksmotor). diff --git a/__pycache__/handicap_engine.cpython-312.pyc b/__pycache__/handicap_engine.cpython-312.pyc index e9c00a7e92740d564abd6b5819a6a36d8d7c9d73..461a4a0495444712f08dddd4c29e0e2379a6063b 100644 GIT binary patch delta 12230 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 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, +) -> 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. + """ + if not index_established: + 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, +) -> 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`. + - 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) + 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)) + + # --------------------------------------------------------------------------- # Allowance-strategier (konfigurasjon per format) # --------------------------------------------------------------------------- diff --git a/test_handicap_engine.py b/test_handicap_engine.py index 8a9ade8..96c22fd 100644 --- a/test_handicap_engine.py +++ b/test_handicap_engine.py @@ -9,6 +9,8 @@ Kjør: python -m pytest test_handicap_engine.py -v ev. python test_handicap_engine.py (kjører en enkel selvsjekk uten pytest) """ +from datetime import date + from handicap_engine import ( Format, HoleResult, @@ -18,13 +20,23 @@ from handicap_engine import ( WeightedLowHigh, RankedSplit, DEFAULT_MATCHPLAY_ALLOWANCES, + adjusted_gross_score, allocate_strokes_by_index, allocate_over_played_holes, + apply_index_caps, compute_match_state, course_handicap, + course_handicap_9, + course_handicap_9_raw, course_handicap_raw, + handicap_index_from_differentials, + low_handicap_index, match_play_strokes, + max_hole_score_for_handicap, + net_par, round_half_up, + round_half_up_decimal, + score_differential, unit_playing_handicap, ) @@ -287,6 +299,193 @@ def test_match_state_side_b_leads(): assert state.describe() == "1 UP (B)" +# --------------------------------------------------------------------------- +# HCP-indeksberegning fra spilte runder (ADR-033) +# +# Fasitverdiene under er, der mulig, hentet direkte fra egne diagrammer og +# tallregneeksempler i "WHS Rules of Handicapping" (effektiv januar 2024, +# USGA/R&A) — samme prinsipp som resten av filen (ADR-005): verifiser mot en +# autoritativ kilde, ikke bare intern konsistens. +# --------------------------------------------------------------------------- + +def test_round_half_up_decimal_rule_5_1c_examples(): + # Rule 5.1c sine tre eksplisitte eksempler, inkl. negative verdier. + assert round_half_up_decimal(-1.54, 1) == -1.5 + assert round_half_up_decimal(-1.55, 1) == -1.5 + assert round_half_up_decimal(-1.56, 1) == -1.6 + assert round_half_up_decimal(15.25, 1) == 15.3 + + +def test_net_par(): + # Net Par (Rule 3.2b/2) -- par + mottatte handicapslag. + assert net_par(par=4, strokes_received=1) == 5 + assert net_par(par=4, strokes_received=0) == 4 + + +def test_max_hole_score_for_handicap(): + # Net Double Bogey (Rule 3.1b): par + 2 + mottatte slag. + assert max_hole_score_for_handicap(4, 1) == 7 + # Diagram 3.1b sitt eksempel: par 4, 1 mottatt slag -> maks 7. + assert max_hole_score_for_handicap(par=4, strokes_received=1) == 7 + # Før indeks etablert (Rule 3.1a): par + 5, uavhengig av slag. + assert max_hole_score_for_handicap(5, strokes_received=0, index_established=False) == 10 + + +def test_adjusted_gross_score_diagram_3_1b_worked_example(): + # John Smith, HCP 16, Diagram 3.1b. Front-9 (par/SI/score) er lest + # tydelig og eksakt fra diagrammet (Out = 35 par / 43 gross, begge + # verifisert ved summering) -- brukt uendret her. + # + # Back-9: par/SI-radene er like eksakte (In = 35 par, verifisert), MEN + # selve score-tallrekken i bilde-utsnittet er for utydelig til å stole + # blindt på hver enkelt siffer -- summen min (46) stemte ikke med + # diagrammets oppgitte "In 45". Eneste back-9-tallet jeg er HELT sikker + # på (egen, tydelig uthevet boks i diagrammet, med piler): hull 17 + # (par 4, SI 6, mottar 1 slag ved HCP 16) hadde bruttoscore 9, capped + # til Net Double Bogey 7. De ANDRE åtte back-9-scorene under er derfor + # egenkomponerte (ikke hentet fra diagrammet) -- valgt lavt nok til at + # ingen av dem selv trigger en cap, slik at testen isolert kan + # verifisere nøyaktig den ene, kildebelagte capping-hendelsen. + front9_pars = [4, 4, 3, 4, 5, 4, 3, 4, 4] + front9_si = [7, 13, 3, 15, 11, 1, 17, 5, 9] + front9_scores = [5, 5, 6, 4, 5, 5, 3, 5, 5] + assert sum(front9_pars) == 35 and sum(front9_scores) == 43 # Out, begge fra diagrammet + + back9_pars = [3, 4, 5, 3, 4, 5, 3, 4, 4] + back9_si = [18, 12, 4, 14, 8, 2, 16, 6, 10] + assert sum(back9_pars) == 35 # In (par), fra diagrammet + # Egenkomponerte back-9-scorer (se kommentar over) -- hull 17 (indeks 7, + # par 4, SI 6) er det ENESTE kildebelagte tallet i denne rekken: gross 9. + back9_scores = [3, 3, 3, 3, 3, 3, 3, 9, 3] # index 7 = hull 17 + + pars = front9_pars + back9_pars + stroke_index = front9_si + back9_si + scores = front9_scores + back9_scores + strokes_received = allocate_strokes_by_index(16, stroke_index) # HCP 16 + + hole17_index = len(front9_pars) + back9_si.index(6) # hole 17 = 0-indexert 16 + assert hole17_index == 16 + assert pars[hole17_index] == 4 and stroke_index[hole17_index] == 6 + assert strokes_received[hole17_index] == 1 # SI 6 <= 16 -> mottar slag + assert max_hole_score_for_handicap(4, 1) == 7 # Net Double Bogey, diagrammets "Max 7" + + ags = adjusted_gross_score(scores, pars, strokes_received) + # Eneste hull som overskrider sin cap er hull 17 (gross 9 -> capped 7): + # adjusted = gross_total - (9 - 7). + assert ags == sum(scores) - 2 + + +def test_adjusted_gross_score_unplayed_holes_use_net_par(): + # 9 spilte hull (front) + 9 uspilte (None) -- uspilte fylles med Net Par + # (TeeCups kildebelagte erstatning for WHS sin upubliserte Expected Score, + # se ADR-033 og moduldoc i handicap_engine.py). + pars = [4] * 18 + stroke_index = list(range(1, 19)) + strokes_received = allocate_strokes_by_index(9, stroke_index) # 9 slag -> 1 på SI 1-9 + scores = [4] * 9 + [None] * 9 + ags = adjusted_gross_score(scores, pars, strokes_received) + # Spilte 9: par 4, ingen over cap -> 9*4 = 36. + # Uspilte 9 (SI 10-18, 0 mottatte slag hver) -> Net Par = par + 0 = 4 hver -> 36. + assert ags == 72 + + +def test_score_differential_formula(): + # Egen tallsjekk mot selve formelen (Rule 5.1a), ingen offisiell + # diagram-fasit for akkurat denne kombinasjonen -- verifiserer + # utregningen, ikke bare at funksjonen kjører. + diff = score_differential(adjusted_gross_score_value=90, course_rating=71.5, slope_rating=128) + expected = round_half_up_decimal((113 / 128) * (90 - 71.5), 1) + assert diff == expected == 16.3 + + +def test_handicap_index_initial_three_scores_rule_5_2a_example_1(): + # Rule 5.2a klargjøring: tre differensialer 15,3 / 15,2 / 16,6 + # -> laveste 1 (15,2), justering -2,0 -> initial indeks 13,2. + idx = handicap_index_from_differentials([15.3, 15.2, 16.6]) + assert idx == 13.2 + + +def test_handicap_index_initial_three_scores_rule_5_2a_example_2(): + # Rule 5.2a klargjøring, andre eksempel: 40,7 / 42,4 / 36,1 + # -> laveste 1 (36,1), justering -2,0 -> initial indeks 34,1. + idx = handicap_index_from_differentials([40.7, 42.4, 36.1]) + assert idx == 34.1 + + +def test_handicap_index_six_scores_rule_5_2a_example_2_continued(): + # Samme klargjøring, spilleren legger til tre nye: 45,9 / 43,6 / 45,0. + # Seks totalt -> snitt av laveste 2 (36,1 og 40,7 = 38,4), justering + # -1,0 -> indeks 37,4. + idx = handicap_index_from_differentials([40.7, 42.4, 36.1, 45.9, 43.6, 45.0]) + assert idx == 37.4 + + +def test_handicap_index_fewer_than_three_returns_none(): + assert handicap_index_from_differentials([]) is None + assert handicap_index_from_differentials([20.0, 21.0]) is None + + +def test_handicap_index_twenty_scores_uses_lowest_eight(): + # Rule 5.2b: 20 differensialer -> snitt av laveste 8, ingen justering. + diffs = [float(v) for v in range(1, 21)] # 1..20 + idx = handicap_index_from_differentials(diffs) + # Laveste 8: 1..8 -> snitt 4,5 + assert idx == 4.5 + + +def test_low_handicap_index_within_window(): + history = [ + (date(2025, 1, 1), 18.0), + (date(2025, 6, 1), 15.0), + (date(2025, 12, 1), 20.0), + ] + # Vindu 365 dager tilbake fra 2025-12-15 dekker alle tre -> laveste 15.0. + assert low_handicap_index(history, date(2025, 12, 15)) == 15.0 + + +def test_low_handicap_index_excludes_old_entries(): + history = [ + (date(2023, 1, 1), 5.0), # for gammel, faller utenfor 365-dagersvinduet + (date(2025, 11, 1), 15.0), + ] + assert low_handicap_index(history, date(2025, 12, 15)) == 15.0 + + +def test_low_handicap_index_empty_returns_none(): + assert low_handicap_index([], date(2025, 1, 1)) is None + + +def test_apply_index_caps_diagram_5_8(): + # Diagram 5.8: Low Handicap Index-referanse 20,0. Soft cap-trigger +3 + # (23,0), hard cap-tak +5 (25,0). + assert apply_index_caps(new_index=22.0, low_handicap_index_value=20.0) == 22.0 # under soft cap + assert apply_index_caps(new_index=23.0, low_handicap_index_value=20.0) == 23.0 # akkurat på triggeren + # Økning 4,0 -> 3,0 uendret + halvparten av resten (1,0) = 3,5 -> 23,5 + assert apply_index_caps(new_index=24.0, low_handicap_index_value=20.0) == 23.5 + # Stor økning -> hard cap-taket, aldri over 25,0 + assert apply_index_caps(new_index=40.0, low_handicap_index_value=20.0) == 25.0 + + +def test_apply_index_caps_no_lower_limit(): + # Ingen nedre grense -- indeksen kan synke fritt, ingen cap i den retningen. + assert apply_index_caps(new_index=5.0, low_handicap_index_value=20.0) == 5.0 + + +def test_course_handicap_9_halves_index_rule_6_1b(): + # Rule 6.1b: indeksen HALVERES før den ganges med 9-hulls slope/113 -- + # dette AVVIKER fra 18-hulls-formelen (course_handicap_raw), som IKKE + # halverer. Verifiserer nettopp dette avviket, siden ingen tallmessig + # offisiell fasit ble gitt for akkurat denne kombinasjonen i kilden. + idx, slope9, rating9, par9 = 20.0, 132, 36.1, 36 + ch9 = course_handicap_9_raw(idx, slope9, rating9, par9) + # Skal IKKE være det samme som å bruke full indeks (ville gitt et annet tall). + full_index_variant = course_handicap_raw(idx, slope9, rating9, par9) + assert ch9 != full_index_variant + expected = (idx / 2.0) * (slope9 / 113.0) + (rating9 - par9) + assert abs(ch9 - expected) < 1e-9 + assert course_handicap_9(idx, slope9, rating9, par9) == round_half_up(expected) + + # --------------------------------------------------------------------------- # Enkel selvsjekk uten pytest # ---------------------------------------------------------------------------