Trifecta-Lab / trifecta_bro /data /normalizer.py
Brettapps's picture
Upload folder using huggingface_hub (part 21)
e23172f verified
Raw History Blame Contribute Delete
3.71 kB
"""Normalise raw FormFav payloads into internal models.
Key responsibilities:
- drop scratched runners from the active set (but keep record)
- mark abandoned races
- never invent missing values; track data_completeness instead
- tolerate missing `form` (fall back to `last20Starts`)
"""
from __future__ import annotations
import logging
from typing import Any
from .models import RaceModel, RunnerModel, MeetingModel
logger = logging.getLogger("trifecta_bro.normalizer")
EXPECTED_RUNNER_FIELDS = [
"name", "jockey", "trainer", "weight", "barrier", "age",
"careerPrizeMoney", "form", "stats",
]
def _to_float(value: Any) -> float | None:
if value is None:
return None
try:
return float(str(value).replace("$", "").replace(",", ""))
except (ValueError, TypeError):
return None
def _normalise_runner(raw: dict[str, Any]) -> RunnerModel:
present = sum(1 for f in EXPECTED_RUNNER_FIELDS if raw.get(f) not in (None, "", {}))
completeness = present / len(EXPECTED_RUNNER_FIELDS)
return RunnerModel(
number=int(raw.get("number", 0)),
name=raw.get("name", "") or "",
jockey=raw.get("jockey"),
trainer=raw.get("trainer"),
weight=_to_float(raw.get("weight")),
barrier=int(raw["barrier"]) if raw.get("barrier") not in (None, "") else None,
age=int(raw["age"]) if raw.get("age") not in (None, "") else None,
sex=raw.get("sex"),
career_prize_money=_to_float(raw.get("careerPrizeMoney")),
form=raw.get("form") or raw.get("last20Starts") or "",
last20_starts=raw.get("last20Starts") or raw.get("form") or "",
scratched=bool(raw.get("scratched", False)),
stats=raw.get("stats", {}) or {},
data_completeness=round(completeness, 3),
)
def normalise_race(payload: dict[str, Any]) -> RaceModel:
warnings: list[str] = []
raw_runners = payload.get("runners", []) or []
runners = [_normalise_runner(r) for r in raw_runners]
active = [r for r in runners if not r.scratched]
if len(active) < 3:
warnings.append(f"only {len(active)} active runners after scratches")
# distance may be like "1200m"
dist = payload.get("distance")
nrun = payload.get("numberOfRunners")
if nrun is None:
nrun = len(active)
return RaceModel(
date=payload.get("date", ""),
track=payload.get("track", ""),
track_slug=payload.get("trackSlug") or payload.get("slug") or payload.get("track", "").lower(),
race_number=int(payload.get("raceNumber", payload.get("race", 0))),
race_name=payload.get("raceName", "") or "",
distance=str(dist) if dist is not None else None,
condition=payload.get("condition"),
weather=payload.get("weather"),
race_class=payload.get("raceClass"),
abandoned=bool(payload.get("abandoned", False)),
start_time=payload.get("startTime"),
timezone=payload.get("timezone"),
prize_money=str(payload.get("prizeMoney")) if payload.get("prizeMoney") is not None else None,
number_of_runners=int(nrun) if nrun is not None else 0,
runners=runners,
source_warnings=warnings,
)
def normalise_meeting(raw: dict[str, Any]) -> MeetingModel:
races = []
for r in raw.get("races", []) or []:
rn = r.get("raceNumber") if isinstance(r, dict) else None
if rn is not None:
races.append(int(rn))
return MeetingModel(
track=raw.get("track", ""),
slug=raw.get("slug", "") or raw.get("track", "").lower(),
country=raw.get("country", ""),
abandoned=bool(raw.get("abandoned", False)),
races=sorted(set(races)),
)