teecup/app/handicap.py

200 lines
7.1 KiB
Python
Raw Normal View History

"""
DB-bevisst bro mellom handicap_engine.py (ren, uten DB-avhengigheter) og
Postgres. Se ADR-005 (allowance er konfig), ADR-014 (fire uavhengige brytere),
ADR-007 (handicap_index_snapshot fryses i team_roster).
Kjent, bevisst begrensning (se plan): hvis en side aldri når forventet
deltakerantall, eller en deltakers tee mangler en matchende tee_rating-rad,
beregnes handicap for den siden ALDRI automatisk her -- ingen bakgrunnsjobb
prøver nytt. Dette løses senere (sannsynligvis en organisator-overstyring).
"""
from __future__ import annotations
from dataclasses import dataclass
from asyncpg import Connection
from handicap_engine import (
AllowanceStrategy,
CombinedPercentage,
DEFAULT_MATCHPLAY_ALLOWANCES,
Format,
PerPlayerPercentage,
RankedSplit,
WeightedLowHigh,
course_handicap_raw,
match_play_strokes,
round_half_up,
)
# Formater der ENHETEN som deler én Playing Handicap er SIDEN (foursome-paret,
# greensome-paret, hele scramble-laget) -- ikke spilleren. Singles/fourball er
# ikke med her: der er enheten spilleren, og beregnes individuelt.
_SIDE_IS_UNIT = {"foursome", "greensome", "scramble_2", "scramble_4"}
FORMAT_UNIT_SIZE: dict[str, int] = {
"singles": 1,
"fourball": 1,
"foursome": 2,
"greensome": 2,
"scramble_2": 2,
"scramble_4": 4,
}
@dataclass(frozen=True)
class AllowanceConfig:
use_handicap: bool
use_course_handicap: bool
use_matchplay_handicap: bool
strategy: AllowanceStrategy
def _strategy_from_json(format_: str, spec: dict | None) -> AllowanceStrategy:
if spec is None:
return DEFAULT_MATCHPLAY_ALLOWANCES[Format(format_)]
kind = spec["type"]
if kind == "per_player":
return PerPlayerPercentage(spec["percentage"])
if kind == "combined":
return CombinedPercentage(spec["percentage"])
if kind == "weighted_low_high":
return WeightedLowHigh(spec["low_weight"], spec["high_weight"])
if kind == "ranked_split":
return RankedSplit(tuple(spec["weights"]))
raise ValueError(f"Ukjent allowance-strategitype: {kind!r}")
def parse_allowance_config(format_: str, allowance_override: dict | None) -> AllowanceConfig:
"""ADR-014: fire uavhengige brytere. Manglende nøkler = på/standard."""
override = allowance_override or {}
return AllowanceConfig(
use_handicap=override.get("use_handicap", True),
use_course_handicap=override.get("use_course_handicap", True),
use_matchplay_handicap=override.get("use_matchplay_handicap", True),
strategy=_strategy_from_json(format_, override.get("strategy")),
)
async def compute_and_store_side_handicaps(
conn: Connection,
match_id: str,
team_side: str,
format_: str,
hole_config: str,
config: AllowanceConfig,
) -> None:
"""Beregn og lagre course_handicap/playing_handicap for én side i en match.
Kalles fra matches.py sin add_participant RETT ETTER hver innsetting.
Avgjør selv om den er klar til å beregne:
- singles/fourball (enhet = spiller): beregner uansett, for hver
deltaker som finnes (idempotent -- trygt å kalle flere ganger).
- foursome/greensome/scramble (enhet = side): beregner KUN når siden har
nøyaktig FORMAT_UNIT_SIZE[format] deltakere OG alle har en matchende
tee_rating -- ellers returnerer den uten å skrive noe (se
modul-docstring).
"""
participants = await conn.fetch(
"""
SELECT mp.id::text AS id,
tr.handicap_index_snapshot::float AS handicap_index_snapshot,
tee_rating.course_rating::float AS course_rating,
tee_rating.slope_rating::float AS slope_rating,
tee_rating.par AS par
FROM match_participant mp
JOIN team_roster tr ON tr.id = mp.team_roster_id
JOIN tee_rating ON tee_rating.tee_id = mp.tee_id AND tee_rating.scope = $3::rating_scope
WHERE mp.match_id = $1 AND mp.team_side = $2::team_side
""",
match_id,
team_side,
hole_config,
)
if format_ in _SIDE_IS_UNIT:
expected = FORMAT_UNIT_SIZE[format_]
total_on_side = await conn.fetchval(
"SELECT count(*) FROM match_participant WHERE match_id = $1 AND team_side = $2::team_side",
match_id,
team_side,
)
if total_on_side != expected or len(participants) != expected:
return # ikke komplett ennå, eller mangler tee_rating for noen
if not participants:
return
if not config.use_handicap:
for p in participants:
await conn.execute(
"UPDATE match_participant SET course_handicap = 0, playing_handicap = 0 WHERE id = $1",
p["id"],
)
return
course_handicaps = [
course_handicap_raw(p["handicap_index_snapshot"], p["slope_rating"], p["course_rating"], p["par"])
if config.use_course_handicap
else p["handicap_index_snapshot"]
for p in participants
]
if format_ in _SIDE_IS_UNIT:
playing = config.strategy.playing_handicap(course_handicaps)
for p, ch in zip(participants, course_handicaps):
await conn.execute(
"UPDATE match_participant SET course_handicap = $1, playing_handicap = $2 WHERE id = $3",
round_half_up(ch),
playing,
p["id"],
)
else:
for p, ch in zip(participants, course_handicaps):
playing = config.strategy.playing_handicap([ch])
await conn.execute(
"UPDATE match_participant SET course_handicap = $1, playing_handicap = $2 WHERE id = $3",
round_half_up(ch),
playing,
p["id"],
)
async def relative_strokes_for_match(
conn: Connection, match_id: str, format_: str, config: AllowanceConfig
) -> dict[str, int]:
"""Relative slag per scoringsenhet i matchen.
Nøkkel = match_participant_id for singles/fourball (fourball: ALLE fire
spillerne i matchen samlet i én liste før match_play_strokes -- jf. R&A-
regelen om at laveste av alle fire spiller av scratch, ikke side-vis).
Nøkkel = team_side ('a'/'b') for foursome/greensome/scramble.
Returnerer {} hvis handicap ikke er beregnet for én eller flere enheter
ennå (se compute_and_store_side_handicaps) -- kalleren tolker det som
"kan ikke avgjøre noen hull ennå".
"""
if format_ in _SIDE_IS_UNIT:
rows = await conn.fetch(
"""
SELECT DISTINCT ON (team_side) team_side::text AS unit, playing_handicap
FROM match_participant
WHERE match_id = $1
ORDER BY team_side
""",
match_id,
)
else:
rows = await conn.fetch(
"SELECT id::text AS unit, playing_handicap FROM match_participant WHERE match_id = $1",
match_id,
)
if not rows or any(r["playing_handicap"] is None for r in rows):
return {}
absolutes = [r["playing_handicap"] for r in rows]
relative = match_play_strokes(absolutes) if config.use_matchplay_handicap else absolutes
return {r["unit"]: v for r, v in zip(rows, relative)}