teecup/app/handicap.py

260 lines
11 KiB
Python
Raw Permalink 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,
}
# Nye formater denne runden (2026-07-30) som er strukturelt IDENTISKE med et
# eksisterende format -- ingen egen HCP-/allowance-logikk, bare et annet
# selvdeklarert navn. Løses opp HELT FØRST i parse_allowance_config/
# compute_and_store_side_handicaps/relative_strokes_for_match, slik at resten
# av disse funksjonene (og SIDE_IS_UNIT/FORMAT_UNIT_SIZE/Format-enumet)
# aldri ser aliasnavnet. Chapman/Pinehurst ("Try all", bekreftet av bruker):
# delt ball, én score per hull per side, CombinedPercentage 50% -- identisk
# med foursome, kun regler for HVEM som slår hvilket slag (ikke sporet i
# skjemaet, verken her eller for foursome).
ENGINE_FORMAT_ALIASES: dict[str, str] = {
"chapman": "foursome",
# Shamble (2026-07-30) -- variabel lagstørrelse (2-4), enheten er
# SPILLEREN (som fourball/singles), ikke siden -- "singles" sin
# PerPlayerPercentage(1.00) er telle-agnostisk (regner ett tall per
# spiller uavhengig av hvor mange andre er på laget), i motsetning til
# foursome/greensome/scramble sine faste antalls-strategier.
# Kildens hullscore-regel (beste N av M) bruker rå brutto, ikke HCP-
# justert netto -- course_handicap/playing_handicap beregnes og lagres
# likevel her (for visning/et fremtidig steg), men brukes IKKE av
# selve hullscore-utregningen (se scoring.py sin _side_net shamble-gren).
"shamble": "singles",
# Money Ball/Lone Ranger (2026-07-30) -- FAST 4-manns lag, enheten er
# SPILLEREN (som Shamble/fourball), ikke siden. Rå brutto brukes for
# selve hullscoren (se scoring.py sin _compute_money_ball_hole_results),
# course_handicap/playing_handicap beregnes og lagres likevel for
# visning (samme begrunnelse som Shamble).
"money_ball": "singles",
# High-low-high (2026-07-30) -- 2 lag à 2, individuell ball. course_
# handicap/playing_handicap beregnes som fourball (samme "alle fire
# samlet i én match_play_strokes-pool"-regel, R&A) -- selve poeng-
# rangeringen (high/low per hull) er en HELT NY, egen beregning (se
# app/routers/rounds.py sin _build_high_low_high_result), ikke del av
# denne aliaseringen.
"high_low_high": "fourball",
}
@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."""
format_ = ENGINE_FORMAT_ALIASES.get(format_, format_)
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,
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).
Update Todos Diagnostisere HCP-bug mot ekte data (gjort) Fiks 1: filtrer valgte spillere bort fra listen (blind draw) Fiks 3: tallvelger for slagregistrering (scorekort) Fiks 4: hardkode full_18-scope i handicap-utregning (bekreftet bug) Punkt 2: migrasjon 014 + kode (courses.py, handicap.py, matches.py, tournaments.py, frontend) Scratch-verifisere punkt 2 (fletting, import, validering, remap) Skrive ADR-029 + oppdatere FEATURE_BACKLOG.md Presentere full plan og be om bekreftelse (ekte migrasjon + deploy) Alle fire punkter er nå bygget og grundig scratch-verifisert. Oppsummering før jeg ber om bekreftelse på å rulle ut mot ekte systemer: 1 — Spillerliste: Fikset. Valgte spillere filtreres nå bort i stedet for å bare vises gråtonet. 3 — Tallvelger: Fikset. Ny StrokePicker (1–9, med utvidbar "10+"→10–19). 4 — HCP-bug: Bekreftet ekte kodebug (ikke bare synlighet). Jeg leste ekte produksjonsdata for testøkten din: front_9 + foursome, og alle fire deltakerne hadde NULL i handicap-feltene. Årsak: handicap-utregningen lette etter en tee-rating scopet til øktens hole_config, men slike rader lages i praksis kun med full_18-omfang — så den fant aldri noe for en front_9-økt. Din beskrevne formel (kombinert hcp/2, laveste side til 0 mottatte slag, resten fordelt fra stroke index 1) var allerede riktig implementert — bugen var at den aldri kjørte. Fikset og verifisert: identisk bruttoscore på begge sider ga nå et ikke-delt resultat, som beviser at hcp faktisk brukes. 2 — Tee/kjønn: Du hadde helt rett i presiseringen din — dette var større enn jeg først trodde. Kjønn er nå flyttet fra selve utslaget til ratingen (ny migrasjon 014_tee_gender_to_rating.sql), tee-valget i blind draw er blitt helt automatisk (ingen kjønnsvelger lenger), og manglende kjønn/rating avvises tydelig i stedet for å gjettes. Fikk egen ADR-029. Verifisert i fem separate runder, inkludert mot ekte teeoff-data (Borregaard) og en gjenskaping av akkurat Tjøme-mønsteret. Bifunn, ikke fikset: en liten, urelatert 500-krasj (stroke-registrering på en bane uten registrerte hull) — vil du at jeg tar den også, eller lar den ligge som egen sak? Utrullingsplan — dette er første gang i denne runden noe rører ekte data: Migrasjon 014_tee_gender_to_rating.sql mot ekte teecup_db — slår sammen Tjømes 8 tee-rader til 4 (bevarer alle ratinger, reparerer alle referanser; mekanisk identisk med det jeg nettopp verifiserte i scratch)
2026-07-20 08:53:53 +02:00
Bruker ALLTID tee-ens full_18-rating (ADR-008), uavhengig av øktens
hole_config -- front/back-9-fordelingen skjer senere, ved selve
slagtildelingen (`allocate_over_played_holes` i scoring.py), ikke her.
FIKSET 2026-07-19: joinet tidligere mot øktens hole_config som
rating-scope direkte, som gjorde at en front_9/back_9-økt ALDRI fikk
beregnet handicap i praksis -- ingen front_9/back_9-scopet tee_rating
har noensinne blitt opprettet (kun full_18, se ADR-019 Beslutning D og
tee-endepunktet), joinen fant alltid null rader. Bekreftet mot ekte
produksjonsdata (en front_9 foursome-økt med NULL course_handicap/
playing_handicap alle fire deltakere).
ADR-029 (2026-07-19): ratingen er også kjønnsspesifikk (`tee_rating.
gender`) -- ett fysisk utslag (`tee`) kan ha én rating per kjønn, aldri
én felles. Joines derfor også mot spillerens `player.gender`. Krever at
`matches.py` sin `add_participant` har validert FØR innsetting at
spilleren har et registrert kjønn OG at valgt utslag faktisk har en
matchende rating -- denne funksjonen forventer at det alltid stemmer,
den validerer ikke selv (samme mønster som for manglende
handicap_index_snapshot).
"""
format_ = ENGINE_FORMAT_ALIASES.get(format_, format_)
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
Update Todos Diagnostisere HCP-bug mot ekte data (gjort) Fiks 1: filtrer valgte spillere bort fra listen (blind draw) Fiks 3: tallvelger for slagregistrering (scorekort) Fiks 4: hardkode full_18-scope i handicap-utregning (bekreftet bug) Punkt 2: migrasjon 014 + kode (courses.py, handicap.py, matches.py, tournaments.py, frontend) Scratch-verifisere punkt 2 (fletting, import, validering, remap) Skrive ADR-029 + oppdatere FEATURE_BACKLOG.md Presentere full plan og be om bekreftelse (ekte migrasjon + deploy) Alle fire punkter er nå bygget og grundig scratch-verifisert. Oppsummering før jeg ber om bekreftelse på å rulle ut mot ekte systemer: 1 — Spillerliste: Fikset. Valgte spillere filtreres nå bort i stedet for å bare vises gråtonet. 3 — Tallvelger: Fikset. Ny StrokePicker (1–9, med utvidbar "10+"→10–19). 4 — HCP-bug: Bekreftet ekte kodebug (ikke bare synlighet). Jeg leste ekte produksjonsdata for testøkten din: front_9 + foursome, og alle fire deltakerne hadde NULL i handicap-feltene. Årsak: handicap-utregningen lette etter en tee-rating scopet til øktens hole_config, men slike rader lages i praksis kun med full_18-omfang — så den fant aldri noe for en front_9-økt. Din beskrevne formel (kombinert hcp/2, laveste side til 0 mottatte slag, resten fordelt fra stroke index 1) var allerede riktig implementert — bugen var at den aldri kjørte. Fikset og verifisert: identisk bruttoscore på begge sider ga nå et ikke-delt resultat, som beviser at hcp faktisk brukes. 2 — Tee/kjønn: Du hadde helt rett i presiseringen din — dette var større enn jeg først trodde. Kjønn er nå flyttet fra selve utslaget til ratingen (ny migrasjon 014_tee_gender_to_rating.sql), tee-valget i blind draw er blitt helt automatisk (ingen kjønnsvelger lenger), og manglende kjønn/rating avvises tydelig i stedet for å gjettes. Fikk egen ADR-029. Verifisert i fem separate runder, inkludert mot ekte teeoff-data (Borregaard) og en gjenskaping av akkurat Tjøme-mønsteret. Bifunn, ikke fikset: en liten, urelatert 500-krasj (stroke-registrering på en bane uten registrerte hull) — vil du at jeg tar den også, eller lar den ligge som egen sak? Utrullingsplan — dette er første gang i denne runden noe rører ekte data: Migrasjon 014_tee_gender_to_rating.sql mot ekte teecup_db — slår sammen Tjømes 8 tee-rader til 4 (bevarer alle ratinger, reparerer alle referanser; mekanisk identisk med det jeg nettopp verifiserte i scratch)
2026-07-20 08:53:53 +02:00
JOIN player p ON p.id = tr.player_id
JOIN tee_rating ON tee_rating.tee_id = mp.tee_id
AND tee_rating.scope = 'full_18'
AND tee_rating.gender = p.gender
WHERE mp.match_id = $1 AND mp.team_side = $2::team_side
""",
match_id,
team_side,
)
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å".
"""
format_ = ENGINE_FORMAT_ALIASES.get(format_, format_)
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)}