Fayzul Islam commited on
Commit
85543b4
·
1 Parent(s): f165109

Fix MILP never solving

Browse files
batteryswap_example/planners/best.pickle CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:6508510aaf0ee366dac78ac2743e6899e8365508af79777929fa3e417d8d25a8
3
- size 201707
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:098ee847de3b9cbe57b2ccdadc65213625cd06585bd2fcb492bb8f8eed56693c
3
+ size 201765
batteryswap_example/train.py CHANGED
@@ -1,18 +1,11 @@
1
-
2
- import pandas
3
- import numpy
4
  import pickle
5
- from typing import Optional, Sequence
6
- from pathlib import Path
7
- import pathlib
8
  import os
 
 
9
 
10
-
11
  import pandas as pd
12
- import numpy
13
  import numpy as np
14
- import pandas
15
- from pydantic import Field
16
  from pydantic_settings import BaseSettings, SettingsConfigDict
17
  import structlog
18
  from ortools.sat.python import cp_model
@@ -23,13 +16,6 @@ from batteryswap_public.evaluate import evaluate_plan, check_plan_valid
23
 
24
  log = structlog.get_logger()
25
 
26
-
27
- # --------------------------------------------------------------------------
28
- # Planner: CP-SAT (MILP-style) assignment of batteries to swap-days,
29
- # accounting for room/building visit costs and daily/weekly worker-hour
30
- # limits. Replaces the OrderedPlanner heuristic.
31
- # --------------------------------------------------------------------------
32
-
33
  COST_SCALE = 600
34
  MINUTES_PER_HOUR = 60
35
 
@@ -54,7 +40,9 @@ def normalize_timeseries(timeseries):
54
  frame[DEVICE_COLUMN] = frame[DEVICE_COLUMN].astype(str)
55
  for column in VALUE_COLUMNS:
56
  frame[column] = pandas.to_numeric(frame[column], errors="coerce")
57
- return frame.sort_values([DEVICE_COLUMN, TIME_COLUMN], kind="stable").reset_index(drop=True)
 
 
58
 
59
 
60
  def _setting(settings, name, default):
@@ -66,7 +54,9 @@ def _setting(settings, name, default):
66
  def _normalize_locations(locations):
67
  frame = locations.copy().reset_index(drop=True)
68
  aliases = {"device_id": "battery", "building_id": "building", "room_id": "room"}
69
- frame = frame.rename(columns={old: new for old, new in aliases.items() if new not in frame})
 
 
70
  required = {"battery", "building", "room"}
71
  missing = required - set(frame.columns)
72
  if missing:
@@ -82,7 +72,10 @@ def _travel_lookup(travel_costs):
82
  missing = required - set(frame.columns)
83
  if missing:
84
  raise ValueError(f"Travel costs is missing required columns: {sorted(missing)}")
85
- return {(str(row["from"]), str(row["to"])): float(row["hours"]) for _, row in frame.iterrows()}
 
 
 
86
 
87
 
88
  def order_daily_route(batteries, locations, travel_costs, base_building):
@@ -98,7 +91,9 @@ def order_daily_route(batteries, locations, travel_costs, base_building):
98
  next_building = min(
99
  buildings,
100
  key=lambda building: (
101
- travel.get((current, building), 0.0 if current == building else float("inf")),
 
 
102
  building,
103
  ),
104
  )
@@ -117,10 +112,23 @@ def order_daily_route(batteries, locations, travel_costs, base_building):
117
 
118
 
119
  class MilpPlanner(Planner):
120
- def __init__(self, rul_estimator, solver_time_limit_seconds=20.0, late_risk_multiplier=1.0):
 
 
 
 
 
 
 
121
  self.rul_estimator = rul_estimator
122
  self.solver_time_limit_seconds = float(solver_time_limit_seconds)
123
  self.late_risk_multiplier = float(late_risk_multiplier)
 
 
 
 
 
 
124
 
125
  def _expected_costs(self, timeseries, batteries, settings):
126
  horizon = int(round(float(_setting(settings, "planning_window_days", 42))))
@@ -131,15 +139,23 @@ class MilpPlanner(Planner):
131
  costs = self.rul_estimator.expected_replacement_costs(
132
  timeseries,
133
  horizon_days=horizon,
134
- early_penalty=float(_setting(settings, "early_replacement_penalty_daily", 0.5)),
135
- late_penalty=float(_setting(settings, "late_replacement_penalty_daily", 10.0))
 
 
 
 
136
  * float(getattr(self, "late_risk_multiplier", 1.0)),
137
  no_swap_extension_days=emergency_delay,
138
  )
139
  expected_columns = list(range(horizon + 1)) + ["no_swap"]
140
  costs = costs.reindex(index=batteries, columns=expected_columns)
141
  finite = costs.to_numpy(dtype=float)
142
- fallback = float(np.nanmax(finite[np.isfinite(finite)])) if np.isfinite(finite).any() else 1e6
 
 
 
 
143
  return costs.replace([np.inf, -np.inf], np.nan).fillna(fallback + 1e3)
144
 
145
  def _solve_assignments(self, expected_costs, locations, travel_costs, settings):
@@ -166,13 +182,22 @@ class MilpPlanner(Planner):
166
  battery_rooms = loc.loc[batteries, "room"].astype(str).to_dict()
167
  battery_buildings = loc.loc[batteries, "building"].astype(str).to_dict()
168
  room_members = {
169
- room: [battery for battery in batteries if battery_rooms[battery] == room] for room in rooms
 
170
  }
171
  building_members = {
172
- building: [battery for battery in batteries if battery_buildings[battery] == building]
 
 
 
 
173
  for building in buildings
174
  }
