import pickle import os from pathlib import Path from typing import Optional, Sequence import pandas import pandas as pd import numpy as np from pydantic_settings import BaseSettings, SettingsConfigDict import structlog from ortools.sat.python import cp_model from batteryswap_public.interfaces import Planner, RULModel from batteryswap_public.utils import load_dataset, iterate_scenarios from batteryswap_public.evaluate import evaluate_plan, check_plan_valid log = structlog.get_logger() COST_SCALE = 600 MINUTES_PER_HOUR = 60 DEVICE_COLUMN = "device_id" TIME_COLUMN = "end_time" VALUE_COLUMNS = ("voltage", "temperature") def normalize_timeseries(timeseries): frame = timeseries.copy() missing_identity = {DEVICE_COLUMN, TIME_COLUMN} - set(frame.columns) if missing_identity: frame = frame.reset_index() required = {DEVICE_COLUMN, TIME_COLUMN, *VALUE_COLUMNS} missing = required - set(frame.columns) if missing: raise ValueError(f"Timeseries is missing required columns: {sorted(missing)}") frame = frame.loc[:, [DEVICE_COLUMN, TIME_COLUMN, *VALUE_COLUMNS]].copy() frame[TIME_COLUMN] = pandas.to_datetime(frame[TIME_COLUMN]) frame[DEVICE_COLUMN] = frame[DEVICE_COLUMN].astype(str) for column in VALUE_COLUMNS: frame[column] = pandas.to_numeric(frame[column], errors="coerce") return frame.sort_values([DEVICE_COLUMN, TIME_COLUMN], kind="stable").reset_index( drop=True ) def _setting(settings, name, default): if isinstance(settings, dict): return settings.get(name, default) return getattr(settings, name, default) def _normalize_locations(locations): frame = locations.copy().reset_index(drop=True) aliases = {"device_id": "battery", "building_id": "building", "room_id": "room"} frame = frame.rename( columns={old: new for old, new in aliases.items() if new not in frame} ) required = {"battery", "building", "room"} missing = required - set(frame.columns) if missing: raise ValueError(f"Locations is missing required columns: {sorted(missing)}") if frame["battery"].duplicated().any(): raise ValueError("Each battery must have exactly one location") return frame def _travel_lookup(travel_costs): frame = travel_costs.copy() required = {"from", "to", "hours"} missing = required - set(frame.columns) if missing: raise ValueError(f"Travel costs is missing required columns: {sorted(missing)}") return { (str(row["from"]), str(row["to"])): float(row["hours"]) for _, row in frame.iterrows() } def order_daily_route(batteries, locations, travel_costs, base_building): selected = set(str(value) for value in batteries) if not selected: return [] loc = _normalize_locations(locations).set_index("battery") travel = _travel_lookup(travel_costs) buildings = set(loc.loc[list(selected), "building"].astype(str)) current = str(base_building) building_order = [] while buildings: next_building = min( buildings, key=lambda building: ( travel.get( (current, building), 0.0 if current == building else float("inf") ), building, ), ) building_order.append(next_building) buildings.remove(next_building) current = next_building ordered = [] for building in building_order: subset = loc.loc[list(selected)] subset = subset.loc[subset["building"].astype(str) == building].copy() subset["battery_key"] = subset.index.astype(str) subset = subset.sort_values(["room", "battery_key"], kind="stable") ordered.extend(subset.index.astype(str).tolist()) return ordered class MilpPlanner(Planner): def __init__( self, rul_estimator, solver_time_limit_seconds=30.0, late_risk_multiplier=1.0, candidate_benefit_threshold=30.0, solver_workers=8, ): self.rul_estimator = rul_estimator self.solver_time_limit_seconds = float(solver_time_limit_seconds) self.late_risk_multiplier = float(late_risk_multiplier) # Only batteries where swapping beats skipping by more than this margin # get decision variables. With ~450 batteries but only ~2-4% actually # due in the window, this shrinks the model ~20x and lets CP-SAT reach # optimality instead of timing out at a 99.9% gap. self.candidate_benefit_threshold = float(candidate_benefit_threshold) self.solver_workers = int(solver_workers) def _expected_costs(self, timeseries, batteries, settings): horizon = int(round(float(_setting(settings, "planning_window_days", 42)))) normalized = normalize_timeseries(timeseries) scenario_start = normalized["end_time"].max().normalize() horizon_end = scenario_start + pandas.Timedelta(days=horizon) emergency_delay = 6 - horizon_end.weekday() costs = self.rul_estimator.expected_replacement_costs( timeseries, horizon_days=horizon, early_penalty=float( _setting(settings, "early_replacement_penalty_daily", 0.5) ), late_penalty=float( _setting(settings, "late_replacement_penalty_daily", 10.0) ) * float(getattr(self, "late_risk_multiplier", 1.0)), no_swap_extension_days=emergency_delay, ) expected_columns = list(range(horizon + 1)) + ["no_swap"] costs = costs.reindex(index=batteries, columns=expected_columns) finite = costs.to_numpy(dtype=float) fallback = ( float(np.nanmax(finite[np.isfinite(finite)])) if np.isfinite(finite).any() else 1e6 ) return costs.replace([np.inf, -np.inf], np.nan).fillna(fallback + 1e3) def _solve_assignments(self, expected_costs, locations, travel_costs, settings): loc = _normalize_locations(locations).set_index("battery") batteries = list(expected_costs.index.astype(str)) horizon = int(round(float(_setting(settings, "planning_window_days", 42)))) real_days = list(range(horizon + 1)) actions = real_days + ["no_swap"] base = str(_setting(settings, "base_location", "")) base_room = str(_setting(settings, "base_room", "")) travel = _travel_lookup(travel_costs) model = cp_model.CpModel() assignment = { (battery, action): model.new_bool_var(f"x_{index}_{action}") for index, battery in enumerate(batteries) for action in actions } for battery in batteries: model.add_exactly_one(assignment[battery, action] for action in actions) rooms = sorted(loc.loc[batteries, "room"].astype(str).unique()) buildings = sorted(loc.loc[batteries, "building"].astype(str).unique()) battery_rooms = loc.loc[batteries, "room"].astype(str).to_dict() battery_buildings = loc.loc[batteries, "building"].astype(str).to_dict() room_members = { room: [battery for battery in batteries if battery_rooms[battery] == room] for room in rooms } building_members = { building: [ battery for battery in batteries if battery_buildings[battery] == building ] for building in buildings } room_visit = { (room, day): model.new_bool_var(f"room_{room}_{day}") for room in rooms for day in real_days } building_visit = { (building, day): model.new_bool_var(f"building_{building}_{day}") for building in buildings for day in real_days } for day in real_days: for room in rooms: members = room_members[room] for battery in members: model.add(assignment[battery, day] <= room_visit[room, day]) model.add( room_visit[room, day] <= sum(assignment[battery, day] for battery in members) ) for building in buildings: members = building_members[building] for battery in members: model.add(assignment[battery, day] <= building_visit[building, day]) model.add( building_visit[building, day] <= sum(assignment[battery, day] for battery in members) ) battery_minutes = round( float(_setting(settings, "time_per_battery_hours", 0.25)) * 60 ) room_minutes = round( float(_setting(settings, "time_per_room_change_hours", 0.5)) * 60 ) building_minutes = round( float(_setting(settings, "time_per_building_change_hours", 1.0)) * 60 ) building_work = {} for building in buildings: round_trip = travel.get((base, building), 0.0 if base == building else 24.0) round_trip += travel.get( (building, base), 0.0 if base == building else 24.0 ) building_work[building] = round(round_trip * 60) + ( 0 if building == base else building_minutes ) maximum_daily = ( len(batteries) * battery_minutes + len(rooms) * room_minutes + sum(building_work.values()) ) daily_work = {} daily_overtime = {} daily_limit_hit = {} overtime_start = round(float(_setting(settings, "overtime_start", 8.0)) * 60) daily_limit = round( float(_setting(settings, "worker_limit_daily_hours", 24.0)) * 60 ) for day in real_days: work = model.new_int_var(0, maximum_daily, f"work_{day}") expression = ( battery_minutes * sum(assignment[battery, day] for battery in batteries) + room_minutes * sum(room_visit[room, day] for room in rooms if room != base_room) + sum( building_work[building] * building_visit[building, day] for building in buildings ) ) model.add(work == expression) daily_work[day] = work overtime = model.new_int_var(0, maximum_daily, f"overtime_{day}") model.add(overtime >= work - overtime_start) daily_overtime[day] = overtime hit = model.new_bool_var(f"daily_limit_hit_{day}") model.add(work <= daily_limit + maximum_daily * hit) daily_limit_hit[day] = hit weekly_limit_hit = {} weekly_limit = round( float(_setting(settings, "worker_limit_weekly_hours", 24.0)) * 60 ) for week_start in range(0, len(real_days), 7): week_days = real_days[week_start : week_start + 7] hit = model.new_bool_var(f"weekly_limit_hit_{week_start // 7}") weekly_maximum = maximum_daily * len(week_days) model.add( sum(daily_work[day] for day in week_days) <= max(weekly_limit - 1, -1) + weekly_maximum * hit ) weekly_limit_hit[week_start] = hit objective_terms = [] for battery_index, battery in enumerate(batteries): for action_index, action in enumerate(actions): coefficient = int( round(float(expected_costs.loc[battery, action]) * COST_SCALE) ) coefficient += action_index + battery_index % 3 objective_terms.append(coefficient * assignment[battery, action]) minute_cost = COST_SCALE // MINUTES_PER_HOUR objective_terms.extend(minute_cost * daily_work[day] for day in real_days) overtime_factor = float(_setting(settings, "overtime_penalty_factor", 2.0)) overtime_minute_cost = int(round(overtime_factor * minute_cost)) objective_terms.extend( overtime_minute_cost * daily_overtime[day] for day in real_days ) daily_penalty = int( round( float(_setting(settings, "worker_limit_daily_penalty", 100.0)) * COST_SCALE ) ) weekly_penalty = int( round( float(_setting(settings, "worker_limit_weekly_penalty", 100.0)) * COST_SCALE ) ) objective_terms.extend( daily_penalty * value for value in daily_limit_hit.values() ) objective_terms.extend( weekly_penalty * value for value in weekly_limit_hit.values() ) model.minimize(sum(objective_terms)) solver = cp_model.CpSolver() solver.parameters.max_time_in_seconds = self.solver_time_limit_seconds solver.parameters.num_search_workers = max(1, int(getattr(self, "solver_workers", 8))) solver.parameters.random_seed = 0 status = solver.solve(model) # Never silently accept a non-solve: an UNKNOWN status here previously # fell through to the greedy fallback and cost us most of the late_swap. log.info( "cpsat-solve", status=solver.status_name(status), batteries=len(batteries), wall_seconds=round(solver.wall_time, 2), ) if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE): log.warning("cpsat-no-solution-using-greedy-fallback", status=solver.status_name(status)) return { battery: min( actions, key=lambda action: ( expected_costs.loc[battery, action], str(action), ), ) for battery in batteries } return { battery: next( action for action in actions if solver.value(assignment[battery, action]) ) for battery in batteries } def plan(self, timeseries, locations, travel_costs, settings): loc = _normalize_locations(locations) batteries = sorted(loc["battery"].astype(str).tolist()) normalized_timeseries = normalize_timeseries(timeseries) if normalized_timeseries.empty: if "end_time" not in loc: raise ValueError("Cannot determine scenario start time") start_time = pandas.to_datetime(loc["end_time"]).max().normalize() else: start_time = normalized_timeseries["end_time"].max().normalize() expected_costs = self._expected_costs(timeseries, batteries, settings) # Restrict the solve to batteries where swapping actually beats skipping. # Only ~2-4% of batteries are due in any window; giving all ~450 of them # decision variables made the model too big to solve (CP-SAT returned # UNKNOWN at a 99.9% gap). Everything below the threshold is pinned to # no_swap, which its own expected costs already say is optimal. day_costs = expected_costs.drop(columns=["no_swap"]) benefit = expected_costs["no_swap"] - day_costs.min(axis=1) # getattr keeps planners pickled before this attribute existed loadable threshold = float(getattr(self, "candidate_benefit_threshold", 30.0)) candidates = sorted(benefit[benefit > threshold].index.astype(str)) log.info("candidate-filter", total=len(batteries), candidates=len(candidates)) assignments = {battery: "no_swap" for battery in batteries} if candidates: solved = self._solve_assignments( expected_costs.loc[candidates], loc, travel_costs, settings ) assignments.update(solved) horizon = int(round(float(_setting(settings, "planning_window_days", 42)))) base = str(_setting(settings, "base_location", "")) records = [] for day in range(horizon + 1): selected = [battery for battery in batteries if assignments[battery] == day] for battery in order_daily_route(selected, loc, travel_costs, base): records.append( {"day": start_time + pandas.Timedelta(days=day), "battery": battery} ) no_swap_day = start_time + pandas.Timedelta(days=horizon + 1) for battery in sorted( battery for battery in batteries if assignments[battery] == "no_swap" ): records.append({"day": no_swap_day, "battery": battery}) plan = pandas.DataFrame.from_records( records, columns=["day", "battery"] ).reset_index(drop=True) plan["day"] = pandas.to_datetime(plan["day"]) check_plan_valid(plan, loc, start_time=start_time) return plan class SearchPlanner(Planner): """Greedy construction + local search scored by the *real* evaluate_plan. The MILP approximates the official cost model; this scores candidate plans with the actual evaluator (using predicted EOL as surrogate truth), so it has zero modeling error -- it sees the emergency-visit mechanics, weekly limit accounting and end-of-day travel exactly as the scorer does. Only batteries that plausibly fail inside the window get scheduled; with ~2-4% due per scenario the search space is small enough to explore well. """ def __init__( self, rul_estimator, candidate_benefit_threshold=5.0, iterations=400, random_seed=0, late_risk_multiplier=1.0, ): self.rul_estimator = rul_estimator self.candidate_benefit_threshold = float(candidate_benefit_threshold) self.iterations = int(iterations) self.random_seed = int(random_seed) self.late_risk_multiplier = float(late_risk_multiplier) def _expected_costs(self, timeseries, batteries, settings): return MilpPlanner._expected_costs(self, timeseries, batteries, settings) @staticmethod def _build_plan(assignment, all_batteries, start_time, park_day): """assignment: battery -> day offset (int). Others parked past window.""" records = [ {"day": start_time + pandas.Timedelta(days=int(d)), "battery": b} for b, d in assignment.items() ] assigned = set(assignment) records.extend( {"day": park_day, "battery": b} for b in all_batteries if b not in assigned ) plan = pandas.DataFrame.from_records(records, columns=["day", "battery"]) # stable ordering: by day, then grouped by building/room handled by caller plan = plan.sort_values(["day", "battery"], kind="stable").reset_index(drop=True) plan["day"] = pandas.to_datetime(plan["day"]) return plan @staticmethod def _route_day(day_batteries, building_of, room_of, travel, base): """Nearest-building route for one day, using precomputed lookups. Same logic as order_daily_route but without rebuilding the location frame and travel dict on every call -- this runs inside the search loop. """ remaining = set(building_of[b] for b in day_batteries) current = str(base) building_order = [] while remaining: nxt = min( remaining, key=lambda bl: ( travel.get((current, bl), 0.0 if current == bl else float("inf")), bl, ), ) building_order.append(nxt) remaining.discard(nxt) current = nxt ordered = [] for building in building_order: here = [b for b in day_batteries if building_of[b] == building] here.sort(key=lambda b: (room_of[b], b)) ordered.extend(here) return ordered def plan(self, timeseries, locations, travel_costs, settings): import random loc = _normalize_locations(locations) batteries = sorted(loc["battery"].astype(str).tolist()) normalized = normalize_timeseries(timeseries) if normalized.empty: start_time = pandas.to_datetime(loc["end_time"]).max().normalize() else: start_time = normalized["end_time"].max().normalize() horizon = int(round(float(_setting(settings, "planning_window_days", 42)))) horizon_end = start_time + pandas.Timedelta(days=horizon) base = str(_setting(settings, "base_location", "")) park_day = start_time + pandas.Timedelta(days=horizon + 1) expected_costs = self._expected_costs(timeseries, batteries, settings) day_costs = expected_costs.drop(columns=["no_swap"]) benefit = expected_costs["no_swap"] - day_costs.min(axis=1) candidates = sorted( benefit[benefit > self.candidate_benefit_threshold].index.astype(str) ) log.info("candidate-filter", total=len(batteries), candidates=len(candidates)) if not candidates: plan = self._build_plan({}, batteries, start_time, park_day) check_plan_valid(plan, loc, start_time=start_time) return plan # Surrogate EOL for scoring: the cost-minimising swap day per battery # already encodes the newsvendor trade-off (early 0.5/day vs late 10/day). target_day = {b: int(day_costs.loc[b].idxmin()) for b in candidates} surrogate_eol = pandas.Series( { b: (start_time + pandas.Timedelta(days=int(target_day[b]))) if b in target_day else park_day + pandas.Timedelta(days=365) for b in batteries } ) # Precomputed lookups so the search loop never rebuilds these. indexed = loc.set_index("battery") building_of = indexed["building"].astype(str).to_dict() room_of = indexed["room"].astype(str).to_dict() travel = _travel_lookup(travel_costs) parked = [b for b in batteries] def make_plan(assignment): by_day = {} for b, d in assignment.items(): by_day.setdefault(int(d), []).append(b) rows_day, rows_bat = [], [] for d in sorted(by_day): ordered = self._route_day( sorted(by_day[d]), building_of, room_of, travel, base ) stamp = start_time + pandas.Timedelta(days=d) rows_day.extend([stamp] * len(ordered)) rows_bat.extend(ordered) assigned = set(assignment) rest = [b for b in parked if b not in assigned] rows_day.extend([park_day] * len(rest)) rows_bat.extend(rest) plan = pandas.DataFrame({"day": rows_day, "battery": rows_bat}) plan["day"] = pandas.to_datetime(plan["day"]) return plan def score(assignment): try: _, _, overall = evaluate_plan( make_plan(assignment), loc, travel_costs, settings, eol_times=surrogate_eol, start_time=start_time, verbose=0, ) return float(overall["total_cost"]) except Exception: return float("inf") # Greedy start: everyone at their own cost-minimising day. current = dict(target_day) current_cost = score(current) # Local search. Moves: shift a day, batch onto another candidate's day, # drop a battery, or restore a dropped one. rng = random.Random(self.random_seed) best, best_cost = dict(current), current_cost used_days = sorted(set(target_day.values())) for _ in range(self.iterations): trial = dict(current) battery = rng.choice(candidates) move = rng.random() if move < 0.4 and trial: # batch: move onto a day already being worked (saves a trip) if used_days: trial[battery] = rng.choice( sorted(set(trial.values())) or used_days ) elif move < 0.75: # shift, biased earlier (late costs 20x more than early) base_day = trial.get(battery, target_day[battery]) shift = -rng.randint(1, 7) if rng.random() < 0.7 else rng.randint(1, 4) trial[battery] = int(min(max(base_day + shift, 0), horizon)) elif move < 0.9: trial.pop(battery, None) # drop else: trial[battery] = target_day[battery] # restore trial_cost = score(trial) if trial_cost <= current_cost: current, current_cost = trial, trial_cost if trial_cost < best_cost: best, best_cost = dict(trial), trial_cost log.info( "search-planner", candidates=len(candidates), scheduled=len(best), surrogate_cost=round(best_cost, 1), ) plan = make_plan(best) check_plan_valid(plan, loc, start_time=start_time) return plan RUL_DEVICE_COLUMN = "device_id" RUL_TIME_COLUMN = "end_time" RUL_VALUE_COLUMNS = ("voltage", "temperature") RUL_WINDOW_DAYS = (7, 30, 90) def _rul_slope(values, timestamps): y_all = values.to_numpy(dtype=float) time_all = timestamps.to_numpy(dtype="datetime64[ns]") valid = np.isfinite(y_all) & ~np.isnat(time_all) if np.count_nonzero(valid) < 2: return 0.0 y = y_all[valid] selected_times = time_all[valid] x = (selected_times - selected_times.min()) / np.timedelta64(1, "D") x = x.astype(float) centered_x = x - x.mean() denominator = float(centered_x @ centered_x) if denominator == 0.0: return 0.0 return float(centered_x @ (y - y.mean()) / denominator) def _rul_finite(value): return float(value) if np.isfinite(value) else 0.0 def extract_snapshot_features(timeseries, reference_time=None, windows=RUL_WINDOW_DAYS): frame = normalize_timeseries(timeseries) if frame.empty: return pd.DataFrame(index=pd.Index([], name=RUL_DEVICE_COLUMN)) reference = ( pd.Timestamp(reference_time) if reference_time is not None else frame[RUL_TIME_COLUMN].max() ) frame = frame.loc[frame[RUL_TIME_COLUMN] <= reference].copy() rows = [] for device_id, group in frame.groupby(RUL_DEVICE_COLUMN, sort=True): group = group.sort_values(RUL_TIME_COLUMN, kind="stable") first_time = group[RUL_TIME_COLUMN].iloc[0] last_time = group[RUL_TIME_COLUMN].iloc[-1] row = { RUL_DEVICE_COLUMN: device_id, "device_age_days": max( (reference - first_time).total_seconds() / 86400.0, 0.0 ), "history_span_days": max( (last_time - first_time).total_seconds() / 86400.0, 0.0 ), "days_since_last_observation": max( (reference - last_time).total_seconds() / 86400.0, 0.0 ), "observation_count": float(len(group)), } for value_column in RUL_VALUE_COLUMNS: values = group[value_column] row[f"{value_column}_latest"] = _rul_finite(values.iloc[-1]) row[f"{value_column}_mean"] = _rul_finite(values.mean()) row[f"{value_column}_std"] = _rul_finite(values.std(ddof=0)) row[f"{value_column}_min"] = _rul_finite(values.min()) row[f"{value_column}_max"] = _rul_finite(values.max()) row[f"{value_column}_slope"] = _rul_finite( _rul_slope(values, group[RUL_TIME_COLUMN]) ) for days in windows: window = group.loc[ group[RUL_TIME_COLUMN] >= reference - pd.Timedelta(days=int(days)) ] row[f"observation_count_{days}d"] = float(len(window)) for value_column in RUL_VALUE_COLUMNS: values = window[value_column] prefix = f"{value_column}_{days}d" row[f"{prefix}_mean"] = _rul_finite(values.mean()) row[f"{prefix}_std"] = _rul_finite(values.std(ddof=0)) row[f"{prefix}_min"] = _rul_finite(values.min()) row[f"{prefix}_max"] = _rul_finite(values.max()) row[f"{value_column}_slope_{days}d"] = _rul_finite( _rul_slope(values, window[RUL_TIME_COLUMN]) ) rows.append(row) features = pd.DataFrame.from_records(rows).set_index(RUL_DEVICE_COLUMN) return features.astype(float) def expected_costs_from_failure_distribution( failure_probability, replacement_days, early_penalty, late_penalty ): probabilities = np.asarray(failure_probability, dtype=float) probabilities = np.clip(probabilities, 0.0, None) total_probability = probabilities.sum() if total_probability <= 0: raise ValueError("Failure probabilities must contain positive mass") probabilities = probabilities / total_probability failure_days = np.arange(len(probabilities), dtype=float) replacement = np.asarray(replacement_days, dtype=float)[:, None] early_days = np.maximum(failure_days[None, :] - replacement, 0.0) late_days = np.maximum(replacement - failure_days[None, :], 0.0) costs = early_penalty * early_days + late_penalty * late_days return costs @ probabilities def expected_no_swap_cost( failure_probability, horizon_day, emergency_day, late_penalty ): probabilities = np.asarray(failure_probability, dtype=float) probabilities = np.clip(probabilities, 0.0, None) total_probability = probabilities.sum() if total_probability <= 0: raise ValueError("Failure probabilities must contain positive mass") probabilities = probabilities / total_probability failure_days = np.arange(len(probabilities), dtype=float) due_inside_window = failure_days <= int(horizon_day) late_days = np.maximum(float(emergency_day) - failure_days, 0.0) return float( np.sum(probabilities[due_inside_window] * late_days[due_inside_window]) * late_penalty ) class DiscreteHazardRULModel(RULModel): quantile_cols = ["p10", "p50", "p90"] period_days = 7 horizon_cap_days = 126 # ~18 weekly periods; covers the 42-day planning window plus emergency margin def __init__(self, random_state=0): self.random_state = int(random_state) self.model_ = None self.feature_columns_ = [] self.feature_medians_ = pd.Series(dtype=float) self.n_periods_ = self.horizon_cap_days // self.period_days self.fallback_hazard_ = 0.01 def _prepare_features(self, features, fitting=False): numeric = features.apply(pd.to_numeric, errors="coerce").replace( [np.inf, -np.inf], np.nan ) if fitting: self.feature_columns_ = list(numeric.columns) self.feature_medians_ = numeric.median().fillna(0.0) else: numeric = numeric.reindex(columns=self.feature_columns_) return numeric.fillna(self.feature_medians_).astype(float) def _expand_person_periods(self, features, durations, events): n_periods = self.n_periods_ row_index = [] row_periods = [] labels = [] for idx in features.index: duration = float(durations[idx]) event = bool(events[idx]) capped_duration = min(duration, float(self.horizon_cap_days)) failure_period = None if event and duration <= self.horizon_cap_days: failure_period = min( int(capped_duration // self.period_days), n_periods - 1 ) max_period = int(np.ceil(capped_duration / self.period_days)) if failure_period is not None: max_period = max(max_period, failure_period + 1) max_period = min(max_period, n_periods) for period in range(max_period): row_index.append(idx) row_periods.append(period) is_failure = failure_period is not None and period == failure_period labels.append(1 if is_failure else 0) if is_failure: break period_features = features.loc[row_index].copy() period_features["period"] = row_periods return period_features, np.array(labels, dtype=int) def fit_snapshots(self, snapshot_features, durations, events): common = snapshot_features.index.intersection(durations.index).intersection( events.index ) if common.empty: raise ValueError("No aligned snapshot labels were provided") features = self._prepare_features(snapshot_features.loc[common], fitting=True) duration = pd.to_numeric(durations.loc[common], errors="coerce").clip( lower=0.25 ) event = events.loc[common].fillna(False).astype(bool) period_features, labels = self._expand_person_periods(features, duration, event) self.fallback_hazard_ = ( float(np.clip(labels.mean(), 1e-3, 0.5)) if len(labels) else 0.01 ) self.model_ = None if labels.sum() >= 2 and len(labels) >= 10: from sklearn.ensemble import HistGradientBoostingClassifier model = HistGradientBoostingClassifier(random_state=self.random_state) model.fit(period_features.to_numpy(dtype=float), labels) self.model_ = model return self def fit(self, timeseries, rul): features = extract_snapshot_features(timeseries) labels = pd.to_numeric(rul, errors="coerce").reindex(features.index) events = pd.Series(True, index=features.index) return self.fit_snapshots(features, labels, events) def _period_hazards(self, features): prepared = self._prepare_features(features, fitting=False) n = len(prepared) n_periods = self.n_periods_ hazards = np.full((n, n_periods), self.fallback_hazard_, dtype=float) if self.model_ is not None: base = prepared.to_numpy(dtype=float) for period in range(n_periods): period_col = np.full((n, 1), float(period), dtype=float) x = np.hstack([base, period_col]) try: hazards[:, period] = self.model_.predict_proba(x)[:, 1] except (ArithmeticError, ValueError): pass return np.clip(hazards, 1e-4, 1.0 - 1e-4) def _survival(self, features, times): hazards = self._period_hazards(features) n = hazards.shape[0] period_survival = np.cumprod(1.0 - hazards, axis=1) period_survival = np.hstack( [np.ones((n, 1)), period_survival] ) # prepend day-0 survival = 1 times = np.asarray(times, dtype=float) result = np.ones((len(times), n), dtype=float) for i, t in enumerate(times): period_idx = min(int(t // self.period_days), self.n_periods_) result[i, :] = period_survival[:, period_idx] return result def predict(self, timeseries): features = extract_snapshot_features(timeseries) times = np.arange( 0, self.horizon_cap_days + self.period_days, self.period_days, dtype=float ) survival = self._survival(features, times) quantile_days = {} for q_col, target in zip(self.quantile_cols, (0.9, 0.5, 0.1)): days = [] for j in range(survival.shape[1]): below = np.where(survival[:, j] <= target)[0] days.append( float(times[below[0]]) if len(below) else float(self.horizon_cap_days) ) quantile_days[q_col] = days return pd.DataFrame(quantile_days, index=features.index)[self.quantile_cols] def failure_probabilities(self, timeseries, max_day): features = extract_snapshot_features(timeseries) times = np.arange(max_day + 2, dtype=float) survival = self._survival(features, times) interval_mass = np.maximum(survival[:-1] - survival[1:], 0.0) probabilities = np.vstack([interval_mass, survival[-1:]]).T row_sums = probabilities.sum(axis=1, keepdims=True) probabilities = np.divide( probabilities, row_sums, out=np.zeros_like(probabilities), where=row_sums > 0, ) return pd.DataFrame( probabilities, index=features.index, columns=range(max_day + 2) ) def expected_replacement_costs( self, timeseries, horizon_days, early_penalty, late_penalty, no_swap_extension_days, ): emergency_day = int(horizon_days + no_swap_extension_days) max_failure_day = emergency_day + max(int(horizon_days), 30) probabilities = self.failure_probabilities(timeseries, max_day=max_failure_day) replacement_days = np.arange(int(horizon_days) + 1) rows = [ np.append( expected_costs_from_failure_distribution( row, replacement_days, early_penalty=float(early_penalty), late_penalty=float(late_penalty), ), expected_no_swap_cost( row, horizon_day=int(horizon_days), emergency_day=emergency_day, late_penalty=float(late_penalty), ), ) for row in probabilities.to_numpy(dtype=float) ] columns = list(replacement_days) + ["no_swap"] return pd.DataFrame(rows, index=probabilities.index, columns=columns) def split_scenarios(scenarios, val_fraction=0.25, seed=0): """Deterministic shuffled train/val split across scenarios (not a prefix-limit) so evaluation isn't done on the same data used to fit.""" import random rng = random.Random(seed) shuffled = list(scenarios) rng.shuffle(shuffled) n_val = max(1, round(len(shuffled) * val_fraction)) return shuffled[n_val:], shuffled[:n_val] def build_training_snapshots( locations, timeseries, eol_times, scenarios, limit_scenarios=None ): feature_parts = [] duration_parts = [] event_parts = [] gen = iterate_scenarios(locations, timeseries, eol_times, scenarios) for scenario_number, (scenario, locs, cut, scenario_eol) in enumerate(gen): if limit_scenarios is not None and scenario_number >= limit_scenarios: break scenario_name = str(scenario["name"]) scenario_start = pd.Timestamp(scenario["start_time"]) features = extract_snapshot_features(cut, reference_time=scenario_start) batteries = features.index.astype(str) loc_by_battery = locs.set_index("battery") observed_eol = pd.to_datetime(scenario_eol.reindex(batteries)) censor_end = pd.to_datetime(loc_by_battery.loc[batteries, "end_time"]) event = observed_eol.notna() endpoint = observed_eol.where(event, censor_end) duration = ((endpoint - scenario_start) / pd.Timedelta(days=1)).astype(float) duration = duration.clip(lower=0.25) snapshot_index = pd.Index( [f"{battery}::{scenario_name}" for battery in batteries], name="snapshot_id" ) features = features.copy() features.index = snapshot_index duration.index = snapshot_index event.index = snapshot_index feature_parts.append(features) duration_parts.append(duration.rename("duration")) event_parts.append(event.astype(bool).rename("event")) if not feature_parts: raise ValueError("No training scenarios produced snapshot features") return ( pd.concat(feature_parts, axis=0), pd.concat(duration_parts, axis=0), pd.concat(event_parts, axis=0), ) def train_rul_model(locations, timeseries, eol_times, scenarios, limit_scenarios=None): features, durations, events = build_training_snapshots( locations, timeseries, eol_times, scenarios, limit_scenarios=limit_scenarios ) log.info( "training-snapshots", rows=len(features), features=len(features.columns), observed_events=int(events.sum()), censored=int((~events).sum()), ) return DiscreteHazardRULModel().fit_snapshots(features, durations, events) class Config(BaseSettings): """ Automatically provides command-line argument support for specified fields """ model_config = SettingsConfigDict( env_prefix="", cli_parse_args=True, cli_ignore_unknown_args=True, ) dataset_path: Optional[Path] = None split: str = "train" solver_time_limit_seconds: float = 20.0 late_risk_multiplier: float = 1.0 val_fraction: float = 0.25 split_seed: int = 0 def main(): cfg = Config() if cfg.dataset_path is None: dataset_path = os.environ.get("BATTERYSWAP_DATASET_PATH", None) assert dataset_path dataset_path = Path(dataset_path) else: dataset_path = cfg.dataset_path split_path = dataset_path / cfg.split locations, timeseries, eol_times, scenarios = load_dataset(split_path) log.info("evaluate-load-data", path=dataset_path) train_scenarios, val_scenarios = split_scenarios( scenarios, val_fraction=cfg.val_fraction, seed=cfg.split_seed ) log.info( "scenario-split", total=len(scenarios), train=len(train_scenarios), val=len(val_scenarios), ) rul_model = train_rul_model(locations, timeseries, eol_times, train_scenarios) log.info("train-done") log.info("evaluate-held-out") gen = iterate_scenarios(locations, timeseries, eol_times, val_scenarios) for scenario, locs, cut, eol in gen: scenario_name = scenario["name"] travel_costs = scenario["travel_costs"] settings = scenario["settings"] planner = MilpPlanner( rul_model, solver_time_limit_seconds=cfg.solver_time_limit_seconds, late_risk_multiplier=cfg.late_risk_multiplier, ) plan = planner.plan(cut, locs, travel_costs, settings) start_time = pandas.Timestamp(scenario["start_time"]) transitions, daily, overall = evaluate_plan( plan, locs, travel_costs, settings, eol_times=eol, start_time=start_time ) print("scores", scenario_name, overall) log.info("refit-on-full-data-for-submission") rul_model_full = train_rul_model(locations, timeseries, eol_times, scenarios) # Save best planner planner = MilpPlanner( rul_model_full, solver_time_limit_seconds=cfg.solver_time_limit_seconds, late_risk_multiplier=cfg.late_risk_multiplier, ) planner_path = 'batteryswap_example/planners/best.pickle' with open(planner_path, "wb") as f: pickle.dump(planner, f) print('planner-save', planner_path) if __name__ == '__main__': main()