Første byggesteg ferdig: HCP-indeks-motoren.
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.
This commit is contained in:
parent
c904bd7949
commit
80e293cfd4
7 changed files with 506 additions and 21 deletions
|
|
@ -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://teecup.teeoff.no/account)",
|
||||||
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" https://teeoff.no/)",
|
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" https://teeoff.no/)",
|
||||||
"Bash(sudo apt-get install -y poppler-utils)",
|
"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": [
|
"additionalDirectories": [
|
||||||
"/opt/teeoff/deploy",
|
"/opt/teeoff/deploy",
|
||||||
|
|
|
||||||
|
|
@ -1730,12 +1730,24 @@ ikke hukommelse:
|
||||||
2 + spillerens handicapslag på det hullet (Rule 3.1b) — allerede
|
2 + spillerens handicapslag på det hullet (Rule 3.1b) — allerede
|
||||||
dekket av eksisterende `allocate_strokes_by_index`/
|
dekket av eksisterende `allocate_strokes_by_index`/
|
||||||
`allocate_over_played_holes`, ingen endring.
|
`allocate_over_played_holes`, ingen endring.
|
||||||
2. **Uspilte hull** (Rule 3.2b, NY metode i 2024): en "expected score"
|
2. **Uspilte hull — LØST 2026-07-22 med et bevisst, kildebelagt avvik
|
||||||
beregnes for hvert uspilt hull ut fra spillerens HCP-indeks og banens
|
fra "Expected Score":** WHS sin offisielle "Expected Score"-mekanisme
|
||||||
standard vanskelighetsgrad, og kombineres med differensialen fra de
|
(Rule 3.2b) er eksplisitt beskrevet som automatisk beregnet av
|
||||||
faktisk spilte hullene. Kun gyldig når minimumsantallet (Beslutning F)
|
sertifisert WHS-programvare, UTEN at selve formelen er publisert i
|
||||||
er oppfylt og grunnen er gyldig (Rule 3.2a — vær/skade/mørke/hull satt
|
regelboken (samme mønster som PCC) — kan derfor ikke bygges presist.
|
||||||
ut av spill; IKKE "unngå en høy score").
|
**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
|
3. **Ikke fullført hull (spilleren plukker opp)** (Rule 3.3): laveste av
|
||||||
"most likely score" (allerede tatte slag + sannsynlig antall til
|
"most likely score" (allerede tatte slag + sannsynlig antall til
|
||||||
fullføring, tabell basert på ballens avstand fra hullet + eventuelle
|
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) ×
|
4. **18-hulls Score Differential** (Rule 5.1a) = `(113 ÷ Slope Rating) ×
|
||||||
(Adjusted Gross Score − Course Rating − PCC-justering)`, avrundet til
|
(Adjusted Gross Score − Course Rating − PCC-justering)`, avrundet til
|
||||||
nærmeste tidel (,5 rundes opp).
|
nærmeste tidel (,5 rundes opp).
|
||||||
5. **9-hulls Score Differential** (Rule 5.1b) = `(113 ÷ 9-hulls Slope
|
5. **(Rule 5.1b, WHS sin egen 9-hulls-differensial-formel) — IKKE brukt.**
|
||||||
Rating) × (9-hulls Adjusted Gross Score − 9-hulls Course Rating −
|
Erstattet av Net-Par-tilnærmingen i punkt 2: en 9-hulls-runde regnes nå
|
||||||
(0,5 × PCC-justering))` — holdes UAVRUNDET til den er kombinert med
|
som en 18-hulls-runde med 9 Net-Par-fylte hull, gjennom SAMME formel
|
||||||
spillerens forventede score over de andre 9 hullene til én 18-hulls-
|
som punkt 4. Nevnt her kun for å dokumentere at det bevisst er valgt
|
||||||
ekvivalent differensial (avrundes FØRST da).
|
bort, ikke oversett.
|
||||||
6. **Handicap Index** (Rule 5.2) = gjennomsnitt av de beste 8 av de siste
|
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
|
20 Score Differentials, avrundet til nærmeste tidel. For færre enn 20
|
||||||
runder i historikken brukes en egen opptrappingstabell (f.eks. 3
|
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
|
eller trenger en ny kolonne som skiller "manuelt satt" fra "beregnet fra
|
||||||
runde".
|
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)
|
### Beslutning H — Shotgun- vs. fortløpende start: EGEN, separat ADR (ADR-034)
|
||||||
|
|
||||||
Bekreftet med bruker: dette er et turnering/økt-konsept (start_hole per
|
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
|
`round_hole_stat` er arbeidsnavn i denne ADR-en, ikke endelig
|
||||||
fastlagt) — avgjøres ved migrasjonsskriving.
|
fastlagt) — avgjøres ved migrasjonsskriving.
|
||||||
|
|
||||||
**Status: 📋 ADR skrevet OG kildebelagt 2026-07-22, IKKE bygget.** Alle
|
**Status: 🔨 ADR skrevet OG kildebelagt 2026-07-22, BYGGING PÅBEGYNT.**
|
||||||
tre store åpne punktene fra første utkast (banedata, WHS 9-hulls-regel,
|
Alle tre store åpne punktene fra første utkast (banedata, WHS 9-hulls-
|
||||||
full HCP-indeksformel) er nå enten eksplisitt bekreftet med bruker
|
regel, full HCP-indeksformel) er enten eksplisitt bekreftet med bruker
|
||||||
(Beslutning C) eller presist kildebelagt fra den offisielle WHS Rules of
|
(Beslutning C) eller presist kildebelagt fra den offisielle WHS Rules of
|
||||||
Handicapping 2024 (Beslutning F/G) — ikke lenger antatt eller tilnærmet.
|
Handicapping 2024 (Beslutning F/G) — ikke lenger antatt eller tilnærmet.
|
||||||
Neste steg: migrasjon + `handicap_engine.py`-utvidelse (Beslutning G,
|
**Første byggesteg ferdig samme dag:** hele HCP-indeks-motor-komponenten
|
||||||
punkt 4-10) + API + frontend, med samme inkrementelle
|
(Beslutning G, punkt 1-11) er bygget og testet i `handicap_engine.py`
|
||||||
scratch-verifiserte rytme som resten av prosjektet.
|
(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å.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
18
CLAUDE.md
18
CLAUDE.md
|
|
@ -2293,8 +2293,22 @@ Neste steg:
|
||||||
6.1b) — bevisst holdt atskilt fra det eksisterende front_9/back_9-
|
6.1b) — bevisst holdt atskilt fra det eksisterende front_9/back_9-
|
||||||
øktoppsettet i turnering-flyten (ADR-008), som løser et annet problem
|
øktoppsettet i turnering-flyten (ADR-008), som løser et annet problem
|
||||||
av andre grunner. ADR-033 er dermed fullt kildebelagt, ingen store
|
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
|
åpne HCP-regelspørsmål gjenstår.
|
||||||
for full detalj.
|
**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,
|
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
|
se status over). **Del 2 (ekte konto-sammenslåing) fortsatt IKKE
|
||||||
designet:** hva skjer hvis den ønskede adressen ALLEREDE tilhører en
|
designet:** hva skjer hvis den ønskede adressen ALLEREDE tilhører en
|
||||||
|
|
|
||||||
|
|
@ -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
|
**Se ADR-033 i ARCHITECTURE_DECISIONS.md for den fulle, besluttede
|
||||||
arkitekturen** (eierskapsmønster, statistikk-datamodell, HCP-indeksmotor).
|
arkitekturen** (eierskapsmønster, statistikk-datamodell, HCP-indeksmotor).
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -21,6 +21,7 @@ Kilder for standardverdier: R&A Rules of Handicapping, Appendix C.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import date, timedelta
|
||||||
from decimal import Decimal, ROUND_FLOOR
|
from decimal import Decimal, ROUND_FLOOR
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Protocol, Sequence
|
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))
|
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)
|
# Allowance-strategier (konfigurasjon per format)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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)
|
ev. python test_handicap_engine.py (kjører en enkel selvsjekk uten pytest)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
from handicap_engine import (
|
from handicap_engine import (
|
||||||
Format,
|
Format,
|
||||||
HoleResult,
|
HoleResult,
|
||||||
|
|
@ -18,13 +20,23 @@ from handicap_engine import (
|
||||||
WeightedLowHigh,
|
WeightedLowHigh,
|
||||||
RankedSplit,
|
RankedSplit,
|
||||||
DEFAULT_MATCHPLAY_ALLOWANCES,
|
DEFAULT_MATCHPLAY_ALLOWANCES,
|
||||||
|
adjusted_gross_score,
|
||||||
allocate_strokes_by_index,
|
allocate_strokes_by_index,
|
||||||
allocate_over_played_holes,
|
allocate_over_played_holes,
|
||||||
|
apply_index_caps,
|
||||||
compute_match_state,
|
compute_match_state,
|
||||||
course_handicap,
|
course_handicap,
|
||||||
|
course_handicap_9,
|
||||||
|
course_handicap_9_raw,
|
||||||
course_handicap_raw,
|
course_handicap_raw,
|
||||||
|
handicap_index_from_differentials,
|
||||||
|
low_handicap_index,
|
||||||
match_play_strokes,
|
match_play_strokes,
|
||||||
|
max_hole_score_for_handicap,
|
||||||
|
net_par,
|
||||||
round_half_up,
|
round_half_up,
|
||||||
|
round_half_up_decimal,
|
||||||
|
score_differential,
|
||||||
unit_playing_handicap,
|
unit_playing_handicap,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -287,6 +299,193 @@ def test_match_state_side_b_leads():
|
||||||
assert state.describe() == "1 UP (B)"
|
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
|
# Enkel selvsjekk uten pytest
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue