Swap OrderedPlanner heuristic for CP-SAT MILP planner

#1
by Fayzul - opened
batteryswap_example/planners/best.pickle CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:098ee847de3b9cbe57b2ccdadc65213625cd06585bd2fcb492bb8f8eed56693c
3
- size 201765
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d298671d2c8f3c4374b5f2d75edae3e7eceff08985f857bd406ebc9e3f450df4
3
+ size 6525860
batteryswap_example/train.py CHANGED
@@ -1,14 +1,21 @@
 
 
 
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
12
 
13
  from batteryswap_public.interfaces import Planner, RULModel
14
  from batteryswap_public.utils import load_dataset, iterate_scenarios
@@ -16,1067 +23,323 @@ from batteryswap_public.evaluate import evaluate_plan, check_plan_valid
16
 
17
  log = structlog.get_logger()
18
 
19
- COST_SCALE = 600
20
- MINUTES_PER_HOUR = 60
21
-
22
- DEVICE_COLUMN = "device_id"
23
- TIME_COLUMN = "end_time"
24
- VALUE_COLUMNS = ("voltage", "temperature")
25
-
26
 
27
- def normalize_timeseries(timeseries):
28
- frame = timeseries.copy()
29
- missing_identity = {DEVICE_COLUMN, TIME_COLUMN} - set(frame.columns)
30
- if missing_identity:
31
- frame = frame.reset_index()
32
-
33
- required = {DEVICE_COLUMN, TIME_COLUMN, *VALUE_COLUMNS}
34
- missing = required - set(frame.columns)
35
- if missing:
36
- raise ValueError(f"Timeseries is missing required columns: {sorted(missing)}")
37
 
38
- frame = frame.loc[:, [DEVICE_COLUMN, TIME_COLUMN, *VALUE_COLUMNS]].copy()
39
- frame[TIME_COLUMN] = pandas.to_datetime(frame[TIME_COLUMN])
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):
49
- if isinstance(settings, dict):
50
- return settings.get(name, default)
51
- return getattr(settings, name, default)
52
 
 
 
 
53
 
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:
63
- raise ValueError(f"Locations is missing required columns: {sorted(missing)}")
64
- if frame["battery"].duplicated().any():
65
- raise ValueError("Each battery must have exactly one location")
66
- return frame
67
-
68
-
69
- def _travel_lookup(travel_costs):
70
- frame = travel_costs.copy()
71
- required = {"from", "to", "hours"}
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):
82
- selected = set(str(value) for value in batteries)
83
- if not selected:
84
- return []
85
- loc = _normalize_locations(locations).set_index("battery")
86
- travel = _travel_lookup(travel_costs)
87
- buildings = set(loc.loc[list(selected), "building"].astype(str))
88
- current = str(base_building)
89
- building_order = []
90
- while buildings:
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
- )
100
- building_order.append(next_building)
101
- buildings.remove(next_building)
102
- current = next_building
103
-
104
- ordered = []
105
- for building in building_order:
106
- subset = loc.loc[list(selected)]
107
- subset = subset.loc[subset["building"].astype(str) == building].copy()
108
- subset["battery_key"] = subset.index.astype(str)
109
- subset = subset.sort_values(["room", "battery_key"], kind="stable")
110
- ordered.extend(subset.index.astype(str).tolist())
111
- return ordered
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))))
135
- normalized = normalize_timeseries(timeseries)
136
- scenario_start = normalized["end_time"].max().normalize()
137
- horizon_end = scenario_start + pandas.Timedelta(days=horizon)
138
- emergency_delay = 6 - horizon_end.weekday()
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):
162
- loc = _normalize_locations(locations).set_index("battery")
163
- batteries = list(expected_costs.index.astype(str))
164
- horizon = int(round(float(_setting(settings, "planning_window_days", 42))))
165
- real_days = list(range(horizon + 1))
166
- actions = real_days + ["no_swap"]
167
- base = str(_setting(settings, "base_location", ""))
168
- base_room = str(_setting(settings, "base_room", ""))
169
- travel = _travel_lookup(travel_costs)
170
-
171
- model = cp_model.CpModel()
172
- assignment = {
173
- (battery, action): model.new_bool_var(f"x_{index}_{action}")
174
- for index, battery in enumerate(batteries)
175
- for action in actions
176
- }
177
- for battery in batteries:
178
- model.add_exactly_one(assignment[battery, action] for action in actions)
179
-
180
- rooms = sorted(loc.loc[batteries, "room"].astype(str).unique())
181
- buildings = sorted(loc.loc[batteries, "building"].astype(str).unique())
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
204
- for day in real_days
205
- }
206
-
207
- for day in real_days:
208
- for room in rooms:
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
270
-
271
- overtime = model.new_int_var(0, maximum_daily, f"overtime_{day}")
272
- model.add(overtime >= work - overtime_start)
273
- daily_overtime[day] = overtime
274
-
275
- hit = model.new_bool_var(f"daily_limit_hit_{day}")
276
- model.add(work <= daily_limit + maximum_daily * hit)
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
-
302
- minute_cost = COST_SCALE // MINUTES_PER_HOUR
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
-
363
- def plan(self, timeseries, locations, travel_costs, settings):
364
- loc = _normalize_locations(locations)
365
- batteries = sorted(loc["battery"].astype(str).tolist())
366
- normalized_timeseries = normalize_timeseries(timeseries)
367
- if normalized_timeseries.empty:
368
- if "end_time" not in loc:
369
- raise ValueError("Cannot determine scenario start time")
370
- start_time = pandas.to_datetime(loc["end_time"]).max().normalize()
371
- else:
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"
623
- RUL_VALUE_COLUMNS = ("voltage", "temperature")
624
- RUL_WINDOW_DAYS = (7, 30, 90)
625
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
626
 
