Update for datetime plans
Browse files- .gitignore +2 -0
- README.md +31 -4
- batteryswap_example/planners/best.pickle +0 -0
- batteryswap_example/train.py +199 -12
.gitignore
CHANGED
|
@@ -1,3 +1,5 @@
|
|
| 1 |
submission.csv
|
| 2 |
data/
|
| 3 |
dataset/
|
|
|
|
|
|
|
|
|
| 1 |
submission.csv
|
| 2 |
data/
|
| 3 |
dataset/
|
| 4 |
+
|
| 5 |
+
venv/
|
README.md
CHANGED
|
@@ -2,9 +2,25 @@
|
|
| 2 |
license: mit
|
| 3 |
---
|
| 4 |
|
|
|
|
| 5 |
|
|
|
|
|
|
|
| 6 |
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
On Linux / Mac OS / Windows Subsystem for Linux (WSL)
|
| 10 |
```
|
|
@@ -17,7 +33,10 @@ Install dependencies
|
|
| 17 |
pip install -r requirements.txt -r requirements.dev.txt
|
| 18 |
```
|
| 19 |
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
Run the training
|
|
@@ -25,14 +44,16 @@ Run the training
|
|
| 25 |
python batteryswap_example/train.py
|
| 26 |
```
|
| 27 |
|
| 28 |
-
#
|
|
|
|
|
|
|
| 29 |
|
| 30 |
Trained models or other output used by submission processing (`script.py`),
|
| 31 |
must be committed in the git repository.
|
| 32 |
An example is `batteryswap_example/planners/best.pickle`.
|
| 33 |
|
| 34 |
|
| 35 |
-
# Test submissions in Docker - recommended
|
| 36 |
|
| 37 |
Using Docker allows to have exactly the same software versions as the submissions system.
|
| 38 |
|
|
@@ -55,4 +76,10 @@ Copy submission.csv out of container
|
|
| 55 |
docker cp batteryswapai:/app/submission.csv ./submission.csv
|
| 56 |
```
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
|
|
|
| 2 |
license: mit
|
| 3 |
---
|
| 4 |
|
| 5 |
+
# Example Model for BatterySwapAI 2026
|
| 6 |
|
| 7 |
+
This repository shows how to create a solution for the
|
| 8 |
+
[BatterySwapAI 2026 challenge](https://www.nora.ai/competitions/batteryswapai/batteryswapai2026.html).
|
| 9 |
|
| 10 |
+
It contains working example code that can be submitted as-is,
|
| 11 |
+
along with tools to check the solution before submitting.
|
| 12 |
+
|
| 13 |
+
# Setup
|
| 14 |
+
|
| 15 |
+
## Prerequisites
|
| 16 |
+
|
| 17 |
+
You need to have the following installed
|
| 18 |
+
|
| 19 |
+
- git
|
| 20 |
+
- Python 3.10+
|
| 21 |
+
- Docker
|
| 22 |
+
|
| 23 |
+
## Setting up virtual environment
|
| 24 |
|
| 25 |
On Linux / Mac OS / Windows Subsystem for Linux (WSL)
|
| 26 |
```
|
|
|
|
| 33 |
pip install -r requirements.txt -r requirements.dev.txt
|
| 34 |
```
|
| 35 |
|
| 36 |
+
|
| 37 |
+
# Developing
|
| 38 |
+
|
| 39 |
+
## Develop in virtual environment
|
| 40 |
|
| 41 |
|
| 42 |
Run the training
|
|
|
|
| 44 |
python batteryswap_example/train.py
|
| 45 |
```
|
| 46 |
|
| 47 |
+
# Making submissions
|
| 48 |
+
|
| 49 |
+
## Submitting trained models
|
| 50 |
|
| 51 |
Trained models or other output used by submission processing (`script.py`),
|
| 52 |
must be committed in the git repository.
|
| 53 |
An example is `batteryswap_example/planners/best.pickle`.
|
| 54 |
|
| 55 |
|
| 56 |
+
## Test submissions in Docker - recommended
|
| 57 |
|
| 58 |
Using Docker allows to have exactly the same software versions as the submissions system.
|
| 59 |
|
|
|
|
| 76 |
docker cp batteryswapai:/app/submission.csv ./submission.csv
|
| 77 |
```
|
| 78 |
|
| 79 |
+
## Create new submission
|
| 80 |
+
|
| 81 |
+
NOTE: remember to commit and push your changes to the HuggingFace model repository.
|
| 82 |
+
|
| 83 |
+
Use `New submission` in the competition application to submit your current code for evaluation.
|
| 84 |
+
|
| 85 |
|
batteryswap_example/planners/best.pickle
CHANGED
|
Binary files a/batteryswap_example/planners/best.pickle and b/batteryswap_example/planners/best.pickle differ
|
|
|
batteryswap_example/train.py
CHANGED
|
@@ -2,34 +2,221 @@
|
|
| 2 |
import pandas
|
| 3 |
import numpy
|
| 4 |
import pickle
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
def plan(self, battery_data, locations, travel_costs, settings):
|
| 11 |
|
| 12 |
-
#
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
plan = pandas.DataFrame({
|
| 21 |
'day': days,
|
| 22 |
'battery': order.index,
|
| 23 |
})
|
|
|
|
|
|
|
|
|
|
| 24 |
return plan
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
def main():
|
| 27 |
-
|
| 28 |
-
|
|
|
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
with open(planner_path, "wb") as f:
|
| 31 |
pickle.dump(planner, f)
|
| 32 |
print('planner-save', planner_path)
|
| 33 |
|
|
|
|
| 34 |
if __name__ == '__main__':
|
| 35 |
main()
|
|
|
|
| 2 |
import pandas
|
| 3 |
import numpy
|
| 4 |
import pickle
|
| 5 |
+
from typing import Optional, Sequence
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
import pathlib
|
| 8 |
|
| 9 |
+
|
| 10 |
+
from sklearn.dummy import DummyRegressor
|
| 11 |
+
import pandas as pd
|
| 12 |
+
import numpy
|
| 13 |
+
import numpy as np
|
| 14 |
+
import pandas
|
| 15 |
+
from pydantic import Field
|
| 16 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 17 |
+
import structlog
|
| 18 |
+
|
| 19 |
+
from batteryswap_public.interfaces import Planner, RULModel
|
| 20 |
+
from batteryswap_public.utils import load_dataset, iterate_scenarios
|
| 21 |
+
from batteryswap_public.evaluate import evaluate_plan, check_plan_valid
|
| 22 |
+
|
| 23 |
+
log = structlog.get_logger()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class OrderedPlanner(Planner):
|
| 27 |
+
def __init__(self, rul_estimator):
|
| 28 |
+
self.rul_estimator = rul_estimator
|
| 29 |
|
| 30 |
def plan(self, battery_data, locations, travel_costs, settings):
|
| 31 |
|
| 32 |
+
# Remaining Useful Life estimation
|
| 33 |
+
# FIXME: consider balance of over/under-estimation
|
| 34 |
+
percentile = 'p50'
|
| 35 |
+
rul = self.rul_estimator.predict(battery_data)
|
| 36 |
+
rul_days = rul[percentile]
|
| 37 |
+
# convert to a date
|
| 38 |
+
start_time = battery_data.reset_index()['end_time'].max()
|
| 39 |
+
predict_eol = start_time + pandas.to_timedelta(rul_days, unit='D')
|
| 40 |
|
| 41 |
+
loc = locations.copy().set_index('battery')
|
| 42 |
+
loc['eol_time'] = predict_eol
|
| 43 |
+
order = loc.sort_values('eol_time', ascending=True)
|
| 44 |
+
|
| 45 |
+
# Planner
|
| 46 |
+
# Stupid heuristic: Do one swap per day
|
| 47 |
+
# FIXME: take travel distances into account
|
| 48 |
+
# FIXME: take co-location into account
|
| 49 |
+
# FIXME: take daily and weekly limits into account
|
| 50 |
+
days = start_time + pandas.to_timedelta(numpy.arange(len(order)), unit='D')
|
| 51 |
plan = pandas.DataFrame({
|
| 52 |
'day': days,
|
| 53 |
'battery': order.index,
|
| 54 |
})
|
| 55 |
+
|
| 56 |
+
check_plan_valid(plan, locations, start_time=start_time)
|
| 57 |
+
|
| 58 |
return plan
|
| 59 |
|
| 60 |
+
|
| 61 |
+
class DummyRULModel(RULModel):
|
| 62 |
+
# RUL model that predicts (no information rate)
|
| 63 |
+
# FIXME: make a model that actually uses the data to improve predictions
|
| 64 |
+
|
| 65 |
+
def __init__(
|
| 66 |
+
self,
|
| 67 |
+
time_col: str = 'end_time',
|
| 68 |
+
group_col: str = 'device_id',
|
| 69 |
+
value_cols: Sequence[str] = ('voltage', 'temperature'),
|
| 70 |
+
quantiles: Sequence[float] = (0.5, ),
|
| 71 |
+
):
|
| 72 |
+
self.time_col = time_col
|
| 73 |
+
self.group_col = group_col
|
| 74 |
+
self.value_cols = list(value_cols)
|
| 75 |
+
self.quantiles = sorted(quantiles)
|
| 76 |
+
self.quantile_cols = [f"p{round(q * 100):02d}" for q in self.quantiles]
|
| 77 |
+
|
| 78 |
+
self.model = None
|
| 79 |
+
self.use_total_elapsed_days = True
|
| 80 |
+
|
| 81 |
+
def _compute_features(self, unit_df: pd.DataFrame) -> np.ndarray:
|
| 82 |
+
unit_df = unit_df.sort_values(self.time_col)
|
| 83 |
+
|
| 84 |
+
all_stats = {}
|
| 85 |
+
# FIXME: actually compute some features
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
feature_names = sorted(all_stats.keys())
|
| 89 |
+
self._feature_names_ = feature_names # stored for inspection/debugging
|
| 90 |
+
return np.array([all_stats[k] for k in feature_names], dtype=float)
|
| 91 |
+
|
| 92 |
+
def _build_feature_matrix(self, timeseries: pd.DataFrame) -> tuple[np.ndarray, list]:
|
| 93 |
+
rows, ids = [], []
|
| 94 |
+
for unit_id, unit_df in timeseries.groupby(self.group_col):
|
| 95 |
+
if len(unit_df) < 2:
|
| 96 |
+
continue
|
| 97 |
+
rows.append(self._compute_features(unit_df))
|
| 98 |
+
ids.append(unit_id)
|
| 99 |
+
return np.vstack(rows), ids
|
| 100 |
+
|
| 101 |
+
def fit(self, timeseries: pd.DataFrame, rul: pd.Series):
|
| 102 |
+
X, ids = self._build_feature_matrix(timeseries)
|
| 103 |
+
y = np.array([rul[unit_id] for unit_id in ids])
|
| 104 |
+
|
| 105 |
+
# FIXME: actually use an estimator that learns
|
| 106 |
+
self.model = DummyRegressor(strategy='median')
|
| 107 |
+
self.model.fit(X, y)
|
| 108 |
+
return self
|
| 109 |
+
|
| 110 |
+
def predict(self, timeseries: pd.DataFrame) -> pd.DataFrame:
|
| 111 |
+
rows, ids = [], []
|
| 112 |
+
for unit_id, unit_df in timeseries.groupby(self.group_col):
|
| 113 |
+
if len(unit_df) < 2:
|
| 114 |
+
raise ValueError(f"too short unit={unit_id} len={len(unit_df)}")
|
| 115 |
+
rows.append(self._compute_features(unit_df))
|
| 116 |
+
ids.append(unit_id)
|
| 117 |
+
|
| 118 |
+
X = np.vstack(rows)
|
| 119 |
+
|
| 120 |
+
# every quantile column just gets the single point prediction.
|
| 121 |
+
point_pred = self.model.predict(X)
|
| 122 |
+
preds = {col: point_pred for col in self.quantile_cols}
|
| 123 |
+
|
| 124 |
+
out = pd.DataFrame(preds, index=pd.Index(ids, name=self.group_col))
|
| 125 |
+
return out[self.quantile_cols]
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def train_rul_model(locations, timeseries, eol_times, scenarios):
|
| 129 |
+
|
| 130 |
+
# Collect training data
|
| 131 |
+
# FIXME: train/validate/test split to estimate generalized predictive performance
|
| 132 |
+
gen = iterate_scenarios(locations, timeseries, eol_times, scenarios)
|
| 133 |
+
cut_dfs = []
|
| 134 |
+
cut_ruls = []
|
| 135 |
+
for scenario, locs, cut, eol in gen:
|
| 136 |
+
|
| 137 |
+
cut = cut.reset_index()
|
| 138 |
+
cut['device_id'] = cut['device_id'].astype(str) + scenario['name']
|
| 139 |
+
cut = cut.set_index(['device_id', 'end_time'])
|
| 140 |
+
cut_dfs.append(cut)
|
| 141 |
+
plan_start = pandas.Timestamp(scenario['start_time'])
|
| 142 |
+
|
| 143 |
+
# Convert EOL datetimes to RUL in days relative to planning time
|
| 144 |
+
rul_days = (eol - plan_start) / pandas.Timedelta(days=1)
|
| 145 |
+
rul_days.index = pandas.Series(rul_days.index) + scenario['name']
|
| 146 |
+
cut_ruls.append(rul_days)
|
| 147 |
+
|
| 148 |
+
assert set(rul_days.index) == set(cut.index.get_level_values('device_id'))
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
# Train a RUL prediction model
|
| 153 |
+
rul_model = DummyRULModel()
|
| 154 |
+
X = pandas.concat(cut_dfs)
|
| 155 |
+
Y = pandas.concat(cut_ruls)
|
| 156 |
+
|
| 157 |
+
print(X.head())
|
| 158 |
+
print(Y.head())
|
| 159 |
+
|
| 160 |
+
rul_model.fit(X, Y)
|
| 161 |
+
|
| 162 |
+
# FIXME: do model selection
|
| 163 |
+
|
| 164 |
+
return rul_model
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
class Config(BaseSettings):
|
| 168 |
+
"""
|
| 169 |
+
Automatically provides command-line argument support for specified fields
|
| 170 |
+
"""
|
| 171 |
+
model_config = SettingsConfigDict(
|
| 172 |
+
env_prefix="",
|
| 173 |
+
cli_parse_args=True,
|
| 174 |
+
cli_ignore_unknown_args=True,
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
dataset_path: Path = Path("dataset")
|
| 178 |
+
split : str = 'train'
|
| 179 |
+
|
| 180 |
def main():
|
| 181 |
+
cfg = Config()
|
| 182 |
+
|
| 183 |
+
dataset_path = pathlib.Path(cfg.dataset_path)
|
| 184 |
|
| 185 |
+
split_path = dataset_path / cfg.split
|
| 186 |
+
locations, timeseries, eol_times, scenarios = load_dataset(split_path)
|
| 187 |
+
|
| 188 |
+
log.info('evaluate-load-data', path=dataset_path)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
# Prediction model training
|
| 192 |
+
rul_model = train_rul_model(locations, timeseries, eol_times, scenarios)
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
# Evaluate on the planning scenarios
|
| 196 |
+
gen = iterate_scenarios(locations, timeseries, eol_times, scenarios)
|
| 197 |
+
for scenario, locs, cut, eol in gen:
|
| 198 |
+
scenario_name = scenario['name']
|
| 199 |
+
travel_costs = scenario['travel_costs']
|
| 200 |
+
settings = scenario['settings']
|
| 201 |
+
|
| 202 |
+
planner = OrderedPlanner(rul_model)
|
| 203 |
+
plan = planner.plan(cut, locs, travel_costs, settings)
|
| 204 |
+
|
| 205 |
+
start_time = pandas.Timestamp(scenario['start_time'])
|
| 206 |
+
|
| 207 |
+
transitions, daily, overall = evaluate_plan(plan, locs, travel_costs, settings, eol_times=eol, start_time=start_time)
|
| 208 |
+
|
| 209 |
+
print('scores', scenario_name, overall)
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
# Save best planner
|
| 213 |
+
planner = OrderedPlanner(rul_model)
|
| 214 |
+
|
| 215 |
+
planner_path = 'batteryswap_example/planners/best.pickle'
|
| 216 |
with open(planner_path, "wb") as f:
|
| 217 |
pickle.dump(planner, f)
|
| 218 |
print('planner-save', planner_path)
|
| 219 |
|
| 220 |
+
|
| 221 |
if __name__ == '__main__':
|
| 222 |
main()
|