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 e9c00a7..461a4a0 100644 Binary files a/__pycache__/handicap_engine.cpython-312.pyc and b/__pycache__/handicap_engine.cpython-312.pyc differ diff --git a/handicap_engine.py b/handicap_engine.py index 50aad10..693e435 100644 --- a/handicap_engine.py +++ b/handicap_engine.py @@ -21,6 +21,7 @@ Kilder for standardverdier: R&A Rules of Handicapping, Appendix C. 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 Protocol, Sequence @@ -73,6 +74,233 @@ def course_handicap( 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, +) -> 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 # ---------------------------------------------------------------------------