Coverage for packages / core / ratings / track_conditions.py: 0%
68 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-08 08:37 +1200
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-08 08:37 +1200
1"""Track condition adjustments for Elo ratings.
3Foundational stub for a model that learns how different track conditions
4(wet, dry, heavy, fast) affect horse/driver/trainer performance.
6The core idea:
7 - Some horses perform significantly better on wet tracks ("mudders") while
8 others prefer fast, dry surfaces.
9 - We maintain per-horse track condition adjustments that are learned from
10 historical performance residuals, similar to how barrier/handicap
11 adjustments are learned.
12 - These adjustments are applied to the effective rating: a horse with a
13 positive wet-track adjustment gets a rating boost when the track is heavy.
15This module is **not yet integrated** into the main ``RatingEngine``.
16Integration would require:
17 1. Adding a ``TrackConditionAdjustment`` model (DB table).
18 2. Loading adjustments into the engine via a repository.
19 3. Applying the adjustment in ``compute_effective_rating`` when a race's
20 ``track_condition`` field is populated.
21"""
23from __future__ import annotations
25from dataclasses import dataclass, field
26from enum import StrEnum
29class TrackConditionCategory(StrEnum):
30 """Broad categorisation of track conditions.
32 Mapped from raw track condition strings (e.g. "Good3", "Heavy10",
33 "Soft5", "Fast") into these categories for adjustment learning.
34 """
36 FAST = "fast" # Firm / fast / hard
37 GOOD = "good" # Good / dead
38 SOFT = "soft" # Soft / slow / easy
39 HEAVY = "heavy" # Heavy / slop / muddy
40 UNKNOWN = "unknown"
43# ── Mapping helpers ──────────────────────────────────────────────────────
46def _categorise_track(condition: str | None) -> TrackConditionCategory:
47 """Map a raw track condition string to a category.
49 Handles common NZ/AU track rating formats:
50 - "Good3", "Good4" → ``GOOD``
51 - "Soft5", "Soft6" → ``SOFT``
52 - "Heavy8", "Heavy10" → ``HEAVY``
53 - "Fast", "Firm" → ``FAST``
54 - "Dead" → ``GOOD``
55 - "Slow" → ``SOFT``
56 """
57 if not condition:
58 return TrackConditionCategory.UNKNOWN
60 c = condition.strip().lower()
62 for keyword, cat in [
63 ("heavy", TrackConditionCategory.HEAVY),
64 ("soft", TrackConditionCategory.SOFT),
65 ("slow", TrackConditionCategory.SOFT),
66 ("slop", TrackConditionCategory.HEAVY),
67 ("muddy", TrackConditionCategory.HEAVY),
68 ("good", TrackConditionCategory.GOOD),
69 ("dead", TrackConditionCategory.GOOD),
70 ("fast", TrackConditionCategory.FAST),
71 ("firm", TrackConditionCategory.FAST),
72 ("hard", TrackConditionCategory.FAST),
73 ]:
74 if keyword in c:
75 return cat
77 return TrackConditionCategory.UNKNOWN
80# ── Track condition model ────────────────────────────────────────────────
83@dataclass
84class TrackConditionModel:
85 """Learn and apply track condition adjustments.
87 Maintains a dictionary of ``(entity_type, entity_id, condition_category)``
88 → adjustment value, learned from performance residuals.
89 """
91 adjustments: dict[tuple[str, int, str], float] = field(default_factory=dict)
92 sample_counts: dict[tuple[str, int, str], int] = field(default_factory=dict)
94 # Learning parameters
95 learning_rate: float = 0.1
96 min_samples: int = 5
97 max_adjustment: float = 50.0
99 # ── Learning ─────────────────────────────────────────────────────
101 def learn_from_performance(
102 self,
103 entity_type: str,
104 entity_id: int,
105 track_condition: str | None,
106 performance_residual: float,
107 ) -> None:
108 """Update the adjustment for an entity + track condition.
110 ``performance_residual`` measures how much the entity outperformed
111 (positive) or underperformed (negative) expectations, in rating
112 points. This is analogous to the residual computed in
113 ``RatingEngine.learn_adjustments_from_race``.
115 Args:
116 entity_type: ``"horse"``, ``"driver"``, or ``"trainer"``.
117 entity_id: Entity ID.
118 track_condition: Raw track condition string (e.g. ``"Good3"``).
119 performance_residual: Rating delta to attribute to the condition.
120 """
121 category = _categorise_track(track_condition)
122 if category == TrackConditionCategory.UNKNOWN:
123 return
125 key = (entity_type, entity_id, category.value)
127 current = self.adjustments.get(key, 0.0)
128 count = self.sample_counts.get(key, 0)
130 # Incremental moving average
131 new_count = count + 1
132 new_adjustment = current + self.learning_rate * (performance_residual - current)
133 new_adjustment = max(
134 -self.max_adjustment, min(self.max_adjustment, new_adjustment)
135 )
137 self.adjustments[key] = new_adjustment
138 self.sample_counts[key] = new_count
140 # ── Application ───────────────────────────────────────────────────
142 def get_adjustment(
143 self,
144 entity_type: str,
145 entity_id: int,
146 track_condition: str | None,
147 ) -> float:
148 """Get the track condition adjustment for an entity.
150 Returns 0.0 if there are insufficient samples.
152 Args:
153 entity_type: ``"horse"``, ``"driver"``, or ``"trainer"``.
154 entity_id: Entity ID.
155 track_condition: Raw track condition string.
157 Returns:
158 Adjustment value in rating points (positive = better on this
159 surface, negative = worse).
160 """
161 category = _categorise_track(track_condition)
162 if category == TrackConditionCategory.UNKNOWN:
163 return 0.0
165 key = (entity_type, entity_id, category.value)
166 count = self.sample_counts.get(key, 0)
168 if count < self.min_samples:
169 return 0.0
171 return self.adjustments.get(key, 0.0)
173 # ── Population-level stats ────────────────────────────────────────
175 def get_population_adjustment(
176 self,
177 track_condition: str | None,
178 ) -> float:
179 """Get the average adjustment across all entities for a condition.
181 Useful as a fallback when no per-entity data is available.
183 Args:
184 track_condition: Raw track condition string.
186 Returns:
187 Population-average adjustment.
188 """
189 category = _categorise_track(track_condition)
190 if category == TrackConditionCategory.UNKNOWN:
191 return 0.0
193 vals = [
194 adj
195 for (_, _, cat), adj in self.adjustments.items()
196 if cat == category.value
197 ]
198 if not vals:
199 return 0.0
200 return sum(vals) / len(vals)
202 def reset(self) -> None:
203 """Clear all learned adjustments (for re-learning)."""
204 self.adjustments.clear()
205 self.sample_counts.clear()
208# ── Example / test usage ─────────────────────────────────────────────────
211def _demo() -> None:
212 """Demonstrate track condition learning and application."""
213 model = TrackConditionModel(learning_rate=0.15, min_samples=3)
215 print("Track Condition Model — demo")
216 print()
218 # Simulate a "mudder" horse that performs better on wet tracks
219 print("Simulating a horse that performs well on heavy tracks...")
220 for _ in range(10):
221 # Residual: +20 rating points on heavy tracks
222 model.learn_from_performance("horse", 42, "Heavy10", 20.0)
223 # Residual: -5 on good tracks
224 model.learn_from_performance("horse", 42, "Good3", -5.0)
226 print(f" Horse 42 on Heavy: {model.get_adjustment('horse', 42, 'Heavy10'):+.1f}")
227 print(f" Horse 42 on Good: {model.get_adjustment('horse', 42, 'Good3'):+.1f}")
228 print(
229 f" Horse 42 on Fast: {model.get_adjustment('horse', 42, 'Fast'):+.1f} (no data)"
230 )
231 print(f" Population heavy: {model.get_population_adjustment('Heavy10'):+.1f}")
234if __name__ == "__main__":
235 _demo()