339 lines
12 KiB
Python
339 lines
12 KiB
Python
|
|
"""
|
|||
|
|
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])
|