175
- room_visit = {(room, day): model.new_bool_var(f"room_{room}_{day}") for room in rooms for day in real_days}
 
 
 
 
176
  building_visit = {
177
  (building, day): model.new_bool_var(f"building_{building}_{day}")
178
  for building in buildings
@@ -184,39 +209,61 @@ class MilpPlanner(Planner):
184
  members = room_members[room]
185
  for battery in members:
186
  model.add(assignment[battery, day] <= room_visit[room, day])
187
- model.add(room_visit[room, day] <= sum(assignment[battery, day] for battery in members))
 
 
 
188
  for building in buildings:
189
  members = building_members[building]
190
  for battery in members:
191
  model.add(assignment[battery, day] <= building_visit[building, day])
192
  model.add(
193
- building_visit[building, day] <= sum(assignment[battery, day] for battery in members)
 
194
  )
195
 
196
- battery_minutes = round(float(_setting(settings, "time_per_battery_hours", 0.25)) * 60)
197
- room_minutes = round(float(_setting(settings, "time_per_room_change_hours", 0.5)) * 60)
198
- building_minutes = round(float(_setting(settings, "time_per_building_change_hours", 1.0)) * 60)
 
 
 
 
 
 
199
  building_work = {}
200
  for building in buildings:
201
  round_trip = travel.get((base, building), 0.0 if base == building else 24.0)
202
- round_trip += travel.get((building, base), 0.0 if base == building else 24.0)
203
- building_work[building] = round(round_trip * 60) + (0 if building == base else building_minutes)
 
 
 
 
204
 
205
  maximum_daily = (
206
- len(batteries) * battery_minutes + len(rooms) * room_minutes + sum(building_work.values())
 
 
207
  )
208
  daily_work = {}
209
  daily_overtime = {}
210
  daily_limit_hit = {}
211
  overtime_start = round(float(_setting(settings, "overtime_start", 8.0)) * 60)
212
- daily_limit = round(float(_setting(settings, "worker_limit_daily_hours", 24.0)) * 60)
 
 
213
 
214
  for day in real_days:
215
  work = model.new_int_var(0, maximum_daily, f"work_{day}")
216
  expression = (
217
  battery_minutes * sum(assignment[battery, day] for battery in batteries)
218
- + room_minutes * sum(room_visit[room, day] for room in rooms if room != base_room)
219
- + sum(building_work[building] * building_visit[building, day] for building in buildings)
 
 
 
 
220
  )
221
  model.add(work == expression)
222
  daily_work[day] = work
@@ -230,20 +277,25 @@ class MilpPlanner(Planner):
230
  daily_limit_hit[day] = hit
231
 
232
  weekly_limit_hit = {}
233
- weekly_limit = round(float(_setting(settings, "worker_limit_weekly_hours", 24.0)) * 60)
 
 
234
  for week_start in range(0, len(real_days), 7):
235
  week_days = real_days[week_start : week_start + 7]
236
  hit = model.new_bool_var(f"weekly_limit_hit_{week_start // 7}")
237
  weekly_maximum = maximum_daily * len(week_days)
238
  model.add(
239
- sum(daily_work[day] for day in week_days) <= max(weekly_limit - 1, -1) + weekly_maximum * hit
 
240
  )
241
  weekly_limit_hit[week_start] = hit
242
 
243
  objective_terms = []
244
  for battery_index, battery in enumerate(batteries):
245
  for action_index, action in enumerate(actions):
246
- coefficient = int(round(float(expected_costs.loc[battery, action]) * COST_SCALE))
 
 
247
  coefficient += action_index + battery_index % 3
248
  objective_terms.append(coefficient * assignment[battery, action])
249
 
@@ -251,25 +303,60 @@ class MilpPlanner(Planner):
251
  objective_terms.extend(minute_cost * daily_work[day] for day in real_days)
252
  overtime_factor = float(_setting(settings, "overtime_penalty_factor", 2.0))
253
  overtime_minute_cost = int(round(overtime_factor * minute_cost))
254
- objective_terms.extend(overtime_minute_cost * daily_overtime[day] for day in real_days)
255
- daily_penalty = int(round(float(_setting(settings, "worker_limit_daily_penalty", 100.0)) * COST_SCALE))
256
- weekly_penalty = int(round(float(_setting(settings, "worker_limit_weekly_penalty", 100.0)) * COST_SCALE))
257
- objective_terms.extend(daily_penalty * value for value in daily_limit_hit.values())
258
- objective_terms.extend(weekly_penalty * value for value in weekly_limit_hit.values())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  model.minimize(sum(objective_terms))
260
 
261
  solver = cp_model.CpSolver()
262
  solver.parameters.max_time_in_seconds = self.solver_time_limit_seconds
263
- solver.parameters.num_search_workers = 1
264
  solver.parameters.random_seed = 0
265
  status = solver.solve(model)
 
 
 
 
 
 
 
 
266
  if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
 
267
  return {
268
- battery: min(actions, key=lambda action: (expected_costs.loc[battery, action], str(action)))
 
 
 
 
 
 
269
  for battery in batteries
270
  }
271
  return {
272
- battery: next(action for action in actions if solver.value(assignment[battery, action]))
 
 
 
 
273
  for battery in batteries
274
  }
275
 
@@ -285,33 +372,251 @@ class MilpPlanner(Planner):
285
  start_time = normalized_timeseries["end_time"].max().normalize()
286
 
287
  expected_costs = self._expected_costs(timeseries, batteries, settings)
288
- assignments = self._solve_assignments(expected_costs, loc, travel_costs, settings)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
  horizon = int(round(float(_setting(settings, "planning_window_days", 42))))
290
  base = str(_setting(settings, "base_location", ""))
291
  records = []
292
  for day in range(horizon + 1):
293
  selected = [battery for battery in batteries if assignments[battery] == day]
294
  for battery in order_daily_route(selected, loc, travel_costs, base):
295
- records.append({"day": start_time + pandas.Timedelta(days=day), "battery": battery})
 
 
296
 
297
  no_swap_day = start_time + pandas.Timedelta(days=horizon + 1)
298
- for battery in sorted(battery for battery in batteries if assignments[battery] == "no_swap"):
 
 
299
  records.append({"day": no_swap_day, "battery": battery})
300
 
301
- plan = pandas.DataFrame.from_records(records, columns=["day", "battery"]).reset_index(drop=True)
 
 
302
  plan["day"] = pandas.to_datetime(plan["day"])
303
  check_plan_valid(plan, loc, start_time=start_time)
304
  return plan
305
 
306
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
 
308
- # --------------------------------------------------------------------------
309
- # RUL model: hand-crafted battery snapshot features feeding a discrete-time
310
- # hazard model (see DiscreteHazardRULModel below). Gives a failure-probability
311
- # distribution (not a single point estimate), so MilpPlanner's expected-cost
312
- # objective can properly weigh early vs. late risk instead of committing to
313
- # one predicted day.
314
- # --------------------------------------------------------------------------
315
 
316
  RUL_DEVICE_COLUMN = "device_id"
317
  RUL_TIME_COLUMN = "end_time"
@@ -345,7 +650,11 @@ def extract_snapshot_features(timeseries, reference_time=None, windows=RUL_WINDO
345
  if frame.empty:
346
  return pd.DataFrame(index=pd.Index([], name=RUL_DEVICE_COLUMN))
347
 
348
- reference = pd.Timestamp(reference_time) if reference_time is not None else frame[RUL_TIME_COLUMN].max()
 
 
 
 
349
  frame = frame.loc[frame[RUL_TIME_COLUMN] <= reference].copy()
350
  rows = []
351
 
@@ -355,8 +664,12 @@ def extract_snapshot_features(timeseries, reference_time=None, windows=RUL_WINDO
355
  last_time = group[RUL_TIME_COLUMN].iloc[-1]
356
  row = {
357
  RUL_DEVICE_COLUMN: device_id,
358
- "device_age_days": max((reference - first_time).total_seconds() / 86400.0, 0.0),
359
- "history_span_days": max((last_time - first_time).total_seconds() / 86400.0, 0.0),
 
 
 
 
360
  "days_since_last_observation": max(
361
  (reference - last_time).total_seconds() / 86400.0, 0.0
362
  ),
@@ -370,10 +683,14 @@ def extract_snapshot_features(timeseries, reference_time=None, windows=RUL_WINDO
370
  row[f"{value_column}_std"] = _rul_finite(values.std(ddof=0))
371
  row[f"{value_column}_min"] = _rul_finite(values.min())
372
  row[f"{value_column}_max"] = _rul_finite(values.max())
373
- row[f"{value_column}_slope"] = _rul_finite(_rul_slope(values, group[RUL_TIME_COLUMN]))
 
 
374
 
375
  for days in windows:
376
- window = group.loc[group[RUL_TIME_COLUMN] >= reference - pd.Timedelta(days=int(days))]
 
 
377
  row[f"observation_count_{days}d"] = float(len(window))
378
  for value_column in RUL_VALUE_COLUMNS:
379
  values = window[value_column]
@@ -382,7 +699,9 @@ def extract_snapshot_features(timeseries, reference_time=None, windows=RUL_WINDO
382
  row[f"{prefix}_std"] = _rul_finite(values.std(ddof=0))
383
  row[f"{prefix}_min"] = _rul_finite(values.min())
384
  row[f"{prefix}_max"] = _rul_finite(values.max())
385
- row[f"{value_column}_slope_{days}d"] = _rul_finite(_rul_slope(values, window[RUL_TIME_COLUMN]))
 
 
386
 
387
  rows.append(row)
388
 
@@ -390,7 +709,9 @@ def extract_snapshot_features(timeseries, reference_time=None, windows=RUL_WINDO
390
  return features.astype(float)
391
 
392
 
393
- def expected_costs_from_failure_distribution(failure_probability, replacement_days, early_penalty, late_penalty):
 
 
394
  probabilities = np.asarray(failure_probability, dtype=float)
395
  probabilities = np.clip(probabilities, 0.0, None)
396
  total_probability = probabilities.sum()
@@ -406,7 +727,9 @@ def expected_costs_from_failure_distribution(failure_probability, replacement_da
406
  return costs @ probabilities
407
 
408
 
409
- def expected_no_swap_cost(failure_probability, horizon_day, emergency_day, late_penalty):
 
 
410
  probabilities = np.asarray(failure_probability, dtype=float)
411
  probabilities = np.clip(probabilities, 0.0, None)
412
  total_probability = probabilities.sum()
@@ -416,17 +739,13 @@ def expected_no_swap_cost(failure_probability, horizon_day, emergency_day, late_
416
  failure_days = np.arange(len(probabilities), dtype=float)
417
  due_inside_window = failure_days <= int(horizon_day)
418
  late_days = np.maximum(float(emergency_day) - failure_days, 0.0)
419
- return float(np.sum(probabilities[due_inside_window] * late_days[due_inside_window]) * late_penalty)
 
 
 
420
 
421
 
422
  class DiscreteHazardRULModel(RULModel):
423
- """Fully non-parametric discrete-time hazard model: bins time into
424
- weekly periods and trains one sklearn classifier to predict
425
- P(fail in period | survived to it, features, elapsed periods). No
426
- Weibull-shape or proportional-hazards assumption -- can capture a sharp
427
- voltage-threshold-crossing failure pattern that parametric models can't.
428
- """
429
-
430
  quantile_cols = ["p10", "p50", "p90"]
431
  period_days = 7
432
  horizon_cap_days = 126 # ~18 weekly periods; covers the 42-day planning window plus emergency margin
@@ -440,7 +759,9 @@ class DiscreteHazardRULModel(RULModel):
440
  self.fallback_hazard_ = 0.01
441
 
442
  def _prepare_features(self, features, fitting=False):
443
- numeric = features.apply(pd.to_numeric, errors="coerce").replace([np.inf, -np.inf], np.nan)
 
 
444
  if fitting:
445
  self.feature_columns_ = list(numeric.columns)
446
  self.feature_medians_ = numeric.median().fillna(0.0)
@@ -459,7 +780,9 @@ class DiscreteHazardRULModel(RULModel):
459
  capped_duration = min(duration, float(self.horizon_cap_days))
460
  failure_period = None
461
  if event and duration <= self.horizon_cap_days:
462
- failure_period = min(int(capped_duration // self.period_days), n_periods - 1)
 
 
463
  max_period = int(np.ceil(capped_duration / self.period_days))
464
  if failure_period is not None:
465
  max_period = max(max_period, failure_period + 1)
@@ -476,15 +799,21 @@ class DiscreteHazardRULModel(RULModel):
476
  return period_features, np.array(labels, dtype=int)
477
 
478
  def fit_snapshots(self, snapshot_features, durations, events):
479
- common = snapshot_features.index.intersection(durations.index).intersection(events.index)
 
 
480
  if common.empty:
481
  raise ValueError("No aligned snapshot labels were provided")
482
  features = self._prepare_features(snapshot_features.loc[common], fitting=True)
483
- duration = pd.to_numeric(durations.loc[common], errors="coerce").clip(lower=0.25)
 
 
484
  event = events.loc[common].fillna(False).astype(bool)
485
 
486
  period_features, labels = self._expand_person_periods(features, duration, event)
487
- self.fallback_hazard_ = float(np.clip(labels.mean(), 1e-3, 0.5)) if len(labels) else 0.01
 
 
488
 
489
  self.model_ = None
490
  if labels.sum() >= 2 and len(labels) >= 10:
@@ -521,7 +850,9 @@ class DiscreteHazardRULModel(RULModel):
521
  hazards = self._period_hazards(features)
522
  n = hazards.shape[0]
523
  period_survival = np.cumprod(1.0 - hazards, axis=1)
524
- period_survival = np.hstack([np.ones((n, 1)), period_survival]) # prepend day-0 survival = 1
 
 
525
 
526
  times = np.asarray(times, dtype=float)
527
  result = np.ones((len(times), n), dtype=float)
@@ -532,14 +863,20 @@ class DiscreteHazardRULModel(RULModel):
532
 
533
  def predict(self, timeseries):
534
  features = extract_snapshot_features(timeseries)
535
- times = np.arange(0, self.horizon_cap_days + self.period_days, self.period_days, dtype=float)
 
 
536
  survival = self._survival(features, times)
537
  quantile_days = {}
538
  for q_col, target in zip(self.quantile_cols, (0.9, 0.5, 0.1)):
539
  days = []
540
  for j in range(survival.shape[1]):
541
  below = np.where(survival[:, j] <= target)[0]
542
- days.append(float(times[below[0]]) if len(below) else float(self.horizon_cap_days))
 
 
 
 
543
  quantile_days[q_col] = days
544
  return pd.DataFrame(quantile_days, index=features.index)[self.quantile_cols]
545
 
@@ -551,11 +888,23 @@ class DiscreteHazardRULModel(RULModel):
551
  probabilities = np.vstack([interval_mass, survival[-1:]]).T
552
  row_sums = probabilities.sum(axis=1, keepdims=True)
553
  probabilities = np.divide(
554
- probabilities, row_sums, out=np.zeros_like(probabilities), where=row_sums > 0
 
 
 
 
 
 
555
  )
556
- return pd.DataFrame(probabilities, index=features.index, columns=range(max_day + 2))
557
 
558
- def expected_replacement_costs(self, timeseries, horizon_days, early_penalty, late_penalty, no_swap_extension_days):
 
 
 
 
 
 
 
559
  emergency_day = int(horizon_days + no_swap_extension_days)
560
  max_failure_day = emergency_day + max(int(horizon_days), 30)
561
  probabilities = self.failure_probabilities(timeseries, max_day=max_failure_day)
@@ -563,10 +912,16 @@ class DiscreteHazardRULModel(RULModel):
563
  rows = [
564
  np.append(
565
  expected_costs_from_failure_distribution(
566
- row, replacement_days, early_penalty=float(early_penalty), late_penalty=float(late_penalty)
 
 
 
567
  ),
568
  expected_no_swap_cost(
569
- row, horizon_day=int(horizon_days), emergency_day=emergency_day, late_penalty=float(late_penalty)
 
 
 
570
  ),
571
  )
572
  for row in probabilities.to_numpy(dtype=float)
@@ -587,7 +942,9 @@ def split_scenarios(scenarios, val_fraction=0.25, seed=0):
587
  return shuffled[n_val:], shuffled[:n_val]
588
 
589
 
590
- def build_training_snapshots(locations, timeseries, eol_times, scenarios, limit_scenarios=None):
 
 
591
  feature_parts = []
592
  duration_parts = []
593
  event_parts = []
@@ -647,59 +1004,54 @@ class Config(BaseSettings):
647
  """
648
  Automatically provides command-line argument support for specified fields
649
  """
 
650
  model_config = SettingsConfigDict(
651
  env_prefix="",
652
  cli_parse_args=True,
653
  cli_ignore_unknown_args=True,
654
  )
655
-
656
  dataset_path: Optional[Path] = None
657
- split : str = 'train'
658
  solver_time_limit_seconds: float = 20.0
659
- # NOTE: fixed at 1.0 (neutral). early/late penalties are identical across
660
- # every scenario in the dataset (0.5 / 10.0) -- there is no real per-scenario
661
- # risk knob to tune here, so this multiplier must not be used to game the
662
- # planner's internal cost objective away from the real evaluator.
663
  late_risk_multiplier: float = 1.0
664
  val_fraction: float = 0.25
665
  split_seed: int = 0
666
 
 
667
  def main():
668
  cfg = Config()
669
 
670
  if cfg.dataset_path is None:
671
- dataset_path = os.environ.get('BATTERYSWAP_DATASET_PATH', None)
672
  assert dataset_path
673
  dataset_path = Path(dataset_path)
674
  else:
675
  dataset_path = cfg.dataset_path
676
 
677
  split_path = dataset_path / cfg.split
678
- locations, timeseries, eol_times, scenarios = load_dataset(split_path)
679
 
680
- log.info('evaluate-load-data', path=dataset_path)
681
 
682
- # Honest train/val split across scenarios -- never evaluate on the
683
- # scenarios the RUL model was fit on.
684
  train_scenarios, val_scenarios = split_scenarios(
685
  scenarios, val_fraction=cfg.val_fraction, seed=cfg.split_seed
686
  )
687
- log.info('scenario-split', total=len(scenarios), train=len(train_scenarios), val=len(val_scenarios))
688
-
689
- # Prediction model training (train fold only -- for the held-out metric below)
690
- # DiscreteHazardRULModel (non-parametric discrete-time hazard model) beat both
691
- # a Weibull AFT and a Cox proportional-hazards model by a wide margin on
692
- # held-out total_cost, confirmed across two different train/val splits.
693
  rul_model = train_rul_model(locations, timeseries, eol_times, train_scenarios)
694
- log.info('train-done')
695
 
696
- log.info('evaluate-held-out')
697
- # Evaluate ONLY on the held-out validation scenarios
698
  gen = iterate_scenarios(locations, timeseries, eol_times, val_scenarios)
699
  for scenario, locs, cut, eol in gen:
700
- scenario_name = scenario['name']
701
- travel_costs = scenario['travel_costs']
702
- settings = scenario['settings']
703
 
704
  planner = MilpPlanner(
705
  rul_model,
@@ -708,15 +1060,15 @@ def main():
708
  )
709
  plan = planner.plan(cut, locs, travel_costs, settings)
710
 
711
- start_time = pandas.Timestamp(scenario['start_time'])
712
 
713
- transitions, daily, overall = evaluate_plan(plan, locs, travel_costs, settings, eol_times=eol, start_time=start_time)
 
 
714
 
715
- print('scores', scenario_name, overall)
716
 
717
- log.info('refit-on-full-data-for-submission')
718
- # For the actual submitted planner, refit on ALL scenarios (train + held-out)
719
- # now that the held-out metric above has told us how good the model really is.
720
  rul_model_full = train_rul_model(locations, timeseries, eol_times, scenarios)
721
 
722
  # Save best planner
 
 
 
 
1
  import pickle
 
 
 
2
  import os
3
+ from pathlib import Path
4
+ from typing import Optional, Sequence
5
 
6
+ import pandas
7
  import pandas as pd
 
8
  import numpy as np
 
 
9
  from pydantic_settings import BaseSettings, SettingsConfigDict
10
  import structlog
11
  from ortools.sat.python import cp_model
 
16
 
17
  log = structlog.get_logger()
18
 
 
 
 
 
 
 
 
19
  COST_SCALE = 600
20
  MINUTES_PER_HOUR = 60
21
 
 
40
  frame[DEVICE_COLUMN] = frame[DEVICE_COLUMN].astype(str)
41
  for column in VALUE_COLUMNS:
42
  frame[column] = pandas.to_numeric(frame[column], errors="coerce")
43
+ return frame.sort_values([DEVICE_COLUMN, TIME_COLUMN], kind="stable").reset_index(
44
+ drop=True
45
+ )
46
 
47
 
48
  def _setting(settings, name, default):
 
54
  def _normalize_locations(locations):
55
  frame = locations.copy().reset_index(drop=True)
56
  aliases = {"device_id": "battery", "building_id": "building", "room_id": "room"}
57
+ frame = frame.rename(
58
+ columns={old: new for old, new in aliases.items() if new not in frame}
59
+ )
60
  required = {"battery", "building", "room"}
61
  missing = required - set(frame.columns)
62
  if missing:
 
72
  missing = required - set(frame.columns)
73
  if missing:
74
  raise ValueError(f"Travel costs is missing required columns: {sorted(missing)}")
75
+ return {
76
+ (str(row["from"]), str(row["to"])): float(row["hours"])
77
+ for _, row in frame.iterrows()
78
+ }
79
 
80
 
81
  def order_daily_route(batteries, locations, travel_costs, base_building):
 
91
  next_building = min(
92
  buildings,
93
  key=lambda building: (
94
+ travel.get(
95
+ (current, building), 0.0 if current == building else float("inf")
96
+ ),
97
  building,
98
  ),
99
  )
 
112
 
113
 
114
  class MilpPlanner(Planner):
115
+ def __init__(
116
+ self,
117
+ rul_estimator,
118
+ solver_time_limit_seconds=30.0,
119
+ late_risk_multiplier=1.0,
120
+ candidate_benefit_threshold=30.0,
121
+ solver_workers=8,
122
+ ):
123
  self.rul_estimator = rul_estimator
124
  self.solver_time_limit_seconds = float(solver_time_limit_seconds)
125
  self.late_risk_multiplier = float(late_risk_multiplier)
126
+ # Only batteries where swapping beats skipping by more than this margin
127
+ # get decision variables. With ~450 batteries but only ~2-4% actually
128
+ # due in the window, this shrinks the model ~20x and lets CP-SAT reach
129
+ # optimality instead of timing out at a 99.9% gap.
130
+ self.candidate_benefit_threshold = float(candidate_benefit_threshold)
131
+ self.solver_workers = int(solver_workers)
132
 
133
  def _expected_costs(self, timeseries, batteries, settings):
134
  horizon = int(round(float(_setting(settings, "planning_window_days", 42))))
 
139
  costs = self.rul_estimator.expected_replacement_costs(
140
  timeseries,
141
  horizon_days=horizon,
142
+ early_penalty=float(
143
+ _setting(settings, "early_replacement_penalty_daily", 0.5)
144
+ ),
145
+ late_penalty=float(
146
+ _setting(settings, "late_replacement_penalty_daily", 10.0)
147
+ )
148
  * float(getattr(self, "late_risk_multiplier", 1.0)),
149
  no_swap_extension_days=emergency_delay,
150
  )
151
  expected_columns = list(range(horizon + 1)) + ["no_swap"]
152
  costs = costs.reindex(index=batteries, columns=expected_columns)
153
  finite = costs.to_numpy(dtype=float)
154
+ fallback = (
155
+ float(np.nanmax(finite[np.isfinite(finite)]))
156
+ if np.isfinite(finite).any()
157
+ else 1e6
158
+ )
159
  return costs.replace([np.inf, -np.inf], np.nan).fillna(fallback + 1e3)
160
 
161
  def _solve_assignments(self, expected_costs, locations, travel_costs, settings):
 
182
  battery_rooms = loc.loc[batteries, "room"].astype(str).to_dict()
183
  battery_buildings = loc.loc[batteries, "building"].astype(str).to_dict()
184
  room_members = {
185
+ room: [battery for battery in batteries if battery_rooms[battery] == room]
186
+ for room in rooms
187
  }
188
  building_members = {
189
+ building: [
190
+ battery
191
+ for battery in batteries
192
+ if battery_buildings[battery] == building
193
+ ]
194
  for building in buildings
195
  }
196
+ room_visit = {
197
+ (room, day): model.new_bool_var(f"room_{room}_{day}")
198
+ for room in rooms
199
+ for day in real_days
200
+ }
201
  building_visit = {
202
  (building, day): model.new_bool_var(f"building_{building}_{day}")
203
  for building in buildings
 
209
  members = room_members[room]
210
  for battery in members:
211
  model.add(assignment[battery, day] <= room_visit[room, day])
212
+ model.add(
213
+ room_visit[room, day]
214
+ <= sum(assignment[battery, day] for battery in members)
215
+ )
216
  for building in buildings:
217
  members = building_members[building]
218
  for battery in members:
219
  model.add(assignment[battery, day] <= building_visit[building, day])
220
  model.add(
221
+ building_visit[building, day]
222
+ <= sum(assignment[battery, day] for battery in members)
223
  )
224
 
225
+ battery_minutes = round(
226
+ float(_setting(settings, "time_per_battery_hours", 0.25)) * 60
227
+ )
228
+ room_minutes = round(
229
+ float(_setting(settings, "time_per_room_change_hours", 0.5)) * 60
230
+ )
231
+ building_minutes = round(
232
+ float(_setting(settings, "time_per_building_change_hours", 1.0)) * 60
233
+ )
234
  building_work = {}
235
  for building in buildings:
236
  round_trip = travel.get((base, building), 0.0 if base == building else 24.0)
237
+ round_trip += travel.get(
238
+ (building, base), 0.0 if base == building else 24.0
239
+ )
240
+ building_work[building] = round(round_trip * 60) + (
241
+ 0 if building == base else building_minutes
242
+ )
243
 
244
  maximum_daily = (
245
+ len(batteries) * battery_minutes
246
+ + len(rooms) * room_minutes
247
+ + sum(building_work.values())
248
  )
249
  daily_work = {}
250
  daily_overtime = {}
251
  daily_limit_hit = {}
252
  overtime_start = round(float(_setting(settings, "overtime_start", 8.0)) * 60)
253
+ daily_limit = round(
254
+ float(_setting(settings, "worker_limit_daily_hours", 24.0)) * 60
255
+ )
256
 
257
  for day in real_days:
258
  work = model.new_int_var(0, maximum_daily, f"work_{day}")
259
  expression = (
260
  battery_minutes * sum(assignment[battery, day] for battery in batteries)
261
+ + room_minutes
262
+ * sum(room_visit[room, day] for room in rooms if room != base_room)
263
+ + sum(
264
+ building_work[building] * building_visit[building, day]
265
+ for building in buildings
266
+ )
267
  )
268
  model.add(work == expression)
269
  daily_work[day] = work
 
277
  daily_limit_hit[day] = hit
278
 
279
  weekly_limit_hit = {}
280
+ weekly_limit = round(
281
+ float(_setting(settings, "worker_limit_weekly_hours", 24.0)) * 60
282
+ )
283
  for week_start in range(0, len(real_days), 7):
284
  week_days = real_days[week_start : week_start + 7]
285
  hit = model.new_bool_var(f"weekly_limit_hit_{week_start // 7}")
286
  weekly_maximum = maximum_daily * len(week_days)
287
  model.add(
288
+ sum(daily_work[day] for day in week_days)
289
+ <= max(weekly_limit - 1, -1) + weekly_maximum * hit
290
  )
291
  weekly_limit_hit[week_start] = hit
292
 
293
  objective_terms = []
294
  for battery_index, battery in enumerate(batteries):
295
  for action_index, action in enumerate(actions):
296
+ coefficient = int(
297
+ round(float(expected_costs.loc[battery, action]) * COST_SCALE)
298
+ )
299
  coefficient += action_index + battery_index % 3
300
  objective_terms.append(coefficient * assignment[battery, action])
301
 
 
303
  objective_terms.extend(minute_cost * daily_work[day] for day in real_days)
304
  overtime_factor = float(_setting(settings, "overtime_penalty_factor", 2.0))
305
  overtime_minute_cost = int(round(overtime_factor * minute_cost))
306
+ objective_terms.extend(
307
+ overtime_minute_cost * daily_overtime[day] for day in real_days
308
+ )
309
+ daily_penalty = int(
310
+ round(
311
+ float(_setting(settings, "worker_limit_daily_penalty", 100.0))
312
+ * COST_SCALE
313
+ )
314
+ )
315
+ weekly_penalty = int(
316
+ round(
317
+ float(_setting(settings, "worker_limit_weekly_penalty", 100.0))
318
+ * COST_SCALE
319
+ )
320
+ )
321
+ objective_terms.extend(
322
+ daily_penalty * value for value in daily_limit_hit.values()
323
+ )
324
+ objective_terms.extend(
325
+ weekly_penalty * value for value in weekly_limit_hit.values()
326
+ )
327
  model.minimize(sum(objective_terms))
328
 
329
  solver = cp_model.CpSolver()
330
  solver.parameters.max_time_in_seconds = self.solver_time_limit_seconds
331
+ solver.parameters.num_search_workers = max(1, int(getattr(self, "solver_workers", 8)))
332
  solver.parameters.random_seed = 0
333
  status = solver.solve(model)
334
+ # Never silently accept a non-solve: an UNKNOWN status here previously
335
+ # fell through to the greedy fallback and cost us most of the late_swap.
336
+ log.info(
337
+ "cpsat-solve",
338
+ status=solver.status_name(status),
339
+ batteries=len(batteries),
340
+ wall_seconds=round(solver.wall_time, 2),
341
+ )
342
  if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
343
+ log.warning("cpsat-no-solution-using-greedy-fallback", status=solver.status_name(status))
344
  return {
345
+ battery: min(
346
+ actions,
347
+ key=lambda action: (
348
+ expected_costs.loc[battery, action],
349
+ str(action),
350
+ ),
351
+ )
352
  for battery in batteries
353
  }
354
  return {
355
+ battery: next(
356
+ action
357
+ for action in actions
358
+ if solver.value(assignment[battery, action])
359
+ )
360
  for battery in batteries
361
  }
362
 
 
372
  start_time = normalized_timeseries["end_time"].max().normalize()
373
 
374
  expected_costs = self._expected_costs(timeseries, batteries, settings)
375
+
376
+ # Restrict the solve to batteries where swapping actually beats skipping.
377
+ # Only ~2-4% of batteries are due in any window; giving all ~450 of them
378
+ # decision variables made the model too big to solve (CP-SAT returned
379
+ # UNKNOWN at a 99.9% gap). Everything below the threshold is pinned to
380
+ # no_swap, which its own expected costs already say is optimal.
381
+ day_costs = expected_costs.drop(columns=["no_swap"])
382
+ benefit = expected_costs["no_swap"] - day_costs.min(axis=1)
383
+ # getattr keeps planners pickled before this attribute existed loadable
384
+ threshold = float(getattr(self, "candidate_benefit_threshold", 30.0))
385
+ candidates = sorted(benefit[benefit > threshold].index.astype(str))
386
+ log.info("candidate-filter", total=len(batteries), candidates=len(candidates))
387
+
388
+ assignments = {battery: "no_swap" for battery in batteries}
389
+ if candidates:
390
+ solved = self._solve_assignments(
391
+ expected_costs.loc[candidates], loc, travel_costs, settings
392
+ )
393
+ assignments.update(solved)
394
+
395
  horizon = int(round(float(_setting(settings, "planning_window_days", 42))))
396
  base = str(_setting(settings, "base_location", ""))
397
  records = []
398
  for day in range(horizon + 1):
399
  selected = [battery for battery in batteries if assignments[battery] == day]
400
  for battery in order_daily_route(selected, loc, travel_costs, base):
401
+ records.append(
402
+ {"day": start_time + pandas.Timedelta(days=day), "battery": battery}
403
+ )
404
 
405
  no_swap_day = start_time + pandas.Timedelta(days=horizon + 1)
406
+ for battery in sorted(
407
+ battery for battery in batteries if assignments[battery] == "no_swap"
408
+ ):
409
  records.append({"day": no_swap_day, "battery": battery})
410
 
411
+ plan = pandas.DataFrame.from_records(
412
+ records, columns=["day", "battery"]
413
+ ).reset_index(drop=True)
414
  plan["day"] = pandas.to_datetime(plan["day"])
415
  check_plan_valid(plan, loc, start_time=start_time)
416
  return plan
417
 
418
 
419
+ class SearchPlanner(Planner):
420
+ """Greedy construction + local search scored by the *real* evaluate_plan.
421
+
422
+ The MILP approximates the official cost model; this scores candidate plans
423
+ with the actual evaluator (using predicted EOL as surrogate truth), so it
424
+ has zero modeling error -- it sees the emergency-visit mechanics, weekly
425
+ limit accounting and end-of-day travel exactly as the scorer does.
426
+
427
+ Only batteries that plausibly fail inside the window get scheduled; with
428
+ ~2-4% due per scenario the search space is small enough to explore well.
429
+ """
430
+
431
+ def __init__(
432
+ self,
433
+ rul_estimator,
434
+ candidate_benefit_threshold=5.0,
435
+ iterations=400,
436
+ random_seed=0,
437
+ late_risk_multiplier=1.0,
438
+ ):
439
+ self.rul_estimator = rul_estimator
440
+ self.candidate_benefit_threshold = float(candidate_benefit_threshold)
441
+ self.iterations = int(iterations)
442
+ self.random_seed = int(random_seed)
443
+ self.late_risk_multiplier = float(late_risk_multiplier)
444
+
445
+ def _expected_costs(self, timeseries, batteries, settings):
446
+ return MilpPlanner._expected_costs(self, timeseries, batteries, settings)
447
+
448
+ @staticmethod
449
+ def _build_plan(assignment, all_batteries, start_time, park_day):
450
+ """assignment: battery -> day offset (int). Others parked past window."""
451
+ records = [
452
+ {"day": start_time + pandas.Timedelta(days=int(d)), "battery": b}
453
+ for b, d in assignment.items()
454
+ ]
455
+ assigned = set(assignment)
456
+ records.extend(
457
+ {"day": park_day, "battery": b} for b in all_batteries if b not in assigned
458
+ )
459
+ plan = pandas.DataFrame.from_records(records, columns=["day", "battery"])
460
+ # stable ordering: by day, then grouped by building/room handled by caller
461
+ plan = plan.sort_values(["day", "battery"], kind="stable").reset_index(drop=True)
462
+ plan["day"] = pandas.to_datetime(plan["day"])
463
+ return plan
464
+
465
+ @staticmethod
466
+ def _route_day(day_batteries, building_of, room_of, travel, base):
467
+ """Nearest-building route for one day, using precomputed lookups.
468
+
469
+ Same logic as order_daily_route but without rebuilding the location
470
+ frame and travel dict on every call -- this runs inside the search loop.
471
+ """
472
+ remaining = set(building_of[b] for b in day_batteries)
473
+ current = str(base)
474
+ building_order = []
475
+ while remaining:
476
+ nxt = min(
477
+ remaining,
478
+ key=lambda bl: (
479
+ travel.get((current, bl), 0.0 if current == bl else float("inf")),
480
+ bl,
481
+ ),
482
+ )
483
+ building_order.append(nxt)
484
+ remaining.discard(nxt)
485
+ current = nxt
486
+ ordered = []
487
+ for building in building_order:
488
+ here = [b for b in day_batteries if building_of[b] == building]
489
+ here.sort(key=lambda b: (room_of[b], b))
490
+ ordered.extend(here)
491
+ return ordered
492
+
493
+ def plan(self, timeseries, locations, travel_costs, settings):
494
+ import random
495
+
496
+ loc = _normalize_locations(locations)
497
+ batteries = sorted(loc["battery"].astype(str).tolist())
498
+ normalized = normalize_timeseries(timeseries)
499
+ if normalized.empty:
500
+ start_time = pandas.to_datetime(loc["end_time"]).max().normalize()
501
+ else:
502
+ start_time = normalized["end_time"].max().normalize()
503
+
504
+ horizon = int(round(float(_setting(settings, "planning_window_days", 42))))
505
+ horizon_end = start_time + pandas.Timedelta(days=horizon)
506
+ base = str(_setting(settings, "base_location", ""))
507
+ park_day = start_time + pandas.Timedelta(days=horizon + 1)
508
+
509
+ expected_costs = self._expected_costs(timeseries, batteries, settings)
510
+ day_costs = expected_costs.drop(columns=["no_swap"])
511
+ benefit = expected_costs["no_swap"] - day_costs.min(axis=1)
512
+ candidates = sorted(
513
+ benefit[benefit > self.candidate_benefit_threshold].index.astype(str)
514
+ )
515
+ log.info("candidate-filter", total=len(batteries), candidates=len(candidates))
516
+ if not candidates:
517
+ plan = self._build_plan({}, batteries, start_time, park_day)
518
+ check_plan_valid(plan, loc, start_time=start_time)
519
+ return plan
520
+
521
+ # Surrogate EOL for scoring: the cost-minimising swap day per battery
522
+ # already encodes the newsvendor trade-off (early 0.5/day vs late 10/day).
523
+ target_day = {b: int(day_costs.loc[b].idxmin()) for b in candidates}
524
+ surrogate_eol = pandas.Series(
525
+ {
526
+ b: (start_time + pandas.Timedelta(days=int(target_day[b])))
527
+ if b in target_day
528
+ else park_day + pandas.Timedelta(days=365)
529
+ for b in batteries
530
+ }
531
+ )
532
+
533
+ # Precomputed lookups so the search loop never rebuilds these.
534
+ indexed = loc.set_index("battery")
535
+ building_of = indexed["building"].astype(str).to_dict()
536
+ room_of = indexed["room"].astype(str).to_dict()
537
+ travel = _travel_lookup(travel_costs)
538
+ parked = [b for b in batteries]
539
+
540
+ def make_plan(assignment):
541
+ by_day = {}
542
+ for b, d in assignment.items():
543
+ by_day.setdefault(int(d), []).append(b)
544
+ rows_day, rows_bat = [], []
545
+ for d in sorted(by_day):
546
+ ordered = self._route_day(
547
+ sorted(by_day[d]), building_of, room_of, travel, base
548
+ )
549
+ stamp = start_time + pandas.Timedelta(days=d)
550
+ rows_day.extend([stamp] * len(ordered))
551
+ rows_bat.extend(ordered)
552
+ assigned = set(assignment)
553
+ rest = [b for b in parked if b not in assigned]
554
+ rows_day.extend([park_day] * len(rest))
555
+ rows_bat.extend(rest)
556
+ plan = pandas.DataFrame({"day": rows_day, "battery": rows_bat})
557
+ plan["day"] = pandas.to_datetime(plan["day"])
558
+ return plan
559
+
560
+ def score(assignment):
561
+ try:
562
+ _, _, overall = evaluate_plan(
563
+ make_plan(assignment),
564
+ loc,
565
+ travel_costs,
566
+ settings,
567
+ eol_times=surrogate_eol,
568
+ start_time=start_time,
569
+ verbose=0,
570
+ )
571
+ return float(overall["total_cost"])
572
+ except Exception:
573
+ return float("inf")
574
+
575
+ # Greedy start: everyone at their own cost-minimising day.
576
+ current = dict(target_day)
577
+ current_cost = score(current)
578
+
579
+ # Local search. Moves: shift a day, batch onto another candidate's day,
580
+ # drop a battery, or restore a dropped one.
581
+ rng = random.Random(self.random_seed)
582
+ best, best_cost = dict(current), current_cost
583
+ used_days = sorted(set(target_day.values()))
584
+ for _ in range(self.iterations):
585
+ trial = dict(current)
586
+ battery = rng.choice(candidates)
587
+ move = rng.random()
588
+ if move < 0.4 and trial:
589
+ # batch: move onto a day already being worked (saves a trip)
590
+ if used_days:
591
+ trial[battery] = rng.choice(
592
+ sorted(set(trial.values())) or used_days
593
+ )
594
+ elif move < 0.75:
595
+ # shift, biased earlier (late costs 20x more than early)
596
+ base_day = trial.get(battery, target_day[battery])
597
+ shift = -rng.randint(1, 7) if rng.random() < 0.7 else rng.randint(1, 4)
598
+ trial[battery] = int(min(max(base_day + shift, 0), horizon))
599
+ elif move < 0.9:
600
+ trial.pop(battery, None) # drop
601
+ else:
602
+ trial[battery] = target_day[battery] # restore
603
+
604
+ trial_cost = score(trial)
605
+ if trial_cost <= current_cost:
606
+ current, current_cost = trial, trial_cost
607
+ if trial_cost < best_cost:
608
+ best, best_cost = dict(trial), trial_cost
609
+
610
+ log.info(
611
+ "search-planner",
612
+ candidates=len(candidates),
613
+ scheduled=len(best),
614
+ surrogate_cost=round(best_cost, 1),
615
+ )
616
+ plan = make_plan(best)
617
+ check_plan_valid(plan, loc, start_time=start_time)
618
+ return plan
619
 
 
 
 
 
 
 
 
620
 
621
  RUL_DEVICE_COLUMN = "device_id"
622
  RUL_TIME_COLUMN = "end_time"
 
650
  if frame.empty:
651
  return pd.DataFrame(index=pd.Index([], name=RUL_DEVICE_COLUMN))
652
 
653
+ reference = (
654
+ pd.Timestamp(reference_time)
655
+ if reference_time is not None
656
+ else frame[RUL_TIME_COLUMN].max()
657
+ )
658
  frame = frame.loc[frame[RUL_TIME_COLUMN] <= reference].copy()
659
  rows = []
660
 
 
664
  last_time = group[RUL_TIME_COLUMN].iloc[-1]
665
  row = {
666
  RUL_DEVICE_COLUMN: device_id,
667
+ "device_age_days": max(
668
+ (reference - first_time).total_seconds() / 86400.0, 0.0
669
+ ),
670
+ "history_span_days": max(
671
+ (last_time - first_time).total_seconds() / 86400.0, 0.0
672
+ ),
673
  "days_since_last_observation": max(
674
  (reference - last_time).total_seconds() / 86400.0, 0.0
675
  ),
 
683
  row[f"{value_column}_std"] = _rul_finite(values.std(ddof=0))
684
  row[f"{value_column}_min"] = _rul_finite(values.min())
685
  row[f"{value_column}_max"] = _rul_finite(values.max())
686
+ row[f"{value_column}_slope"] = _rul_finite(
687
+ _rul_slope(values, group[RUL_TIME_COLUMN])
688
+ )
689
 
690
  for days in windows:
691
+ window = group.loc[
692
+ group[RUL_TIME_COLUMN] >= reference - pd.Timedelta(days=int(days))
693
+ ]
694
  row[f"observation_count_{days}d"] = float(len(window))
695
  for value_column in RUL_VALUE_COLUMNS:
696
  values = window[value_column]
 
699
  row[f"{prefix}_std"] = _rul_finite(values.std(ddof=0))
700
  row[f"{prefix}_min"] = _rul_finite(values.min())
701
  row[f"{prefix}_max"] = _rul_finite(values.max())
702
+ row[f"{value_column}_slope_{days}d"] = _rul_finite(
703
+ _rul_slope(values, window[RUL_TIME_COLUMN])
704
+ )
705
 
706
  rows.append(row)
707
 
 
709
  return features.astype(float)
710
 
711
 
712
+ def expected_costs_from_failure_distribution(
713
+ failure_probability, replacement_days, early_penalty, late_penalty
714
+ ):
715
  probabilities = np.asarray(failure_probability, dtype=float)
716
  probabilities = np.clip(probabilities, 0.0, None)
717
  total_probability = probabilities.sum()
 
727
  return costs @ probabilities
728
 
729
 
730
+ def expected_no_swap_cost(
731
+ failure_probability, horizon_day, emergency_day, late_penalty
732
+ ):
733
  probabilities = np.asarray(failure_probability, dtype=float)
734
  probabilities = np.clip(probabilities, 0.0, None)
735
  total_probability = probabilities.sum()
 
739
  failure_days = np.arange(len(probabilities), dtype=float)
740
  due_inside_window = failure_days <= int(horizon_day)
741
  late_days = np.maximum(float(emergency_day) - failure_days, 0.0)
742
+ return float(
743
+ np.sum(probabilities[due_inside_window] * late_days[due_inside_window])
744
+ * late_penalty
745
+ )
746
 
747
 
748
  class DiscreteHazardRULModel(RULModel):
 
 
 
 
 
 
 
749
  quantile_cols = ["p10", "p50", "p90"]
750
  period_days = 7
751
  horizon_cap_days = 126 # ~18 weekly periods; covers the 42-day planning window plus emergency margin
 
759
  self.fallback_hazard_ = 0.01
760
 
761
  def _prepare_features(self, features, fitting=False):
762
+ numeric = features.apply(pd.to_numeric, errors="coerce").replace(
763
+ [np.inf, -np.inf], np.nan
764
+ )
765
  if fitting:
766
  self.feature_columns_ = list(numeric.columns)
767
  self.feature_medians_ = numeric.median().fillna(0.0)
 
780
  capped_duration = min(duration, float(self.horizon_cap_days))
781
  failure_period = None
782
  if event and duration <= self.horizon_cap_days:
783
+ failure_period = min(
784
+ int(capped_duration // self.period_days), n_periods - 1
785
+ )
786
  max_period = int(np.ceil(capped_duration / self.period_days))
787
  if failure_period is not None:
788
  max_period = max(max_period, failure_period + 1)
 
799
  return period_features, np.array(labels, dtype=int)
800
 
801
  def fit_snapshots(self, snapshot_features, durations, events):
802
+ common = snapshot_features.index.intersection(durations.index).intersection(
803
+ events.index
804
+ )
805
  if common.empty:
806
  raise ValueError("No aligned snapshot labels were provided")
807
  features = self._prepare_features(snapshot_features.loc[common], fitting=True)
808
+ duration = pd.to_numeric(durations.loc[common], errors="coerce").clip(
809
+ lower=0.25
810
+ )
811
  event = events.loc[common].fillna(False).astype(bool)
812
 
813
  period_features, labels = self._expand_person_periods(features, duration, event)
814
+ self.fallback_hazard_ = (
815
+ float(np.clip(labels.mean(), 1e-3, 0.5)) if len(labels) else 0.01
816
+ )
817
 
818
  self.model_ = None
819
  if labels.sum() >= 2 and len(labels) >= 10:
 
850
  hazards = self._period_hazards(features)
851
  n = hazards.shape[0]
852
  period_survival = np.cumprod(1.0 - hazards, axis=1)
853
+ period_survival = np.hstack(
854
+ [np.ones((n, 1)), period_survival]
855
+ ) # prepend day-0 survival = 1
856
 
857
  times = np.asarray(times, dtype=float)
858
  result = np.ones((len(times), n), dtype=float)
 
863
 
864
  def predict(self, timeseries):
865
  features = extract_snapshot_features(timeseries)
866
+ times = np.arange(
867
+ 0, self.horizon_cap_days + self.period_days, self.period_days, dtype=float
868
+ )
869
  survival = self._survival(features, times)
870
  quantile_days = {}
871
  for q_col, target in zip(self.quantile_cols, (0.9, 0.5, 0.1)):
872
  days = []
873
  for j in range(survival.shape[1]):
874
  below = np.where(survival[:, j] <= target)[0]
875
+ days.append(
876
+ float(times[below[0]])
877
+ if len(below)
878
+ else float(self.horizon_cap_days)
879
+ )
880
  quantile_days[q_col] = days
881
  return pd.DataFrame(quantile_days, index=features.index)[self.quantile_cols]
882
 
 
888
  probabilities = np.vstack([interval_mass, survival[-1:]]).T
889
  row_sums = probabilities.sum(axis=1, keepdims=True)
890
  probabilities = np.divide(
891
+ probabilities,
892
+ row_sums,
893
+ out=np.zeros_like(probabilities),
894
+ where=row_sums > 0,
895
+ )
896
+ return pd.DataFrame(
897
+ probabilities, index=features.index, columns=range(max_day + 2)
898
  )
 
899
 
900
+ def expected_replacement_costs(
901
+ self,
902
+ timeseries,
903
+ horizon_days,
904
+ early_penalty,
905
+ late_penalty,
906
+ no_swap_extension_days,
907
+ ):
908
  emergency_day = int(horizon_days + no_swap_extension_days)
909
  max_failure_day = emergency_day + max(int(horizon_days), 30)
910
  probabilities = self.failure_probabilities(timeseries, max_day=max_failure_day)
 
912
  rows = [
913
  np.append(
914
  expected_costs_from_failure_distribution(
915
+ row,
916
+ replacement_days,
917
+ early_penalty=float(early_penalty),
918
+ late_penalty=float(late_penalty),
919
  ),
920
  expected_no_swap_cost(
921
+ row,
922
+ horizon_day=int(horizon_days),
923
+ emergency_day=emergency_day,
924
+ late_penalty=float(late_penalty),
925
  ),
926
  )
927
  for row in probabilities.to_numpy(dtype=float)
 
942
  return shuffled[n_val:], shuffled[:n_val]
943
 
944
 
945
+ def build_training_snapshots(
946
+ locations, timeseries, eol_times, scenarios, limit_scenarios=None
947
+ ):
948
  feature_parts = []
949
  duration_parts = []
950
  event_parts = []
 
1004
  """
1005
  Automatically provides command-line argument support for specified fields
1006
  """
1007
+
1008
  model_config = SettingsConfigDict(
1009
  env_prefix="",
1010
  cli_parse_args=True,
1011
  cli_ignore_unknown_args=True,
1012
  )
1013
+
1014
  dataset_path: Optional[Path] = None
1015
+ split: str = "train"
1016
  solver_time_limit_seconds: float = 20.0
 
 
 
 
1017
  late_risk_multiplier: float = 1.0
1018
  val_fraction: float = 0.25
1019
  split_seed: int = 0
1020
 
1021
+
1022
  def main():
1023
  cfg = Config()
1024
 
1025
  if cfg.dataset_path is None:
1026
+ dataset_path = os.environ.get("BATTERYSWAP_DATASET_PATH", None)
1027
  assert dataset_path
1028
  dataset_path = Path(dataset_path)
1029
  else:
1030
  dataset_path = cfg.dataset_path
1031
 
1032
  split_path = dataset_path / cfg.split
1033
+ locations, timeseries, eol_times, scenarios = load_dataset(split_path)
1034
 
1035
+ log.info("evaluate-load-data", path=dataset_path)
1036
 
 
 
1037
  train_scenarios, val_scenarios = split_scenarios(
1038
  scenarios, val_fraction=cfg.val_fraction, seed=cfg.split_seed
1039
  )
1040
+ log.info(
1041
+ "scenario-split",
1042
+ total=len(scenarios),
1043
+ train=len(train_scenarios),
1044
+ val=len(val_scenarios),
1045
+ )
1046
  rul_model = train_rul_model(locations, timeseries, eol_times, train_scenarios)
1047
+ log.info("train-done")
1048
 
1049
+ log.info("evaluate-held-out")
 
1050
  gen = iterate_scenarios(locations, timeseries, eol_times, val_scenarios)
1051
  for scenario, locs, cut, eol in gen:
1052
+ scenario_name = scenario["name"]
1053
+ travel_costs = scenario["travel_costs"]
1054
+ settings = scenario["settings"]
1055
 
1056
  planner = MilpPlanner(
1057
  rul_model,
 
1060
  )
1061
  plan = planner.plan(cut, locs, travel_costs, settings)
1062
 
1063
+ start_time = pandas.Timestamp(scenario["start_time"])
1064
 
1065
+ transitions, daily, overall = evaluate_plan(
1066
+ plan, locs, travel_costs, settings, eol_times=eol, start_time=start_time
1067
+ )
1068
 
1069
+ print("scores", scenario_name, overall)
1070
 
1071
+ log.info("refit-on-full-data-for-submission")
 
 
1072
  rul_model_full = train_rul_model(locations, timeseries, eol_times, scenarios)
1073
 
1074
  # Save best planner