Jawaril99 commited on
Commit
6c2e259
·
1 Parent(s): 446c98c

Add official BatterySwapAI example submission

Browse files
Dockerfile ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # Submission will be executed with this image
3
+ FROM huggingface/competitions:latest
4
+
5
+ # Default to running on train split only
6
+ ENV BATTERYSWAP_SPLITS=train
7
+ ENV BATTERYSWAP_SUBMISSION_PATH=submission.csv
8
+
9
+ WORKDIR /app
10
+
11
+ # NOTE: allowed requirements are specified by competition
12
+ # WARNING: Your customizations in requirements.txt will be ignored
13
+ COPY requirements.txt ./
14
+ RUN pip install --no-cache-dir -r requirements.txt
15
+
16
+ # Copy relevant files
17
+ # You can copy more in here, if needed
18
+ # (or just keep it under batteryswap_example/)
19
+ COPY batteryswap_example/ ./batteryswap_example
20
+ COPY script.py ./
21
+
22
+ # Default to making submissions
23
+ CMD python3 script.py
README_example.md ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
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
+ ```
27
+ python -m venv venv
28
+ source venv/bin/activate
29
+ ```
30
+
31
+ Install dependencies
32
+ ```
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
43
+ ```
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
+
60
+ This helps to ensure there are no errors when running in the submission environment.
61
+
62
+ NOTE: this requires around 20 GB+ of disk space.
63
+
64
+ Build Docker image
65
+ ```
66
+ docker build -t batteryswapai-2026-example .
67
+ ```
68
+
69
+ Make submissions and run evaluation
70
+ ```
71
+ docker run --name batteryswapai -v ./dataset:/tmp/data batteryswapai-2026-example bash -c "/app/env/bin/python3 script.py && /app/env/bin/python3 -m batteryswap_public.metric"
72
+ ```
73
+
74
+ Copy submission.csv out of container
75
+ ```
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 ADDED
Binary file (589 Bytes). View file
 
batteryswap_example/train.py ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.dummy import DummyRegressor
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
22
+ from batteryswap_public.evaluate import evaluate_plan, check_plan_valid
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_values(self.time_col)
84
+
85
+ all_stats = {}
86
+ # FIXME: actually compute some features
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
+ rows.append(self._compute_features(unit_df))
114
+ ids.append(unit_id)
115
+
116
+ X = np.vstack(rows)
117
+
118
+ # every quantile column just gets the single point prediction.
119
+ point_pred = self.model.predict(X)
120
+ preds = {col: point_pred for col in self.quantile_cols}
121
+
122
+ out = pd.DataFrame(preds, index=pd.Index(ids, name=self.group_col))
123
+ return out[self.quantile_cols]
124
+
125
+
126
+ def train_rul_model(locations, timeseries, eol_times, scenarios, limit_scenarios=None):
127
+
128
+ # Collect training data
129
+ # FIXME: train/validate/test split to estimate generalized predictive performance
130
+ gen = iterate_scenarios(locations, timeseries, eol_times, scenarios)
131
+ cut_dfs = []
132
+ cut_ruls = []
133
+ scenarios_loaded = 0
134
+ for scenario, locs, cut, eol in gen:
135
+ print('load-scenario')
136
+ cut = cut.reset_index()
137
+ cut['device_id'] = cut['device_id'].astype(str) + scenario['name']
138
+ cut = cut.set_index(['device_id', 'end_time'])
139
+ cut_dfs.append(cut)
140
+ plan_start = pandas.Timestamp(scenario['start_time'])
141
+ unobserved_rul = 120
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
+ rul_days = rul_days.fillna(unobserved_rul)
147
+ cut_ruls.append(rul_days)
148
+
149
+ assert set(rul_days.index) == set(cut.index.get_level_values('device_id'))
150
+
151
+ if limit_scenarios is not None:
152
+ if scenarios_loaded > limit_scenarios:
153
+ break
154
+ scenarios_loaded += 1
155
+
156
+
157
+ # Train a RUL prediction model
158
+ rul_model = DummyRULModel()
159
+ X = pandas.concat(cut_dfs)
160
+ Y = pandas.concat(cut_ruls)
161
+
162
+ print(X.head())
163
+ print(Y.head())
164
+
165
+ rul_model.fit(X, Y)
166
+
167
+ # FIXME: do model selection
168
+
169
+ return rul_model
170
+
171
+
172
+ class Config(BaseSettings):
173
+ """
174
+ Automatically provides command-line argument support for specified fields
175
+ """
176
+ model_config = SettingsConfigDict(
177
+ env_prefix="",
178
+ cli_parse_args=True,
179
+ cli_ignore_unknown_args=True,
180
+ )
181
+
182
+ dataset_path: Optional[Path] = None
183
+ split : str = 'train'
184
+
185
+ def main():
186
+ cfg = Config()
187
+
188
+ if cfg.dataset_path is None:
189
+ dataset_path = os.environ.get('BATTERYSWAP_DATASET_PATH', None)
190
+ assert dataset_path
191
+ dataset_path = Path(dataset_path)
192
+ else:
193
+ dataset_path = cfg.dataset_path
194
+
195
+ split_path = dataset_path / cfg.split
196
+ locations, timeseries, eol_times, scenarios = load_dataset(split_path)
197
+
198
+ log.info('evaluate-load-data', path=dataset_path)
199
+
200
+
201
+ # Prediction model training
202
+ #rul_model = DummyRULModel()
203
+ rul_model = train_rul_model(locations, timeseries, eol_times, scenarios, limit_scenarios=1)
204
+ log.info('train-done')
205
+
206
+ log.info('evaluate')
207
+ # Evaluate on the planning scenarios
208
+ gen = iterate_scenarios(locations, timeseries, eol_times, scenarios)
209
+ for scenario, locs, cut, eol in gen:
210
+ scenario_name = scenario['name']
211
+ travel_costs = scenario['travel_costs']
212
+ settings = scenario['settings']
213
+
214
+ planner = OrderedPlanner(rul_model)
215
+ plan = planner.plan(cut, locs, travel_costs, settings)
216
+
217
+ start_time = pandas.Timestamp(scenario['start_time'])
218
+
219
+ transitions, daily, overall = evaluate_plan(plan, locs, travel_costs, settings, eol_times=eol, start_time=start_time)
220
+
221
+ print('scores', scenario_name, overall)
222
+
223
+
224
+ # Save best planner
225
+ planner = OrderedPlanner(rul_model)
226
+
227
+ planner_path = 'batteryswap_example/planners/best.pickle'
228
+ with open(planner_path, "wb") as f:
229
+ pickle.dump(planner, f)
230
+ print('planner-save', planner_path)
231
+
232
+
233
+ if __name__ == '__main__':
234
+ main()
requirements.dev.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ # Add your custom dependencies here
requirements.txt ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # The following packages are provided in the competition
2
+ # NOTE: changing these will *not* affect the competition runtime environment
3
+ # To have packages for local development, use requirements.dev.txt
4
+ pandas>=2.3.0
5
+ plotly>=6.7.0
6
+ pydantic-settings>=2.14.1
7
+ structlog>=25.5.0
8
+ requests>=2.34.2
9
+ joblib>=1.5.3
10
+ fastparquet>=2026.5.0
11
+ pyarrow>=24.0.0
12
+ tqdm>=4.67.3
13
+ scikit-learn>=1.7.0
14
+ huggingface_hub>=1.25.1
15
+ batteryswap_public>=0.1.0
16
+ scipy>=1.15.0
17
+ numpy>=2.2.0
18
+ statsmodels>=0.14.0
19
+ polars>=1.43.0
20
+ ortools>=9.15.0
21
+ lifelines>=0.30.0
22
+ torch>=2.13.0
script.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ print('import start')
3
+
4
+ import os
5
+ import pickle
6
+ from pathlib import Path
7
+
8
+ # These utilities must be part of submission repo
9
+ from batteryswap_public.utils import make_submissions
10
+ from batteryswap_public.interfaces import Planner
11
+
12
+ # ensure classes that might be referenced in pickle is imported
13
+ from batteryswap_example.train import *
14
+
15
+
16
+ def pickle_loader(path : str):
17
+
18
+ def load() -> Planner:
19
+ with open(path, "rb") as f:
20
+ return pickle.load(f)
21
+ return load
22
+
23
+ def main():
24
+ print('main start')
25
+
26
+ # Load a trained model from repo
27
+ default_planner_path = 'batteryswap_example/planners/best.pickle'
28
+ planner_path = Path(os.environ.get('BATTERYSWAP_PLANNER_PATH', default_planner_path))
29
+ loader = pickle_loader(planner_path)
30
+
31
+ # NOTE: On HuggingFace the dataset will be in /tmp/data
32
+ dataset_path = Path(os.environ.get('BATTERYSWAP_DATASET_PATH', '/tmp/data'))
33
+
34
+ splits = os.environ.get('BATTERYSWAP_SPLITS', 'public,private').split(',')
35
+ make_submissions(loader, dataset_path=dataset_path, splits=splits)
36
+
37
+ # NOTE: On HuggingFace the output must be submission.csv
38
+ assert os.path.exists('submission.csv')
39
+
40
+ if __name__ == '__main__':
41
+ main()
42
+