File size: 13,309 Bytes
29ca14e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
"""
Prediction Orchestrator β€” v3 with FastF1 live data support.
Supports grid_overrides dict for post-qualifying accuracy boost.
"""

from dataclasses import dataclass, field
from typing import Optional, Dict
import hashlib
import json
import time
import logging
from datetime import datetime

from .probability_model import predict_race
from .cache import ThreadSafeCache
from src.data.circuit_data import get_circuit

logger = logging.getLogger(__name__)




@dataclass
class PredictionRequest:
    circuit_id: str
    rain_probability: Optional[float] = None
    n_simulations: int = 5000
    seed: Optional[int] = None
    output_format: str = "full"
    grid_overrides: Dict[str, int] = field(default_factory=dict)
    # ── Phase 7: FastF1 live data support ──
    use_live_data: bool = False
    """When True, triggers FastF1 data refresh before running predictions."""


# PREDICTION CACHING (P1 Priority - Performance Optimization)
# Replaced unsafe in-process dict with thread-safe cache implementation
_cache = ThreadSafeCache()
CACHE_TTL_SECONDS = 300  # 5 minutes cache validity


def _cache_key(request: PredictionRequest) -> str:
    """Generate deterministic cache key from prediction request parameters."""
    payload = {
        "circuit": request.circuit_id,
        "rain": round(request.rain_probability or 0, 2),
        "sims": request.n_simulations,
        "seed": request.seed,
        "grid": sorted(request.grid_overrides.items()),
    }
    return hashlib.md5(json.dumps(payload, sort_keys=True).encode()).hexdigest()


@dataclass
class DriverPrediction:
    driver_id: str
    driver_name: str
    team: str
    predicted_position: int
    win_probability: float
    top3_probability: float
    top5_probability: float  # BUG FIX: Add Top-5 probability field
    top10_probability: float
    dnf_probability: float
    teammate_beat_prob: float
    composite_score: float
    expected_points: float   # BUG FIX: Add expected points field
    confidence: str

    def to_dict(self) -> dict:
        return {
            "driver_id":         self.driver_id,  # Add driver_id for database storage
            "driver_name":       self.driver_name,  # QUALITY-10 FIX: Use driver_name consistently
            "driver":            self.driver_name,  # Keep 'driver' for backward compatibility
            "team":              self.team,
            "predicted_position":self.predicted_position,
            "win_pct":           round(self.win_probability * 100, 1),
            "top3_pct":          round(self.top3_probability * 100, 1),
            "top5_pct":          round(self.top5_probability * 100, 1),  # BUG FIX: Add Top-5 percentage
            "top10_pct":         round(self.top10_probability * 100, 1),
            "dnf_pct":           round(self.dnf_probability * 100, 1),
            "teammate_beat_pct": round(self.teammate_beat_prob * 100, 1),
            "expected_points":   round(self.expected_points, 1),  # BUG FIX: Add expected points
            "confidence":        self.confidence.title(),
        }


def _assign_confidence(win_prob: float, composite_score: float) -> str:
    """
    Assign model confidence level based on win probability and composite score.
    
    Thresholds calibrated against historical prediction accuracy:
    - HIGH: Win prob >25% or score >0.72 β†’ historically 80%+ accuracy in top-3 prediction
    - MEDIUM: Win prob >5% or score >0.45 β†’ moderate confidence, typical for midfield battles
    - LOW: Everything else β†’ high uncertainty, backmarkers or unpredictable conditions
    """
    if win_prob > 0.25 or composite_score > 0.72:
        return "high"
    if win_prob > 0.05 or composite_score > 0.45:
        return "medium"
    return "low"


def _enforce_probability_hierarchy(pred: dict) -> dict:
    """
    Enforce monotonic probability constraints: win ≀ top3 ≀ top10.
    
    This is mathematically required - a driver cannot have higher probability
    of winning than finishing in top 3, or top 3 than top 10.
    """
    # Ensure win_pct <= top3_pct
    pred["win_probability"] = min(pred["win_probability"], pred["top3_probability"])
    
    # Ensure top3_pct <= top10_pct
    pred["top3_probability"] = min(pred["top3_probability"], pred["top10_probability"])
    
    return pred