627
- def _rul_slope(values, timestamps):
628
- y_all = values.to_numpy(dtype=float)
629
- time_all = timestamps.to_numpy(dtype="datetime64[ns]")
630
- valid = np.isfinite(y_all) & ~np.isnat(time_all)
631
- if np.count_nonzero(valid) < 2:
632
- return 0.0
633
- y = y_all[valid]
634
- selected_times = time_all[valid]
635
- x = (selected_times - selected_times.min()) / np.timedelta64(1, "D")
636
- x = x.astype(float)
637
- centered_x = x - x.mean()
638
- denominator = float(centered_x @ centered_x)
639
- if denominator == 0.0:
640
- return 0.0
641
- return float(centered_x @ (y - y.mean()) / denominator)
642
 
 
 
643
 
644
- def _rul_finite(value):
645
- return float(value) if np.isfinite(value) else 0.0
 
 
646
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
647
 
648
- def extract_snapshot_features(timeseries, reference_time=None, windows=RUL_WINDOW_DAYS):
649
- frame = normalize_timeseries(timeseries)
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
-
661
- for device_id, group in frame.groupby(RUL_DEVICE_COLUMN, sort=True):
662
- group = group.sort_values(RUL_TIME_COLUMN, kind="stable")
663
- first_time = group[RUL_TIME_COLUMN].iloc[0]
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
- ),
676
- "observation_count": float(len(group)),
677
- }
678
-
679
- for value_column in RUL_VALUE_COLUMNS:
680
- values = group[value_column]
681
- row[f"{value_column}_latest"] = _rul_finite(values.iloc[-1])
682
- row[f"{value_column}_mean"] = _rul_finite(values.mean())
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]
697
- prefix = f"{value_column}_{days}d"
698
- row[f"{prefix}_mean"] = _rul_finite(values.mean())
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
-
708
- features = pd.DataFrame.from_records(rows).set_index(RUL_DEVICE_COLUMN)
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()
718
- if total_probability <= 0:
719
- raise ValueError("Failure probabilities must contain positive mass")
720
- probabilities = probabilities / total_probability
721
-
722
- failure_days = np.arange(len(probabilities), dtype=float)
723
- replacement = np.asarray(replacement_days, dtype=float)[:, None]
724
- early_days = np.maximum(failure_days[None, :] - replacement, 0.0)
725
- late_days = np.maximum(replacement - failure_days[None, :], 0.0)
726
- costs = early_penalty * early_days + late_penalty * late_days
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()
736
- if total_probability <= 0:
737
- raise ValueError("Failure probabilities must contain positive mass")
738
- probabilities = probabilities / total_probability
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
752
-
753
- def __init__(self, random_state=0):
754
- self.random_state = int(random_state)
755
- self.model_ = None
756
- self.feature_columns_ = []
757
- self.feature_medians_ = pd.Series(dtype=float)
758
- self.n_periods_ = self.horizon_cap_days // self.period_days
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)
768
- else:
769
- numeric = numeric.reindex(columns=self.feature_columns_)
770
- return numeric.fillna(self.feature_medians_).astype(float)
771
-
772
- def _expand_person_periods(self, features, durations, events):
773
- n_periods = self.n_periods_
774
- row_index = []
775
- row_periods = []
776
- labels = []
777
- for idx in features.index:
778
- duration = float(durations[idx])
779
- event = bool(events[idx])
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)
789
- max_period = min(max_period, n_periods)
790
- for period in range(max_period):
791
- row_index.append(idx)
792
- row_periods.append(period)
793
- is_failure = failure_period is not None and period == failure_period
794
- labels.append(1 if is_failure else 0)
795
- if is_failure:
796
- break
797
- period_features = features.loc[row_index].copy()
798
- period_features["period"] = row_periods
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:
820
- from sklearn.ensemble import HistGradientBoostingClassifier
821
-
822
- model = HistGradientBoostingClassifier(random_state=self.random_state)
823
- model.fit(period_features.to_numpy(dtype=float), labels)
824
- self.model_ = model
825
- return self
826
 
