Jawaril99 commited on
Commit
cf2a82f
·
1 Parent(s): 65a368c

Use simulated annealing planner for submission

Browse files
batteryswap_example/planners/best.pickle CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:e8a24a181db4723084582e1f526305bde6be6496dbe305af888ac39259611783
3
- size 6519559
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3cca90ca40103c1e80c3251f185a99ca2668019c80bf076551352315aadaeb91
3
+ size 6525989
batteryswap_example/train.py CHANGED
@@ -16,7 +16,6 @@ import pandas
16
  from pydantic import Field
17
  from pydantic_settings import BaseSettings, SettingsConfigDict
18
  import structlog
19
- from ortools.sat.python import cp_model
20
 
21
  from batteryswap_public.interfaces import Planner, RULModel
22
  from batteryswap_public.utils import load_dataset, iterate_scenarios
@@ -25,287 +24,365 @@ from batteryswap_public.evaluate import evaluate_plan, check_plan_valid
25
  log = structlog.get_logger()
26
 
27
 
28
- # --------------------------------------------------------------------------
29
- # Planner: CP-SAT (MILP-style) assignment of batteries to swap-days,
30
- # accounting for room/building visit costs and daily/weekly worker-hour
31
- # limits. Replaces the OrderedPlanner heuristic.
32
- # --------------------------------------------------------------------------
33
-
34
- COST_SCALE = 600
35
- MINUTES_PER_HOUR = 60
36
-
37
- DEVICE_COLUMN = "device_id"
38
- TIME_COLUMN = "end_time"
39
- VALUE_COLUMNS = ("voltage", "temperature")
40
-
41
-
42
- def normalize_timeseries(timeseries):
43
- frame = timeseries.copy()
44
- missing_identity = {DEVICE_COLUMN, TIME_COLUMN} - set(frame.columns)
45
- if missing_identity:
46
- frame = frame.reset_index()
47
-
48
- required = {DEVICE_COLUMN, TIME_COLUMN, *VALUE_COLUMNS}
49
- missing = required - set(frame.columns)
50
- if missing:
51
- raise ValueError(f"Timeseries is missing required columns: {sorted(missing)}")
52
-
53
- frame = frame.loc[:, [DEVICE_COLUMN, TIME_COLUMN, *VALUE_COLUMNS]].copy()
54
- frame[TIME_COLUMN] = pandas.to_datetime(frame[TIME_COLUMN])
55
- frame[DEVICE_COLUMN] = frame[DEVICE_COLUMN].astype(str)
56
- for column in VALUE_COLUMNS:
57
- frame[column] = pandas.to_numeric(frame[column], errors="coerce")
58
- return frame.sort_values([DEVICE_COLUMN, TIME_COLUMN], kind="stable").reset_index(drop=True)
59
-
60
-
61
- def _setting(settings, name, default):
62
- if isinstance(settings, dict):
63
- return settings.get(name, default)
64
- return getattr(settings, name, default)
65
-
66
-
67
- def _normalize_locations(locations):
68
- frame = locations.copy().reset_index(drop=True)
69
- aliases = {"device_id": "battery", "building_id": "building", "room_id": "room"}
70
- frame = frame.rename(columns={old: new for old, new in aliases.items() if new not in frame})
71
- required = {"battery", "building", "room"}
72
- missing = required - set(frame.columns)
73
- if missing:
74
- raise ValueError(f"Locations is missing required columns: {sorted(missing)}")
75
- if frame["battery"].duplicated().any():
76
- raise ValueError("Each battery must have exactly one location")
77
- return frame
78
-
79
-
80
- def _travel_lookup(travel_costs):
81
- frame = travel_costs.copy()
82
- required = {"from", "to", "hours"}
83
- missing = required - set(frame.columns)
84
- if missing:
85
- raise ValueError(f"Travel costs is missing required columns: {sorted(missing)}")
86
- return {(str(row["from"]), str(row["to"])): float(row["hours"]) for _, row in frame.iterrows()}
87
-
88
-
89
- def order_daily_route(batteries, locations, travel_costs, base_building):
90
- selected = set(str(value) for value in batteries)
91
- if not selected:
92
- return []
93
- loc = _normalize_locations(locations).set_index("battery")
94
- travel = _travel_lookup(travel_costs)
95
- buildings = set(loc.loc[list(selected), "building"].astype(str))
96
- current = str(base_building)
97
- building_order = []
98
- while buildings:
99
- next_building = min(
100
- buildings,
101
- key=lambda building: (
102
- travel.get((current, building), 0.0 if current == building else float("inf")),
103
- building,
104
- ),
105
- )
106
- building_order.append(next_building)
107
- buildings.remove(next_building)
108
- current = next_building
109
-
110
- ordered = []
111
- for building in building_order:
112
- subset = loc.loc[list(selected)]
113
- subset = subset.loc[subset["building"].astype(str) == building].copy()
114
- subset["battery_key"] = subset.index.astype(str)
115
- subset = subset.sort_values(["room", "battery_key"], kind="stable")
116
- ordered.extend(subset.index.astype(str).tolist())
117
- return ordered
118
-
119
-
120
- class MilpPlanner(Planner):
121
- def __init__(self, rul_estimator, solver_time_limit_seconds=20.0, late_risk_multiplier=1.0):
122
  self.rul_estimator = rul_estimator