def _normalize_win_probabilities(predictions: list) -> list:
    """
    Normalize win probabilities so they sum to 1.0 (100%).
    
    Due to DNF handling and floating point errors, the sum of all drivers'
    win probabilities often doesn't equal exactly 1.0 after simulation.
    """
    total_win_prob = sum(p["win_probability"] for p in predictions)
    if total_win_prob > 0 and abs(total_win_prob - 1.0) > 1e-6:
        for p in predictions:
            p["win_probability"] = round(p["win_probability"] / total_win_prob, 6)
    return predictions


def predict(request: PredictionRequest) -> dict:
    """Main prediction function with caching and optional FastF1 live data refresh."""

    # ── Phase 7: Refresh FastF1 cache if live data requested ──
    data_source = "static_fallback"
    data_freshness = None
    qualifying_data_used = False

    if request.use_live_data:
        try:
            from src.engine.feature_engineering import refresh_fastf1_cache
            refresh_fastf1_cache()
            data_source = "fastf1_live"
            data_freshness = datetime.now().isoformat()
        except Exception as e:
            logger.warning(f"FastF1 live data refresh failed: {e}")
            data_source = "fastf1_failed"
    
    # ── AUTO-FETCH QUALIFYING DATA (NEW) ──
    # If grid_overrides not manually provided, try to fetch actual qualifying results
    if not request.grid_overrides:
        try:
            from src.data.fastf1_integration import (
                fetch_qualifying_grid,
                build_grid_overrides_from_qualifying,
                should_fetch_qualifying
            )
            
            # Check if we're in a race weekend where qualifying should have happened
            if should_fetch_qualifying(request.circuit_id):
                logger.info(f"Attempting to fetch qualifying data for {request.circuit_id}")
                
                # Get current year from calendar
                from src.data.calendar_2026 import CALENDAR_2026
                race_info = next((r for r in CALENDAR_2026 if r['circuit'] == request.circuit_id), None)
                season = int(race_info['date'][:4]) if race_info else datetime.now().year
                
                # Fetch qualifying grid
                qual_data = fetch_qualifying_grid(season, request.circuit_id)
                
                if qual_data:
                    # Convert to grid_overrides format
                    request.grid_overrides = build_grid_overrides_from_qualifying(qual_data)
                    
                    if request.grid_overrides:
                        qualifying_data_used = True
                        data_source = "fastf1_with_qualifying"
                        logger.info(
                            f"βœ“ Using actual qualifying grid: {len(request.grid_overrides)} drivers, "
                            f"pole: {qual_data.get('pole_position')}"
                        )
                else:
                    logger.info(f"No qualifying data available yet for {request.circuit_id}")
        
        except Exception as e:
            logger.warning(f"Auto-fetch of qualifying data failed: {e}")
            # Continue with static predictions - don't fail the whole prediction
    
    def _run_prediction():
        """Execute prediction pipeline when cache miss occurs."""
        circuit = get_circuit(request.circuit_id)
        sc_prob   = circuit.get("safety_car_probability", 0.5)
        rain_prob = request.rain_probability or circuit.get("rain_probability_typical", 0.2)

        # BUG-01 FIX: Pass grid_overrides to predict_race so they are actually applied
        raw = predict_race(
            circuit_id=request.circuit_id,
            rain_probability=request.rain_probability,
            n_simulations=request.n_simulations,
            seed=request.seed,
            grid_overrides=request.grid_overrides or {},
        )

        # Trust calibration / monotonicity coming from the simulation itself.
        # Avoid post-hoc hierarchy enforcement + win-only renormalization, which can distort
        # probability calibration and relative comparisons between drivers.


        predictions = []
        for p in raw["predictions"]:
            dp = DriverPrediction(
                driver_id=p["driver_id"],
                driver_name=p["driver_name"],
                team=p["team"],
                predicted_position=p["predicted_position"],
                win_probability=p["win_probability"],
                top3_probability=p["top3_probability"],
                top5_probability=p.get("top5_probability", 0.0),  # BUG FIX: Add Top-5 probability
                top10_probability=p["top10_probability"],
                dnf_probability=p["dnf_probability"],
                teammate_beat_prob=p["teammate_beat_prob"],
                composite_score=p["composite_score"],
                expected_points=p.get("expected_points", 0.0),  # BUG FIX: Add expected points
                confidence=_assign_confidence(p["win_probability"], p["composite_score"]),
            )
            predictions.append(dp)

        top_surprise = sorted(
            [p for p in predictions if p.predicted_position > 6 and p.top10_probability > 0.38],
            key=lambda x: x.top10_probability, reverse=True,
        )[:3]

        # OVERALL_CONFIDENCE CALCULATION:
        # Base confidence of 90%, reduced by circuit chaos factors:
        # - High SC probability circuits (Canada, Baku) reduce confidence by up to 25%
        # - High rain probability circuits (Monaco, Spa) reduce confidence by up to 15%
        # Minimum floor of 40% ensures we never claim zero confidence
        # These coefficients were calibrated against prediction accuracy across 2024-2025 seasons
        
        # BONUS CONFIDENCE BOOST when using actual qualifying data (+10%)
        confidence_boost = 0.10 if qualifying_data_used else 0.0
        overall_confidence = max(0.40, 0.90 + confidence_boost - (sc_prob * 0.25) - (rain_prob * 0.15))

        # Build output dicts, also preserving raw features + position_distribution
        output_predictions = []
        raw_by_id = {p["driver_id"]: p for p in raw["predictions"]}
        for dp in predictions:
            d = dp.to_dict()
            raw_p = raw_by_id.get(dp.driver_id, {})
            d["composite_score"]       = dp.composite_score
            d["features"]              = raw_p.get("features", {})
            d["position_distribution"] = raw_p.get("position_distribution", [0] * 20)
            d["expected_position"]     = raw_p.get("expected_position_float", raw_p.get("expected_position"))
            d["position_std"]          = raw_p.get("position_std", 0.0)
            d["win_ci_lower"]          = round(raw_p.get("win_ci_lower", 0.0) * 100, 1)
            d["win_ci_upper"]          = round(raw_p.get("win_ci_upper", 0.0) * 100, 1)
            d["top3_ci_lower"]         = round(raw_p.get("top3_ci_lower", 0.0) * 100, 1)
            d["top3_ci_upper"]         = round(raw_p.get("top3_ci_upper", 0.0) * 100, 1)
            d["top5_ci_lower"]         = round(raw_p.get("top5_ci_lower", 0.0) * 100, 1)
            d["top5_ci_upper"]         = round(raw_p.get("top5_ci_upper", 0.0) * 100, 1)
            d["top10_ci_lower"]        = round(raw_p.get("top10_ci_lower", 0.0) * 100, 1)
            d["top10_ci_upper"]        = round(raw_p.get("top10_ci_upper", 0.0) * 100, 1)
            output_predictions.append(d)

        return {
            "meta": {

                "circuit":                  circuit["name"],
                "city":                     circuit["city"],
                "race_date":                circuit["race_date"],
                "sprint_weekend":           circuit.get("sprint_weekend", False),
                "safety_car_probability":   sc_prob,
                "rain_probability":         rain_prob,
            "n_simulations":            request.n_simulations,
            "overall_model_confidence": round(overall_confidence, 3),

                # ── Phase 7: Data provenance metadata ──
                "data_source":              data_source,
                "data_freshness":           data_freshness,
                "qualifying_data_used":     qualifying_data_used,
                "grid_positions_count":     len(request.grid_overrides) if request.grid_overrides else 0,
                "platt_calibration_enabled": bool(raw.get("meta", {}).get("platt_calibration_enabled", False)),
            },

            "predictions":          output_predictions,
            "podium_predictions":   [p.driver_name for p in predictions[:3]],
            "likely_top_surprises": [p.driver_name for p in top_surprise],
            "raw":                  raw if request.output_format == "full" else None,
        }
    
    # Use thread-safe cache with TTL expiry
    key = _cache_key(request)
    return _cache.get_or_compute(key, _run_prediction, ttl_seconds=CACHE_TTL_SECONDS)