teecup/handicap_engine.py

338 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""
TeeCup handicap-motor
=====================
Frittstående regel- og matematikk-motor for golfhandicap (ADR-005).
Ingen avhengigheter til database, API eller web-rammeverk — kun standardbibliotek.
Designprinsipp (ADR-005):
"Allowances" (prosenttildelinger per format) er KONFIGURASJON, ikke hardkodet
logikk. Motoren vet ikke selv at four-ball = 90 %; den mottar en
AllowanceStrategy. Standardverdiene under følger R&A Appendix C (2026), men
kan overstyres per turnering — f.eks. 75 %, 3/4, eller lokale varianter.
Kilder for standardverdier: R&A Rules of Handicapping, Appendix C.
- Course Handicap = Index × Slope/113 + (Course Rating Par)
- Allowance påføres UAVRUNDET Course Handicap; resultatet avrundes til
Playing Handicap.
- Match play: laveste enhet spiller av 0, øvrige mottar differansen.
"""
from __future__ import annotations
from dataclasses import dataclass
from 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])