123
- self.solver_time_limit_seconds = float(solver_time_limit_seconds)
124
- self.late_risk_multiplier = float(late_risk_multiplier)
125
-
126
- def _expected_costs(self, timeseries, batteries, settings):
127
- horizon = int(round(float(_setting(settings, "planning_window_days", 42))))
128
- normalized = normalize_timeseries(timeseries)
129
- scenario_start = normalized["end_time"].max().normalize()
130
- horizon_end = scenario_start + pandas.Timedelta(days=horizon)
131
- emergency_delay = 6 - horizon_end.weekday()
132
- costs = self.rul_estimator.expected_replacement_costs(
133
- timeseries,
134
- horizon_days=horizon,
135
- early_penalty=float(_setting(settings, "early_replacement_penalty_daily", 0.5)),
136
- late_penalty=float(_setting(settings, "late_replacement_penalty_daily", 10.0))
137
- * float(getattr(self, "late_risk_multiplier", 1.0)),
138
- no_swap_extension_days=emergency_delay,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  )
140
- expected_columns = list(range(horizon + 1)) + ["no_swap"]
141
- costs = costs.reindex(index=batteries, columns=expected_columns)
142
- finite = costs.to_numpy(dtype=float)
143
- fallback = float(np.nanmax(finite[np.isfinite(finite)])) if np.isfinite(finite).any() else 1e6
144
- return costs.replace([np.inf, -np.inf], np.nan).fillna(fallback + 1e3)
145
-
146
- def _solve_assignments(self, expected_costs, locations, travel_costs, settings):
147
- loc = _normalize_locations(locations).set_index("battery")
148
- batteries = list(expected_costs.index.astype(str))
149
- horizon = int(round(float(_setting(settings, "planning_window_days", 42))))
150
- real_days = list(range(horizon + 1))
151
- actions = real_days + ["no_swap"]
152
- base = str(_setting(settings, "base_location", ""))
153
- base_room = str(_setting(settings, "base_room", ""))
154
- travel = _travel_lookup(travel_costs)
155
-
156
- model = cp_model.CpModel()
157
- assignment = {
158
- (battery, action): model.new_bool_var(f"x_{index}_{action}")
159
- for index, battery in enumerate(batteries)
160
- for action in actions
161
- }
162
- for battery in batteries:
163
- model.add_exactly_one(assignment[battery, action] for action in actions)
164
-
165
- rooms = sorted(loc.loc[batteries, "room"].astype(str).unique())
166
- buildings = sorted(loc.loc[batteries, "building"].astype(str).unique())
167
- battery_rooms = loc.loc[batteries, "room"].astype(str).to_dict()
168
- battery_buildings = loc.loc[batteries, "building"].astype(str).to_dict()
169
- room_members = {
170
- room: [battery for battery in batteries if battery_rooms[battery] == room] for room in rooms
171
- }
172
- building_members = {
173
- building: [battery for battery in batteries if battery_buildings[battery] == building]
174
- for building in buildings
175
- }
176
- room_visit = {(room, day): model.new_bool_var(f"room_{room}_{day}") for room in rooms for day in real_days}
177
- building_visit = {
178
- (building, day): model.new_bool_var(f"building_{building}_{day}")
179
- for building in buildings
180
- for day in real_days
181
- }
182
-
183
- for day in real_days:
184
- for room in rooms:
185
- members = room_members[room]
186
- for battery in members:
187
- model.add(assignment[battery, day] <= room_visit[room, day])
188
- model.add(room_visit[room, day] <= sum(assignment[battery, day] for battery in members))
189
- for building in buildings:
190
- members = building_members[building]
191
- for battery in members:
192
- model.add(assignment[battery, day] <= building_visit[building, day])
193
- model.add(
194
- building_visit[building, day] <= sum(assignment[battery, day] for battery in members)
195
  )
