""" TeeCup handicap-motor ===================== Frittstående regel- og matematikk-motor for golfhandicap (ADR-005). Ingen avhengigheter til database, API eller web-rammeverk — kun standardbibliotek. Designprinsipp (ADR-005): "Allowances" (prosenttildelinger per format) er KONFIGURASJON, ikke hardkodet logikk. Motoren vet ikke selv at four-ball = 90 %; den mottar en AllowanceStrategy. Standardverdiene under følger R&A Appendix C (2026), men kan overstyres per turnering — f.eks. 75 %, 3/4, eller lokale varianter. Kilder for standardverdier: R&A Rules of Handicapping, Appendix C. - Course Handicap = Index × Slope/113 + (Course Rating − Par) - Allowance påføres UAVRUNDET Course Handicap; resultatet avrundes til Playing Handicap. - Match play: laveste enhet spiller av 0, øvrige mottar differansen. """ from __future__ import annotations from dataclasses import dataclass from decimal import Decimal, ROUND_FLOOR from enum import Enum from typing import Protocol, Sequence # --------------------------------------------------------------------------- # Grunnleggende matematikk # --------------------------------------------------------------------------- def round_half_up(value: float) -> int: """Avrund til nærmeste heltall, 0,5 alltid opp mot mer positivt tall. Pythons innebygde round() bruker "banker's rounding" (0,5 -> nærmeste partall), noe som gir feil handicap. WHS bruker vanlig 0,5-opp. Merk om minus-handicap (plusspillere): "opp" tolkes her som mot +uendelig, slik at -2,5 -> -2 (ikke -3). Dette er en sjelden kant og regelverket er ikke entydig; endre bare denne funksjonen dersom en autoritativ kilde tilsier noe annet. Bruk Decimal for å unngå flyttallsfeil. """ d = Decimal(str(value)) floor_part = d.to_integral_value(rounding=ROUND_FLOOR) frac = d - floor_part if frac >= Decimal("0.5"): return int(floor_part) + 1 return int(floor_part) def course_handicap_raw( handicap_index: float, slope_rating: float, course_rating: float, par: int, ) -> float: """Uavrundet Course Handicap. Index × Slope/113 + (Course Rating − Par). Beholdes uavrundet fordi allowancen skal påføres den uavrundede verdien (Appendix C). """ return handicap_index * (slope_rating / 113.0) + (course_rating - par) def course_handicap( handicap_index: float, slope_rating: float, course_rating: float, par: int, ) -> int: """Avrundet Course Handicap (til referanse/visning).""" return round_half_up(course_handicap_raw(handicap_index, slope_rating, course_rating, par)) # --------------------------------------------------------------------------- # Allowance-strategier (konfigurasjon per format) # --------------------------------------------------------------------------- # # En strategi tar en liste av UAVRUNDEDE course handicaps for de spillerne som # hører til én "handicap-bærende enhet" og returnerer den enhetens Playing # Handicap (avrundet). # # - Singles/four-ball: hver spiller er sin egen enhet -> kall strategien én # gang per spiller (liste med ett element). # - Foursomes/greensomes/scramble: hele laget er én enhet -> kall strategien # med lagets spillere. class AllowanceStrategy(Protocol): def playing_handicap(self, course_handicaps: Sequence[float]) -> int: """Returner avrundet Playing Handicap for enheten.""" ... @dataclass(frozen=True) class PerPlayerPercentage: """Én prosent av hver enkelt spillers course handicap. Singles match play = 100 %, four-ball match play = 90 %. Forventer nøyaktig én spiller per kall (enheten er individet). """ percentage: float def playing_handicap(self, course_handicaps: Sequence[float]) -> int: if len(course_handicaps) != 1: raise ValueError("PerPlayerPercentage forventer nøyaktig én spiller per enhet") return round_half_up(self.percentage * course_handicaps[0]) @dataclass(frozen=True) class CombinedPercentage: """Prosent av lagets SAMLEDE course handicap. Foursomes = 50 %.""" percentage: float def playing_handicap(self, course_handicaps: Sequence[float]) -> int: return round_half_up(self.percentage * sum(course_handicaps)) @dataclass(frozen=True) class WeightedLowHigh: """Vektet lav/høy. Greensomes = 60 % laveste + 40 % høyeste.""" low_weight: float high_weight: float def playing_handicap(self, course_handicaps: Sequence[float]) -> int: if len(course_handicaps) != 2: raise ValueError("WeightedLowHigh forventer nøyaktig to spillere") low, high = sorted(course_handicaps) return round_half_up(self.low_weight * low + self.high_weight * high) @dataclass(frozen=True) class RankedSplit: """Rangert splitt fra laveste til høyeste handicap. Scramble (4 spillere) = 25/20/15/10. Scramble (2) = 35/15. Antall vekter må matche antall spillere i laget. """ weights: tuple[float, ...] def playing_handicap(self, course_handicaps: Sequence[float]) -> int: if len(course_handicaps) != len(self.weights): raise ValueError( f"RankedSplit forventer {len(self.weights)} spillere, fikk {len(course_handicaps)}" ) ordered = sorted(course_handicaps) # laveste først total = sum(w * ch for w, ch in zip(self.weights, ordered)) return round_half_up(total) # --------------------------------------------------------------------------- # Format-register (STANDARDVERDIER — kan overstyres per turnering) # --------------------------------------------------------------------------- class Format(str, Enum): SINGLES = "singles" FOURBALL = "fourball" FOURSOME = "foursome" GREENSOME = "greensome" SCRAMBLE_2 = "scramble_2" SCRAMBLE_4 = "scramble_4" # Standard match-play-allowances per R&A Appendix C (2026). # VIKTIG: Dette er defaults. En turnering kan levere sitt eget register # (f.eks. four-ball 85 %, eller lokale satser) uten å endre motoren. DEFAULT_MATCHPLAY_ALLOWANCES: dict[Format, AllowanceStrategy] = { Format.SINGLES: PerPlayerPercentage(1.00), Format.FOURBALL: PerPlayerPercentage(0.90), Format.FOURSOME: CombinedPercentage(0.50), Format.GREENSOME: WeightedLowHigh(0.60, 0.40), Format.SCRAMBLE_2: RankedSplit((0.35, 0.15)), Format.SCRAMBLE_4: RankedSplit((0.25, 0.20, 0.15, 0.10)), } # --------------------------------------------------------------------------- # Match play: relativ slagtildeling # --------------------------------------------------------------------------- def match_play_strokes(playing_handicaps: Sequence[int]) -> list[int]: """Gjør absolutte playing handicaps om til relative slag i match play. Laveste enhet spiller av 0; øvrige mottar differansen fra laveste. Rekkefølgen i output samsvarer med input. """ if not playing_handicaps: return [] lowest = min(playing_handicaps) return [ph - lowest for ph in playing_handicaps] # --------------------------------------------------------------------------- # Slagfordeling på hull (Stroke Index) # --------------------------------------------------------------------------- def allocate_strokes_by_index(total_strokes: int, stroke_indexes: Sequence[int]) -> list[int]: """Fordel `total_strokes` mottatte slag på hull etter deres Stroke Index. - stroke_indexes: liste med SI per hull (typisk 1..18, hardeste = 1). - Håndterer total > antall hull: da får hvert hull et grunnslag, og de laveste SI-hullene får et ekstra (spiller med 20 slag på 18 hull får 1 slag overalt + 1 ekstra på SI 1 og 2 -> 2 slag der). - Håndterer minus-handicap (total < 0): spilleren GIR slag tilbake, med start på de letteste hullene (høyest SI). Returnerer liste med slag per hull i samme rekkefølge som stroke_indexes. """ n = len(stroke_indexes) if n == 0: return [] sign = 1 if total_strokes >= 0 else -1 magnitude = abs(total_strokes) base, extra = divmod(magnitude, n) # Ved positive slag går ekstraslag til laveste SI (hardeste hull). # Ved negative (gir tilbake) går de til høyeste SI (letteste hull). if sign >= 0: gets_extra = {si for si in stroke_indexes if si <= extra} else: # de `extra` høyeste SI-verdiene threshold = n - extra gets_extra = {si for si in stroke_indexes if si > threshold} result = [] for si in stroke_indexes: strokes = base + (1 if si in gets_extra else 0) result.append(sign * strokes) return result def allocate_over_played_holes( total_strokes: int, all_18_stroke_indexes: Sequence[int], played_hole_numbers: Sequence[int], ) -> list[int]: """Slagfordeling når bare et delsett av hullene spilles (f.eks. front/back 9). VIKTIG: Fordel ALLTID over hele 18-hulls stroke index, og ta så ut de hullene som faktisk spilles. Å sende bare de spilte hullene inn i `allocate_strokes_by_index` gir feil ved høye slagtall, fordi den da ville fordelt slagene som om totalen gjaldt kun de ni hullene (grunnslag på hvert hull). Eksempel: 12 slag, back-9 -> riktig 6 slag, naivt 10. - all_18_stroke_indexes: SI for hull 1..18 i hullrekkefølge (indeks 0 = hull 1). - played_hole_numbers: hullnumrene som spilles (f.eks. [10..18] for back-9). Returnerer slag per spilt hull, i samme rekkefølge som played_hole_numbers. """ full = allocate_strokes_by_index(total_strokes, all_18_stroke_indexes) return [full[hole - 1] for hole in played_hole_numbers] # --------------------------------------------------------------------------- # Match-status ("2 UP", "dormie", "3&2", "AS") # --------------------------------------------------------------------------- class HoleResult(int, Enum): SIDE_A = 1 # side A vant hullet HALVED = 0 # delt SIDE_B = -1 # side B vant hullet @dataclass(frozen=True) class MatchState: lead: int # positiv = A leder, negativ = B leder, 0 = all square holes_played: int holes_remaining: int is_closed: bool # matchen er avgjort (ledelse > gjenstående hull) is_dormie: bool # ledelse == gjenstående hull (motstander kan ikke vinne) def describe(self) -> str: """Kort tekstlig status, f.eks. '2 UP (A)', 'AS', '3&2 (A)', 'dormie 2 (B)'.""" if self.is_closed: # Avgjort: "X&Y" der Y = hull som gjensto da matchen ble vunnet. winner = "A" if self.lead > 0 else "B" margin = abs(self.lead) if self.holes_remaining == 0 and margin > 0: # Vunnet på siste hull return f"{margin} UP ({winner})" return f"{margin}&{self.holes_remaining} ({winner})" if self.lead == 0: return "AS" leader = "A" if self.lead > 0 else "B" status = f"{abs(self.lead)} UP ({leader})" if self.is_dormie: status = f"dormie {abs(self.lead)} ({leader})" return status def compute_match_state( hole_results: Sequence[HoleResult], total_holes: int = 18, ) -> MatchState: """Beregn løpende match play-status fra hull-for-hull-resultater. hole_results: resultatene så langt (kan være færre enn total_holes). total_holes: antall hull i matchen (18 som standard, men støtter 9 osv.). """ lead = sum(int(r) for r in hole_results) holes_played = len(hole_results) holes_remaining = total_holes - holes_played is_closed = abs(lead) > holes_remaining is_dormie = (not is_closed) and holes_remaining > 0 and abs(lead) == holes_remaining return MatchState( lead=lead, holes_played=holes_played, holes_remaining=holes_remaining, is_closed=is_closed, is_dormie=is_dormie, ) # --------------------------------------------------------------------------- # Bekvemmelighets-API: fra rådata til relative slag for en match # --------------------------------------------------------------------------- @dataclass(frozen=True) class Player: name: str handicap_index: float slope_rating: float course_rating: float par: int def course_handicap_raw(self) -> float: return course_handicap_raw( self.handicap_index, self.slope_rating, self.course_rating, self.par ) def unit_playing_handicap(players: Sequence[Player], strategy: AllowanceStrategy) -> int: """Playing Handicap for én enhet (spiller eller lag) gitt en strategi.""" return strategy.playing_handicap([p.course_handicap_raw() for p in players])