2026-07-16 14:38:42 +02:00
|
|
|
"""
|
|
|
|
|
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 på 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,
|
|
|
|
|
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), så joinen fant alltid null rader. Bekreftet mot ekte
|
|
|
|
|
produksjonsdata (en front_9 foursome-økt med NULL course_handicap/
|
|
|
|
|
playing_handicap på alle fire deltakere).
|
|
|
|
|
|
|
|
|
|
ADR-029 (2026-07-19): ratingen er også kjønnsspesifikk nå (`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).
|
2026-07-16 14:38:42 +02:00
|
|
|
"""
|
|
|
|
|
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
|
2026-07-16 14:38:42 +02:00
|
|
|
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å".
|
|
|
|
|
"""
|
|
|
|
|
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)}
|