827
- def fit(self, timeseries, rul):
828
- features = extract_snapshot_features(timeseries)
829
- labels = pd.to_numeric(rul, errors="coerce").reindex(features.index)
830
- events = pd.Series(True, index=features.index)
831
- return self.fit_snapshots(features, labels, events)
832
-
833
- def _period_hazards(self, features):
834
- prepared = self._prepare_features(features, fitting=False)
835
- n = len(prepared)
836
- n_periods = self.n_periods_
837
- hazards = np.full((n, n_periods), self.fallback_hazard_, dtype=float)
838
- if self.model_ is not None:
839
- base = prepared.to_numpy(dtype=float)
840
- for period in range(n_periods):
841
- period_col = np.full((n, 1), float(period), dtype=float)
842
- x = np.hstack([base, period_col])
843
- try:
844
- hazards[:, period] = self.model_.predict_proba(x)[:, 1]
845
- except (ArithmeticError, ValueError):
846
- pass
847
- return np.clip(hazards, 1e-4, 1.0 - 1e-4)
848
-
849
- def _survival(self, features, times):
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)
859
- for i, t in enumerate(times):
860
- period_idx = min(int(t // self.period_days), self.n_periods_)
861
- result[i, :] = period_survival[:, period_idx]
862
- return result
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
-
883
- def failure_probabilities(self, timeseries, max_day):
884
- features = extract_snapshot_features(timeseries)
885
- times = np.arange(max_day + 2, dtype=float)
886
- survival = self._survival(features, times)
887
- interval_mass = np.maximum(survival[:-1] - survival[1:], 0.0)
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)
911
- replacement_days = np.arange(int(horizon_days) + 1)
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)
928
- ]
929
- columns = list(replacement_days) + ["no_swap"]
930
- return pd.DataFrame(rows, index=probabilities.index, columns=columns)
931
 
 
 
 
 
 
932
 
933
- def split_scenarios(scenarios, val_fraction=0.25, seed=0):
934
- """Deterministic shuffled train/val split across scenarios (not a
935
- prefix-limit) so evaluation isn't done on the same data used to fit."""
936
- import random
937
 
938
- rng = random.Random(seed)
939
- shuffled = list(scenarios)
940
- rng.shuffle(shuffled)
941
- n_val = max(1, round(len(shuffled) * val_fraction))
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 = []
951
 