196
 
197
- battery_minutes = round(float(_setting(settings, "time_per_battery_hours", 0.25)) * 60)
198
- room_minutes = round(float(_setting(settings, "time_per_room_change_hours", 0.5)) * 60)
199
- building_minutes = round(float(_setting(settings, "time_per_building_change_hours", 1.0)) * 60)
200
- building_work = {}
201
- for building in buildings:
202
- round_trip = travel.get((base, building), 0.0 if base == building else 24.0)
203
- round_trip += travel.get((building, base), 0.0 if base == building else 24.0)
204
- building_work[building] = round(round_trip * 60) + (0 if building == base else building_minutes)
205
-
206
- maximum_daily = (
207
- len(batteries) * battery_minutes + len(rooms) * room_minutes + sum(building_work.values())
 
 
 
 
 
 
 
 
208
  )
209
- daily_work = {}
210
- daily_overtime = {}
211
- daily_limit_hit = {}
212
- overtime_start = round(float(_setting(settings, "overtime_start", 8.0)) * 60)
213
- daily_limit = round(float(_setting(settings, "worker_limit_daily_hours", 24.0)) * 60)
214
-
215
- for day in real_days:
216
- work = model.new_int_var(0, maximum_daily, f"work_{day}")
217
- expression = (
218
- battery_minutes * sum(assignment[battery, day] for battery in batteries)
219
- + room_minutes * sum(room_visit[room, day] for room in rooms if room != base_room)
220
- + sum(building_work[building] * building_visit[building, day] for building in buildings)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
  )
222
- model.add(work == expression)
223
- daily_work[day] = work
224
-
225
- overtime = model.new_int_var(0, maximum_daily, f"overtime_{day}")
226
- model.add(overtime >= work - overtime_start)
227
- daily_overtime[day] = overtime
228
-
229
- hit = model.new_bool_var(f"daily_limit_hit_{day}")
230
- model.add(work <= daily_limit + maximum_daily * hit)
231
- daily_limit_hit[day] = hit
232
-
233
- weekly_limit_hit = {}
234
- weekly_limit = round(float(_setting(settings, "worker_limit_weekly_hours", 24.0)) * 60)
235
- for week_start in range(0, len(real_days), 7):
236
- week_days = real_days[week_start : week_start + 7]
237
- hit = model.new_bool_var(f"weekly_limit_hit_{week_start // 7}")
238
- weekly_maximum = maximum_daily * len(week_days)
239
- model.add(
240
- sum(daily_work[day] for day in week_days) <= max(weekly_limit - 1, -1) + weekly_maximum * hit
241
  )
242
- weekly_limit_hit[week_start] = hit
243
-
244
- objective_terms = []
245
- for battery_index, battery in enumerate(batteries):
246
- for action_index, action in enumerate(actions):
247
- coefficient = int(round(float(expected_costs.loc[battery, action]) * COST_SCALE))
248
- coefficient += action_index + battery_index % 3
249
- objective_terms.append(coefficient * assignment[battery, action])
250
-
251
- minute_cost = COST_SCALE // MINUTES_PER_HOUR
252
- objective_terms.extend(minute_cost * daily_work[day] for day in real_days)
253
- overtime_factor = float(_setting(settings, "overtime_penalty_factor", 2.0))
254
- overtime_minute_cost = int(round(overtime_factor * minute_cost))
255
- objective_terms.extend(overtime_minute_cost * daily_overtime[day] for day in real_days)
256
- daily_penalty = int(round(float(_setting(settings, "worker_limit_daily_penalty", 100.0)) * COST_SCALE))
257
- weekly_penalty = int(round(float(_setting(settings, "worker_limit_weekly_penalty", 100.0)) * COST_SCALE))
258
- objective_terms.extend(daily_penalty * value for value in daily_limit_hit.values())
259
- objective_terms.extend(weekly_penalty * value for value in weekly_limit_hit.values())
260
- model.minimize(sum(objective_terms))
261
-
262
- solver = cp_model.CpSolver()
263
- solver.parameters.max_time_in_seconds = self.solver_time_limit_seconds
264
- solver.parameters.num_search_workers = 1
265
- solver.parameters.random_seed = 0
266
- status = solver.solve(model)
267
- if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
268
- return {
269
- battery: min(actions, key=lambda action: (expected_costs.loc[battery, action], str(action)))
270
- for battery in batteries
271
- }
272
- return {
273
- battery: next(action for action in actions if solver.value(assignment[battery, action]))
274
- for battery in batteries
275
- }
276
-
277
- def plan(self, timeseries, locations, travel_costs, settings):
278
- loc = _normalize_locations(locations)
279
- batteries = sorted(loc["battery"].astype(str).tolist())
280
- normalized_timeseries = normalize_timeseries(timeseries)
281
- if normalized_timeseries.empty:
282
- if "end_time" not in loc:
283
- raise ValueError("Cannot determine scenario start time")
284
- start_time = pandas.to_datetime(loc["end_time"]).max().normalize()
285
- else:
286
- start_time = normalized_timeseries["end_time"].max().normalize()
287
 
288
- expected_costs = self._expected_costs(timeseries, batteries, settings)
289
- assignments = self._solve_assignments(expected_costs, loc, travel_costs, settings)
290
- horizon = int(round(float(_setting(settings, "planning_window_days", 42))))
291
- base = str(_setting(settings, "base_location", ""))
292
- records = []
293
- for day in range(horizon + 1):
294
- selected = [battery for battery in batteries if assignments[battery] == day]
295
- for battery in order_daily_route(selected, loc, travel_costs, base):
296
- records.append({"day": start_time + pandas.Timedelta(days=day), "battery": battery})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
 
298
- no_swap_day = start_time + pandas.Timedelta(days=horizon + 1)
299
- for battery in sorted(battery for battery in batteries if assignments[battery] == "no_swap"):
300
- records.append({"day": no_swap_day, "battery": battery})
 
301
 
302
- plan = pandas.DataFrame.from_records(records, columns=["day", "battery"]).reset_index(drop=True)
303
- plan["day"] = pandas.to_datetime(plan["day"])
304
- check_plan_valid(plan, loc, start_time=start_time)
305
- return plan
306
 
 
 
 
 
 
 
 
 
307
 
 
 
 
 
 
308
 
 
 
 
309
  class DummyRULModel(RULModel):
310
  # RUL model that predicts (no information rate)
311
  # FIXME: make a model that actually uses the data to improve predictions
@@ -486,38 +563,6 @@ class DummyRULModel(RULModel):
486
  out = pd.DataFrame(preds, index=pd.Index(ids, name=self.group_col))
487
  return out[self.quantile_cols]
488
 
489
- def expected_replacement_costs(
490
- self,
491
- timeseries: pd.DataFrame,
492
- horizon_days,
493
- early_penalty,
494
- late_penalty,
495
- no_swap_extension_days,
496
- ) -> pd.DataFrame:
497
- """Adapter so MilpPlanner can consume this point-forecast RUL model.
498
-
499
- Treats the p50 prediction as a deterministic failure day (no
500
- distributional information available) and prices each candidate
501
- swap day with a linear early/late penalty around that day.
502
- """
503
- horizon = int(round(float(horizon_days)))
504
- emergency_day = horizon + int(round(float(no_swap_extension_days)))
505
- predicted_days = self.predict(timeseries)['p50'].clip(lower=0.0)
506
-
507
- replacement_days = np.arange(horizon + 1, dtype=float)
508
- rows = {}
509
- for battery, day in predicted_days.items():
510
- early = np.maximum(day - replacement_days, 0.0)
511
- late = np.maximum(replacement_days - day, 0.0)
512
- costs = float(early_penalty) * early + float(late_penalty) * late
513
- no_swap_cost = (
514
- float(late_penalty) * max(emergency_day - day, 0.0) if day <= horizon else 0.0
515
- )
516
- rows[battery] = np.append(costs, no_swap_cost)
517
-
518
- columns = list(range(horizon + 1)) + ["no_swap"]
519
- return pd.DataFrame.from_dict(rows, orient='index', columns=columns)
520
-
521
 
522
  def train_rul_model(locations, timeseries, eol_times, scenarios, limit_scenarios=None):
523
 
@@ -577,8 +622,6 @@ class Config(BaseSettings):
577
 
578
  dataset_path: Optional[Path] = None
579
  split : str = 'train'
580
- solver_time_limit_seconds: float = 20.0
581
- late_risk_multiplier: float = 1.0
582
 
583
  def main():
584
  cfg = Config()
@@ -609,11 +652,7 @@ def main():
609
  travel_costs = scenario['travel_costs']
610
  settings = scenario['settings']
611
 
612
- planner = MilpPlanner(
613
- rul_model,
614
- solver_time_limit_seconds=cfg.solver_time_limit_seconds,
615
- late_risk_multiplier=cfg.late_risk_multiplier,
616
- )
617
  plan = planner.plan(cut, locs, travel_costs, settings)
618
 
619
  start_time = pandas.Timestamp(scenario['start_time'])
@@ -624,11 +663,7 @@ def main():
624
 
625
 
626
  # Save best planner
627
- planner = MilpPlanner(
628
- rul_model,
629
- solver_time_limit_seconds=cfg.solver_time_limit_seconds,
630
- late_risk_multiplier=cfg.late_risk_multiplier,
631
- )
632
 
633
  planner_path = 'batteryswap_example/planners/best.pickle'
634
  with open(planner_path, "wb") as f:
 
16
  from pydantic import Field
17
  from pydantic_settings import BaseSettings, SettingsConfigDict
18
  import structlog
 
19
 
20
  from batteryswap_public.interfaces import Planner, RULModel
21
  from batteryswap_public.utils import load_dataset, iterate_scenarios
 
24
  log = structlog.get_logger()
25
 
26
 
27
+ class OrderedPlanner(Planner):
28
+ def __init__(
29
+ self,
30
+ rul_estimator,
31
+ safety_days=14,
32
+ max_swaps_per_day=6,
33
+ sa_iterations=1000,
34
+ initial_temperature=500.0,
35
+ cooling_rate=0.995,
36
+ random_seed=42,
37
+ ):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  self.rul_estimator = rul_estimator
39
+ self.safety_days = safety_days
40
+ self.max_swaps_per_day = max_swaps_per_day
41
+
42
+ # Simulated annealing parameters
43
+ self.sa_iterations = sa_iterations
44
+ self.initial_temperature = initial_temperature
45
+ self.cooling_rate = cooling_rate
46
+ self.random_seed = random_seed
47
+
48
+ def _score_plan(
49
+ self,
50
+ plan,
51
+ locations,
52
+ travel_costs,
53
+ settings,
54
+ predicted_eol,
55
+ start_time,
56
+ ):
57
+ """
58
+ Evaluate a candidate schedule.
59
+
60
+ True EOL is unavailable at prediction time, so predicted EOL
61
+ is used as a surrogate inside the official cost function.
62
+ """
63
+
64
+ try:
65
+ check_plan_valid(
66
+ plan,
67
+ locations,
68
+ start_time=start_time,
69
+ )
70
+
71
+ _, _, overall = evaluate_plan(
72
+ plan,
73
+ locations,
74
+ travel_costs,
75
+ settings,
76
+ eol_times=predicted_eol,
77
+ start_time=start_time,
78
+ )
79
+
80
+ return float(overall["total_cost"])
81
+
82
+ except Exception:
83
+ # Invalid schedules should never be selected
84
+ return float("inf")
85
+
86
+ def _make_initial_plan(
87
+ self,
88
+ order,
89
+ start_time,
90
+ ):
91
+ """
92
+ Build the 14-day heuristic schedule that we already know works.
93
+ """
94
+
95
+ planned_days = []
96
+
97
+ current_day = start_time
98
+ swaps_today = 0
99
+
100
+ for battery, row in order.iterrows():
101
+
102
+ desired_day = max(
103
+ start_time,
104
+ row["target_day"],
105
+ )
106
+
107
+ if desired_day > current_day:
108
+ current_day = desired_day
109
+ swaps_today = 0
110
+
111
+ if swaps_today >= self.max_swaps_per_day:
112
+ current_day += pandas.Timedelta(days=1)
113
+ swaps_today = 0
114
+
115
+ planned_days.append(current_day)
116
+ swaps_today += 1
117
+
118
+ planned_days = pandas.to_datetime(
119
+ planned_days
120
+ ).normalize()
121
+
122
+ return pandas.DataFrame({
123
+ "day": planned_days,
124
+ "battery": order.index,
125
+ }).reset_index(drop=True)
126
+
127
+ def _neighbor(
128
+ self,
129
+ plan,
130
+ start_time,
131
+ rng,
132
+ ):
133
+ """
134
+ Produce a nearby schedule.
135
+
136
+ Two types of moves:
137
+ 1. Move one battery a few days earlier/later.
138
+ 2. Swap the scheduled days of two batteries.
139
+ """
140
+
141
+ candidate = plan.copy()
142
+
143
+ n = len(candidate)
144
+
145
+ if n < 2:
146
+ return candidate
147
+
148
+ move_type = rng.integers(0, 2)
149
+
150
+
151
+ #Step 1: shift one battery
152
+ if move_type == 0:
153
+
154
+ idx = int(rng.integers(0, n))
155
+
156
+ # Bias moves toward earlier scheduling
157
+ if rng.random() < 0.7:
158
+ shift = -int(rng.integers(1, 15))
159
+ else:
160
+ shift = int(rng.integers(1, 8))
161
+
162
+ new_day = (
163
+ candidate.loc[idx, "day"]
164
+ + pandas.Timedelta(days=shift)
165
+ )
166
+
167
+ # Never schedule before planning begins
168
+ if new_day < start_time:
169
+ new_day = start_time
170
+
171
+ candidate.loc[idx, "day"] = new_day
172
+
173
+
174
+ # Step 2: swap two batteries' days
175
+ else:
176
+
177
+ i, j = rng.choice(
178
+ n,
179
+ size=2,
180
+ replace=False,
181
+ )
182
+
183
+ day_i = candidate.loc[i, "day"]
184
+ day_j = candidate.loc[j, "day"]
185
+
186
+ candidate.loc[i, "day"] = day_j
187
+ candidate.loc[j, "day"] = day_i
188
+
189
+ candidate["day"] = pandas.to_datetime(
190
+ candidate["day"]
191
+ ).dt.normalize()
192
+
193
+ return candidate
194
+
195
+ def plan(
196
+ self,
197
+ battery_data,
198
+ locations,
199
+ travel_costs,
200
+ settings,
201
+ ):
202
+
203
+
204
+ #Predict Remaining Useful Life
205
+ rul = self.rul_estimator.predict(
206
+ battery_data
207
+ )
208
+
209
+ rul_days = rul["p50"]
210
+
211
+ start_time = (
212
+ battery_data
213
+ .reset_index()["end_time"]
214
+ .max()
215
+ .normalize()
216
+ )
217
+
218
+ predicted_eol = (
219
+ start_time
220
+ + pandas.to_timedelta(
221
+ rul_days,
222
+ unit="D",
223
+ )
224
+ )
225
+
226
+ # Official evaluation uses calendar days
227
+ predicted_eol = predicted_eol.dt.normalize()
228
+
229
+
230
+ #Prepare batteries and target dates
231
+
232
+
233
+ loc = (
234
+ locations
235
+ .copy()
236
+ .set_index("battery")
237
  )
238
+
239
+ # Align prediction order explicitly
240
+ loc["predicted_eol"] = predicted_eol.reindex(
241
+ loc.index
242
+ )
243
+
244
+ loc["target_day"] = (
245
+ loc["predicted_eol"]
246
+ - pandas.to_timedelta(
247
+ self.safety_days,
248
+ unit="D",
249
+ )
250
+ )
251
+
252
+ loc["target_day"] = (
253
+ loc["target_day"]
254
+ .dt.normalize()
255
+ .clip(lower=start_time)
256
+ )
257
+
258
+
259
+ #Initial urgency/location ordering
260
+
261
+ location_columns = []
262
+
263
+ for candidate_column in [
264
+ "building",
265
+ "building_id",
266
+ "room",
267
+ "room_id",
268
+ ]:
269
+ if candidate_column in loc.columns:
270
+ location_columns.append(
271
+ candidate_column
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  )
273
 
274
+ sort_columns = [
275
+ "target_day"
276
+ ] + location_columns
277
+
278
+ order = loc.sort_values(
279
+ sort_columns,
280
+ ascending=True,
281
+ )
282
+
283
+
284
+ #Start from our V3 14-day heuristic
285
+ current_plan = self._make_initial_plan(
286
+ order,
287
+ start_time,
288
+ )
289
+
290
+ # Predicted EOL series must use battery IDs
291
+ predicted_eol_for_score = (
292
+ loc["predicted_eol"].copy()
293
  )
294
+
295
+ current_cost = self._score_plan(
296
+ current_plan,
297
+ locations,
298
+ travel_costs,
299
+ settings,
300
+ predicted_eol_for_score,
301
+ start_time,
302
+ )
303
+
304
+ best_plan = current_plan.copy()
305
+ best_cost = current_cost
306
+
307
+
308
+ #Simulated annealing
309
+ rng = numpy.random.default_rng(
310
+ self.random_seed
311
+ )
312
+
313
+ temperature = self.initial_temperature
314
+
315
+ for iteration in range(
316
+ self.sa_iterations
317
+ ):
318
+
319
+ candidate_plan = self._neighbor(
320
+ current_plan,
321
+ start_time,
322
+ rng,
323
  )
324
+
325
+ candidate_cost = self._score_plan(
326
+ candidate_plan,
327
+ locations,
328
+ travel_costs,
329
+ settings,
330
+ predicted_eol_for_score,
331
+ start_time,
 
 
 
 
 
 
 
 
 
 
 
332
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
333
 
334
+ delta = (
335
+ candidate_cost
336
+ - current_cost
337
+ )
338
+
339
+ # Always accept improvements
340
+ accept = delta <= 0
341
+
342
+ # Sometimes accept worse solutions
343
+ # so SA can escape local minima
344
+ if (
345
+ not accept
346
+ and numpy.isfinite(candidate_cost)
347
+ and temperature > 1e-8
348
+ ):
349
+
350
+ probability = numpy.exp(
351
+ -delta / temperature
352
+ )
353
+
354
+ if rng.random() < probability:
355
+ accept = True
356
+
357
+ if accept:
358
+ current_plan = candidate_plan
359
+ current_cost = candidate_cost
360
 
361
+ # Remember the best schedule ever seen
362
+ if current_cost < best_cost:
363
+ best_plan = current_plan.copy()
364
+ best_cost = current_cost
365
 
366
+ temperature *= self.cooling_rate
 
 
 
367
 
368
+
369
+ #Final validation
370
+ best_plan["day"] = (
371
+ pandas.to_datetime(
372
+ best_plan["day"]
373
+ )
374
+ .dt.normalize()
375
+ )
376
 
377
+ check_plan_valid(
378
+ best_plan,
379
+ locations,
380
+ start_time=start_time,
381
+ )
382
 
383
+ return best_plan
384
+
385
+
386
  class DummyRULModel(RULModel):
387
  # RUL model that predicts (no information rate)
388
  # FIXME: make a model that actually uses the data to improve predictions
 
563
  out = pd.DataFrame(preds, index=pd.Index(ids, name=self.group_col))
564
  return out[self.quantile_cols]
565
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
566
 
567
  def train_rul_model(locations, timeseries, eol_times, scenarios, limit_scenarios=None):
568
 
 
622
 
623
  dataset_path: Optional[Path] = None
624
  split : str = 'train'
 
 
625
 
626
  def main():
627
  cfg = Config()
 
652
  travel_costs = scenario['travel_costs']
653
  settings = scenario['settings']
654
 
655
+ planner = OrderedPlanner(rul_model)
 
 
 
 
656
  plan = planner.plan(cut, locs, travel_costs, settings)
657
 
658
  start_time = pandas.Timestamp(scenario['start_time'])
 
663
 
664
 
665
  # Save best planner
666
+ planner = OrderedPlanner(rul_model)
 
 
 
 
667
 
668
  planner_path = 'batteryswap_example/planners/best.pickle'
669
  with open(planner_path, "wb") as f: