1"""Evaluation: prediction metrics and the replay simulation on held-out real trips.2 3Replay: a plan (fixed stop sequences per van, made with some time model) is driven again,4but each leg i -> j departing at time t takes a duration drawn from REAL test-week trips with5the same (pickup zone, drop-off zone, hour), times the van factor. Pools with fewer than6`min_pool` trips back off to hour +-1, then to all hours of that zone pair. The van still7waits if early. We count late deliveries (service start after the window closes).8Orders a plan could not assign count as late (unserved).9"""10from __future__ import annotations11 12from collections import defaultdict13 14import numpy as np15 16from . import config as C17from .search import simulate18from .integration import van_problem19 20 21def metrics(y_true, y_pred) -> dict:22 e = np.asarray(y_pred) - np.asarray(y_true)23 return {"MAE": float(np.abs(e).mean()), "RMSE": float(np.sqrt((e ** 2).mean())),24 "bias": float(e.mean()), "n": int(len(e))}25 26 27def metrics_by_band(trips, pred) -> dict:28 bands = {"night 0-5": (0, 6), "morning 6-9": (6, 10), "midday 10-15": (10, 16),29 "evening 16-19": (16, 20), "late 20-23": (20, 24)}30 out = {}31 for name, (a, b) in bands.items():32 m = (trips.hour.values >= a) & (trips.hour.values < b)33 out[name] = metrics(trips.duration_min.values[m], np.asarray(pred)[m])34 return out35 36 37class ReplaySampler:38 def __init__(self, test_trips, factor: float = C.VAN_FACTOR, min_pool: int = 3):39 self.factor, self.min_pool = factor, min_pool40 self.cell = defaultdict(list)41 self.pair = defaultdict(list)42 for i, j, h, d in zip(test_trips.pu.values, test_trips["do"].values,43 test_trips.hour.values, test_trips.duration_min.values):44 self.cell[(int(i), int(j), int(h))].append(float(d))45 self.pair[(int(i), int(j))].append(float(d))46 self.cell = {k: np.array(v) for k, v in self.cell.items()}47 self.pair = {k: np.array(v) for k, v in self.pair.items()}48 self.backoffs = defaultdict(int)49 50 def pool(self, i, j, t):51 h = int(t // 60) % 2452 p = self.cell.get((i, j, h))53 if p is not None and len(p) >= self.min_pool:54 return p55 near = [self.cell.get((i, j, hh)) for hh in (h - 1, h, h + 1)]56 near = [x for x in near if x is not None]57 if near and sum(len(x) for x in near) >= self.min_pool:58 self.backoffs["hour+-1"] += 159 return np.concatenate(near)60 if (i, j) in self.pair:61 self.backoffs["all-hours"] += 162 return self.pair[(i, j)]63 self.backoffs["reverse-pair"] += 164 return self.pair.get((j, i), np.array([10.0]))65 66 def travel_fn(self, rng):67 def f(i, j, t):68 p = self.pool(int(i), int(j), t)69 return float(p[rng.integers(len(p))]) * self.factor70 return f71 72 73def replay(inst, result, sampler: ReplaySampler, tm, n_reps: int = 30, seed: int = 0) -> dict:74 """Late deliveries when the plan's sequences are driven with real held-out durations."""75 rng = np.random.default_rng(seed)76 served = {i for r in result.routes.values() for i in r.sequence}77 unserved = len(inst.orders) - len(served)78 lates, minutes = [], []79 for _ in range(n_reps):80 fn = sampler.travel_fn(rng)81 n_late, tot = unserved, 0.082 for v in inst.vans:83 r = result.routes.get(v.id)84 if r is None or not r.sequence:85 continue86 prob = van_problem(inst, tm, v, r.sequence)87 sim = simulate(prob, list(range(len(r.sequence))), travel=fn)88 n_late += sim.n_late89 tot += sum(sim.lateness)90 lates.append(n_late); minutes.append(tot)91 return {"late_mean": float(np.mean(lates)), "late_std": float(np.std(lates)),92 "late_minutes_mean": float(np.mean(minutes)), "unserved": unserved,93 "late_frac": float(np.mean(lates)) / max(1, len(inst.orders))}