952
- gen = iterate_scenarios(locations, timeseries, eol_times, scenarios)
953
- for scenario_number, (scenario, locs, cut, scenario_eol) in enumerate(gen):
954
- if limit_scenarios is not None and scenario_number >= limit_scenarios:
955
- break
956
- scenario_name = str(scenario["name"])
957
- scenario_start = pd.Timestamp(scenario["start_time"])
958
- features = extract_snapshot_features(cut, reference_time=scenario_start)
959
- batteries = features.index.astype(str)
960
-
961
- loc_by_battery = locs.set_index("battery")
962
- observed_eol = pd.to_datetime(scenario_eol.reindex(batteries))
963
- censor_end = pd.to_datetime(loc_by_battery.loc[batteries, "end_time"])
964
- event = observed_eol.notna()
965
- endpoint = observed_eol.where(event, censor_end)
966
- duration = ((endpoint - scenario_start) / pd.Timedelta(days=1)).astype(float)
967
- duration = duration.clip(lower=0.25)
968
-
969
- snapshot_index = pd.Index(
970
- [f"{battery}::{scenario_name}" for battery in batteries], name="snapshot_id"
971
- )
972
- features = features.copy()
973
- features.index = snapshot_index
974
- duration.index = snapshot_index
975
- event.index = snapshot_index
976
- feature_parts.append(features)
977
- duration_parts.append(duration.rename("duration"))
978
- event_parts.append(event.astype(bool).rename("event"))
979
-
980
- if not feature_parts:
981
- raise ValueError("No training scenarios produced snapshot features")
982
- return (
983
- pd.concat(feature_parts, axis=0),
984
- pd.concat(duration_parts, axis=0),
985
- pd.concat(event_parts, axis=0),
986
- )
987
 
 
988
 
989
- def train_rul_model(locations, timeseries, eol_times, scenarios, limit_scenarios=None):
990
- features, durations, events = build_training_snapshots(
991
- locations, timeseries, eol_times, scenarios, limit_scenarios=limit_scenarios
992
- )
993
- log.info(
994
- "training-snapshots",
995
- rows=len(features),
996
- features=len(features.columns),
997
- observed_events=int(events.sum()),
998
- censored=int((~events).sum()),
999
- )
1000
- return DiscreteHazardRULModel().fit_snapshots(features, durations, events)
1001
 
1002
 
1003
  class Config(BaseSettings):
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,
1058
- solver_time_limit_seconds=cfg.solver_time_limit_seconds,
1059
- late_risk_multiplier=cfg.late_risk_multiplier,
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
1075
- planner = MilpPlanner(
1076
- rul_model_full,
1077
- solver_time_limit_seconds=cfg.solver_time_limit_seconds,
1078
- late_risk_multiplier=cfg.late_risk_multiplier,
1079
- )
1080
 
1081
  planner_path = 'batteryswap_example/planners/best.pickle'
1082
  with open(planner_path, "wb") as f:
 
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
+ from sklearn.ensemble import ExtraTreesRegressor
12
  import pandas as pd
13
+ import numpy
14
  import numpy as np
15
+ import pandas
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
 
23
 
24
  log = structlog.get_logger()
25
 
 
 
 
 
 
 
 
26
 
27
+ class OrderedPlanner(Planner):
28
+ def __init__(self, rul_estimator):
29
+ self.rul_estimator = rul_estimator
 
 
 
 
 
 
 
30
 
31
+ def plan(self, battery_data, locations, travel_costs, settings):
32
+
33
+ # Remaining Useful Life estimation
34
+ # FIXME: consider balance of over/under-estimation
35
+ percentile = 'p50'
36
+ rul = self.rul_estimator.predict(battery_data)
37
+ rul_days = rul[percentile]
38
+ # convert to a date
39
+ start_time = battery_data.reset_index()['end_time'].max().normalize()
40
+ predict_eol = start_time + pandas.to_timedelta(rul_days, unit='D')
41
+
42
+ loc = locations.copy().set_index('battery')
43
+ loc['eol_time'] = predict_eol
44
+ order = loc.sort_values('eol_time', ascending=True)
45
+
46
+ # Planner
47
+ # Stupid heuristic: Do one swap per day
48
+ # FIXME: take travel distances into account
49
+ # FIXME: take co-location into account
50
+ # FIXME: take daily and weekly limits into account
51
+ days = start_time + pandas.to_timedelta(numpy.arange(len(order)), unit='D')
52
+ plan = pandas.DataFrame({
53
+ 'day': days,
54
+ 'battery': order.index,
55
+ })
56
+
57
+ check_plan_valid(plan, locations, start_time=start_time)
58
 
59
+ return plan
60
 
 
 
 
 
61
 
62
+ class DummyRULModel(RULModel):
63
+ # RUL model that predicts (no information rate)
64
+ # FIXME: make a model that actually uses the data to improve predictions
65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  def __init__(
67
  self,
68
+ time_col: str = 'end_time',
69
+ group_col: str = 'device_id',
70
+ value_cols: Sequence[str] = ('voltage', 'temperature'),
71
+ quantiles: Sequence[float] = (0.5, ),
 
72
  ):
73
+ self.time_col = time_col
74
+ self.group_col = group_col
75
+ self.value_cols = list(value_cols)
76
+ self.quantiles = sorted(quantiles)
77
+ self.quantile_cols = [f"p{round(q * 100):02d}" for q in self.quantiles]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
 
79
+ self.model = None
80
+ self.use_total_elapsed_days = True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
 
82
+ def _compute_features(self, unit_df: pd.DataFrame) -> np.ndarray:
83
+ unit_df = unit_df.sort_index(level=self.time_col).copy()
84
+ if len(unit_df) == 0:
85
+ raise ValueError("Received empty battery time series")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
 
87
+ all_stats = {}
88
+
89
+ # Convert timestamps to elapsed days
90
+ times = pd.to_datetime(
91
+ unit_df.index.get_level_values(self.time_col)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  )
93
+ elapsed_days = (
94
+ times - times[0]
95
+ ).total_seconds().to_numpy() / 86400.0
96
+
97
+
98
+ # General history features
99
+ all_stats["n_obs"] = float(len(unit_df))
100
+ all_stats["history_days"] = (
101
+ float(elapsed_days[-1]) if len(elapsed_days) > 1 else 0.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  )
 
 
 
 
 
 
 
 
 
 
 
103
 
104
+ # Features for voltage and temperature
105
+ for col in self.value_cols:
106
+ values = pd.to_numeric(
107
+ unit_df[col],
108
+ errors="coerce"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
 
111
+ valid = values.notna()
112
+
113
+ if valid.sum() == 0:
114
+ all_stats[f"{col}_latest"] = 0.0
115
+ all_stats[f"{col}_mean"] = 0.0
116
+ all_stats[f"{col}_std"] = 0.0
117
+ all_stats[f"{col}_min"] = 0.0
118
+ all_stats[f"{col}_max"] = 0.0
119
+ all_stats[f"{col}_change"] = 0.0
120
+ all_stats[f"{col}_slope"] = 0.0
121
+ continue
122
+
123
+ x = values[valid].to_numpy(dtype=float)
124
+ t = elapsed_days[valid.to_numpy()]
125
+
126
+ all_stats[f"{col}_latest"] = float(x[-1])
127
+ all_stats[f"{col}_mean"] = float(np.mean(x))
128
+ all_stats[f"{col}_std"] = float(np.std(x))
129
+ all_stats[f"{col}_min"] = float(np.min(x))
130
+ all_stats[f"{col}_max"] = float(np.max(x))
131
+ all_stats[f"{col}_change"] = float(x[-1] - x[0])
132
+
133
+ # Recent-window features
134
+ for window_days in (7, 14, 30):
135
+ cutoff = t[-1] - window_days
136
+ recent_mask = t >= cutoff
137
+
138
+ recent_x = x[recent_mask]
139
+ recent_t = t[recent_mask]
140
+
141
+ prefix = f"{col}_{window_days}d"
142
+
143
+ if len(recent_x) > 0:
144
+ all_stats[f"{prefix}_mean"] = float(np.mean(recent_x))
145
+ all_stats[f"{prefix}_std"] = float(np.std(recent_x))
146
+ all_stats[f"{prefix}_min"] = float(np.min(recent_x))
147
+ all_stats[f"{prefix}_max"] = float(np.max(recent_x))
148
+ all_stats[f"{prefix}_change"] = float(
149
+ recent_x[-1] - recent_x[0]
150
+ )
151
+
152
+ if len(recent_x) >= 2 and np.ptp(recent_t) > 0:
153
+ recent_slope = np.polyfit(
154
+ recent_t,
155
+ recent_x,
156
+ 1
157
+ )[0]
158
+ else:
159
+ recent_slope = 0.0
160
+
161
+ all_stats[f"{prefix}_slope"] = float(recent_slope)
162
+ else:
163
+ all_stats[f"{prefix}_mean"] = 0.0
164
+ all_stats[f"{prefix}_std"] = 0.0
165
+ all_stats[f"{prefix}_min"] = 0.0
166
+ all_stats[f"{prefix}_max"] = 0.0
167
+ all_stats[f"{prefix}_change"] = 0.0
168
+ all_stats[f"{prefix}_slope"] = 0.0
169
+
170
+ # Recent level compared with overall level
171
+ if len(x) > 0:
172
+ recent_7_mask = t >= (t[-1] - 7)
173
+ recent_7 = x[recent_7_mask]
174
+
175
+ if len(recent_7) > 0:
176
+ all_stats[f"{col}_recent7_vs_mean"] = float(
177
+ np.mean(recent_7) - np.mean(x)
178
+ )
179
+ else:
180
+ all_stats[f"{col}_recent7_vs_mean"] = 0.0
181
+
182
+ # Trend per day
183
+ if len(x) >= 2 and np.ptp(t) > 0:
184
+ slope = np.polyfit(t, x, 1)[0]
185
+ else:
186
+ slope = 0.0
187
 
188
+ all_stats[f"{col}_slope"] = float(slope)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
 
190
+ feature_names = sorted(all_stats.keys())
191
+ self._feature_names_ = feature_names
192
 
193
+ return np.array(
194
+ [all_stats[k] for k in feature_names],
195
+ dtype=float,
196
+ )
197
 
198
+ def _build_feature_matrix(self, timeseries: pd.DataFrame) -> tuple[np.ndarray, list]:
199
+ rows, ids = [], []
200
+ for unit_id, unit_df in timeseries.groupby(
201
+ self.group_col,
202
+ observed=True
203
+ ):
204
+ if len(unit_df) < 2:
205
+ continue
206
+ rows.append(self._compute_features(unit_df))
207
+ ids.append(unit_id)
208
+ return np.vstack(rows), ids
209
+
210
+ def fit(self, timeseries: pd.DataFrame, rul: pd.Series):
211
+ X, ids = self._build_feature_matrix(timeseries)
212
+ y = np.array([rul[unit_id] for unit_id in ids])
213
+
214
+ # FIXME: actually use an estimator that learns
215
+ self.model = ExtraTreesRegressor(
216
+ n_estimators=300,
217
+ min_samples_leaf=2,
218
+ random_state=42,
219
+ n_jobs=-1,
220
+ )
221
+ self.model.fit(X, y)
222
+ return self
223
 
224
+ def predict(self, timeseries: pd.DataFrame) -> pd.DataFrame:
225
+ rows, ids = [], []
226
+ for unit_id, unit_df in timeseries.groupby(
227
+ self.group_col,
228
+ observed=True
229
+ ):
230
+ rows.append(self._compute_features(unit_df))
231
+ ids.append(unit_id)
232
 
233
+ X = np.vstack(rows)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
 
235
+ # every quantile column just gets the single point prediction.
236
+ point_pred = self.model.predict(X)
237
+ preds = {col: point_pred for col in self.quantile_cols}
 
 
 
 
 
 
 
 
 
 
 
 
238
 
239
+ out = pd.DataFrame(preds, index=pd.Index(ids, name=self.group_col))
240
+ return out[self.quantile_cols]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
 
242
 
243
+ def train_rul_model(locations, timeseries, eol_times, scenarios, limit_scenarios=None):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
 
245
+ # Collect training data
246
+ # FIXME: train/validate/test split to estimate generalized predictive performance
247
+ gen = iterate_scenarios(locations, timeseries, eol_times, scenarios)
248
+ cut_dfs = []
249
+ cut_ruls = []
250
+ scenarios_loaded = 0
251
+ for scenario, locs, cut, eol in gen:
252
+ print('load-scenario')
253
+ cut = cut.reset_index()
254
+ cut['device_id'] = cut['device_id'].astype(str) + scenario['name']
255
+ cut = cut.set_index(['device_id', 'end_time'])
256
+ cut_dfs.append(cut)
257
+ plan_start = pandas.Timestamp(scenario['start_time'])
258
+ unobserved_rul = 120
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
 
260
+ # Convert EOL datetimes to RUL in days relative to planning time
261
+ rul_days = (eol - plan_start) / pandas.Timedelta(days=1)
262
+ rul_days.index = pandas.Series(rul_days.index) + scenario['name']
263
+ rul_days = rul_days.fillna(unobserved_rul)
264
+ cut_ruls.append(rul_days)
265
 
266
+ assert set(rul_days.index) == set(cut.index.get_level_values('device_id'))
 
 
 
267
 
268
+ if limit_scenarios is not None:
269
+ if scenarios_loaded > limit_scenarios:
270
+ break
271
+ scenarios_loaded += 1
 
272
 
273
+
274
+ # Train a RUL prediction model
275
+ rul_model = DummyRULModel()
276
+ X = pandas.concat(cut_dfs)
277
+ Y = pandas.concat(cut_ruls)
278
 
279
+ print(X.head())
280
+ print(Y.head())
 
 
 
 
281
 
282
+ rul_model.fit(X, Y)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
 
284
+ # FIXME: do model selection
285
 
286
+ return rul_model
 
 
 
 
 
 
 
 
 
 
 
287
 
288
 
289
  class Config(BaseSettings):
290
  """
291
  Automatically provides command-line argument support for specified fields
292
  """
 
293
  model_config = SettingsConfigDict(
294
  env_prefix="",
295
  cli_parse_args=True,
296
  cli_ignore_unknown_args=True,
297
  )
298
+
299
  dataset_path: Optional[Path] = None
300
+ split : str = 'train'
 
 
 
 
 
301
 
302
  def main():
303
  cfg = Config()
304
 
305
  if cfg.dataset_path is None:
306
+ dataset_path = os.environ.get('BATTERYSWAP_DATASET_PATH', None)
307
  assert dataset_path
308
  dataset_path = Path(dataset_path)
309
  else:
310
  dataset_path = cfg.dataset_path
311
 
312
  split_path = dataset_path / cfg.split
313
+ locations, timeseries, eol_times, scenarios = load_dataset(split_path)
314
 
315
+ log.info('evaluate-load-data', path=dataset_path)
316
 
 
 
 
 
 
 
 
 
 
 
 
317
 
318
+ # Prediction model training
319
+ #rul_model = DummyRULModel()
320
+ rul_model = train_rul_model(locations, timeseries, eol_times, scenarios, limit_scenarios=1)
321
+ log.info('train-done')
322
+
323
+ log.info('evaluate')
324
+ # Evaluate on the planning scenarios
325
+ gen = iterate_scenarios(locations, timeseries, eol_times, scenarios)
326
  for scenario, locs, cut, eol in gen:
327
+ scenario_name = scenario['name']
328
+ travel_costs = scenario['travel_costs']
329
+ settings = scenario['settings']
330
+
331
+ planner = OrderedPlanner(rul_model)
 
 
 
 
332
  plan = planner.plan(cut, locs, travel_costs, settings)
333
 
334
+ start_time = pandas.Timestamp(scenario['start_time'])
335
 
336
+ transitions, daily, overall = evaluate_plan(plan, locs, travel_costs, settings, eol_times=eol, start_time=start_time)
 
 
337
 
338
+ print('scores', scenario_name, overall)
339
 
 
 
340
 
341
  # Save best planner
342
+ planner = OrderedPlanner(rul_model)
 
 
 
 
343
 
344
  planner_path = 'batteryswap_example/planners/best.pickle'
345
  with open(planner_path, "wb") as f: