CarlAlbertCode commited on
Commit
1a5021f
·
1 Parent(s): 58f71d2

Submission 010: weekly capacity margin on the 008 stack

Browse files
script.py CHANGED
@@ -45,7 +45,7 @@ def main() -> None:
45
  artifact_path = Path(
46
  os.environ.get(
47
  "BATTERYSWAP_PLANNER_PATH",
48
- "submission_artifacts/final_planner.joblib",
49
  )
50
  )
51
  splits = [
 
45
  artifact_path = Path(
46
  os.environ.get(
47
  "BATTERYSWAP_PLANNER_PATH",
48
+ "submission_artifacts/weekly_planner.joblib",
49
  )
50
  )
51
  splits = [
src/batteryswapai/audit.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ import pandas as pd
5
+
6
+ from .schema import InferredSchema, leakage_columns
7
+
8
+
9
+ def _safe_quantiles(series: pd.Series) -> dict[str, float | None]:
10
+ values = pd.to_numeric(series, errors="coerce").dropna()
11
+ if values.empty:
12
+ return {"p01": None, "p10": None, "p50": None, "p90": None, "p99": None}
13
+ quantiles = values.quantile([0.01, 0.10, 0.50, 0.90, 0.99])
14
+ return {
15
+ "p01": float(quantiles.loc[0.01]),
16
+ "p10": float(quantiles.loc[0.10]),
17
+ "p50": float(quantiles.loc[0.50]),
18
+ "p90": float(quantiles.loc[0.90]),
19
+ "p99": float(quantiles.loc[0.99]),
20
+ }
21
+
22
+
23
+ def audit_longitudinal_table(df: pd.DataFrame, schema: InferredSchema) -> dict:
24
+ report: dict = {
25
+ "rows": int(len(df)),
26
+ "columns": int(len(df.columns)),
27
+ "possible_leakage_columns": leakage_columns(df, schema.target),
28
+ }
29
+
30
+ if schema.entity_id:
31
+ counts = df.groupby(schema.entity_id, dropna=False).size()
32
+ report["entities"] = {
33
+ "count": int(counts.size),
34
+ "rows_per_entity": _safe_quantiles(counts),
35
+ }
36
+
37
+ if schema.timestamp:
38
+ timestamps = pd.to_datetime(df[schema.timestamp], errors="coerce", utc=True)
39
+ valid = timestamps.dropna()
40
+ if not valid.empty:
41
+ report["time"] = {
42
+ "start": valid.min().isoformat(),
43
+ "end": valid.max().isoformat(),
44
+ "span_days": float((valid.max() - valid.min()).total_seconds() / 86400.0),
45
+ }
46
+
47
+ if schema.entity_id:
48
+ ordered = pd.DataFrame({"entity": df[schema.entity_id], "time": timestamps}).dropna()
49
+ ordered = ordered.sort_values(["entity", "time"])
50
+ cadence = ordered.groupby("entity", sort=False)["time"].diff().dt.total_seconds() / 3600.0
51
+ report.setdefault("time", {})["cadence_hours"] = _safe_quantiles(cadence)
52
+
53
+ if schema.target:
54
+ target = pd.to_numeric(df[schema.target], errors="coerce")
55
+ report["target"] = {
56
+ "non_null": int(target.notna().sum()),
57
+ "quantiles": _safe_quantiles(target),
58
+ "negative_count": int((target < 0).sum()),
59
+ "zero_count": int((target == 0).sum()),
60
+ }
61
+ if schema.entity_id and schema.timestamp:
62
+ temp = pd.DataFrame(
63
+ {
64
+ "entity": df[schema.entity_id],
65
+ "time": pd.to_datetime(df[schema.timestamp], errors="coerce", utc=True),
66
+ "target": target,
67
+ }
68
+ ).dropna().sort_values(["entity", "time"])
69
+ delta = temp.groupby("entity", sort=False)["target"].diff()
70
+ report["target"]["delta_quantiles"] = _safe_quantiles(delta)
71
+ report["target"]["fraction_increasing"] = float((delta > 1e-9).mean()) if delta.notna().any() else None
72
+
73
+ if schema.voltage:
74
+ voltage = pd.to_numeric(df[schema.voltage], errors="coerce")
75
+ report["voltage"] = {"quantiles": _safe_quantiles(voltage)}
76
+ if schema.entity_id and schema.timestamp:
77
+ temp = pd.DataFrame(
78
+ {
79
+ "entity": df[schema.entity_id],
80
+ "time": pd.to_datetime(df[schema.timestamp], errors="coerce", utc=True),
81
+ "voltage": voltage,
82
+ }
83
+ ).dropna().sort_values(["entity", "time"])
84
+ jumps = temp.groupby("entity", sort=False)["voltage"].diff()
85
+ report["voltage"]["jump_quantiles"] = _safe_quantiles(jumps)
86
+ report["voltage"]["jumps_over_0_25v"] = int((jumps >= 0.25).sum())
87
+ report["voltage"]["jumps_over_0_35v"] = int((jumps >= 0.35).sum())
88
+
89
+ if schema.site_id:
90
+ report["sites"] = int(df[schema.site_id].nunique(dropna=True))
91
+
92
+ return report
src/batteryswapai/cli.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from .audit import audit_longitudinal_table
8
+ from .config import load_config
9
+ from .io import download_dataset, load_named_table
10
+ from .pipeline import inspect_dataset, run_cv
11
+ from .schema import resolve_schema
12
+ from .synthetic import make_synthetic_dataset
13
+
14
+
15
+ def _parser() -> argparse.ArgumentParser:
16
+ parser = argparse.ArgumentParser(prog="bsa")
17
+ sub = parser.add_subparsers(dest="command", required=True)
18
+
19
+ download = sub.add_parser("download", help="Download the public Hugging Face dataset")
20
+ download.add_argument("--config", default="configs/default.yaml")
21
+
22
+ inspect = sub.add_parser("inspect", help="Inspect local tables and infer semantic columns")
23
+ inspect.add_argument("--config", default="configs/default.yaml")
24
+ inspect.add_argument("--table", default=None)
25
+
26
+ cv = sub.add_parser("cv", help="Run leakage-safe grouped cross validation")
27
+ cv.add_argument("--config", default="configs/default.yaml")
28
+ cv.add_argument("--table", default=None)
29
+
30
+ audit = sub.add_parser("audit", help="Audit longitudinal structure, cadence, target and voltage resets")
31
+ audit.add_argument("--config", default="configs/default.yaml")
32
+ audit.add_argument("--table", default=None)
33
+
34
+ synthetic = sub.add_parser("synthetic", help="Generate a local smoke-test dataset")
35
+ synthetic.add_argument("--output", default="data/raw/synthetic.csv")
36
+ synthetic.add_argument("--devices", type=int, default=24)
37
+ synthetic.add_argument("--days", type=int, default=180)
38
+ return parser
39
+
40
+
41
+ def main() -> None:
42
+ args = _parser().parse_args()
43
+ if args.command == "download":
44
+ config = load_config(args.config)
45
+ path = download_dataset(
46
+ config["dataset"]["repo_id"],
47
+ config["dataset"]["data_dir"],
48
+ config["dataset"].get("revision", "main"),
49
+ )
50
+ print(path)
51
+ return
52
+
53
+ if args.command == "inspect":
54
+ print(json.dumps(inspect_dataset(args.config, args.table), indent=2, default=str))
55
+ return
56
+
57
+ if args.command == "cv":
58
+ print(json.dumps(run_cv(args.config, args.table), indent=2))
59
+ return
60
+
61
+ if args.command == "audit":
62
+ config = load_config(args.config)
63
+ _, frame = load_named_table(config["dataset"]["data_dir"], args.table)
64
+ schema = resolve_schema(frame, config["schema"])
65
+ print(json.dumps(audit_longitudinal_table(frame, schema), indent=2, default=str))
66
+ return
67
+
68
+ if args.command == "synthetic":
69
+ output = Path(args.output)
70
+ output.parent.mkdir(parents=True, exist_ok=True)
71
+ make_synthetic_dataset(args.devices, args.days).to_csv(output, index=False)
72
+ print(output)
73
+ return
74
+
75
+
76
+ if __name__ == "__main__":
77
+ main()
src/batteryswapai/config.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import yaml
7
+
8
+
9
+ def load_config(path: str | Path) -> dict[str, Any]:
10
+ with Path(path).open("r", encoding="utf-8") as handle:
11
+ return yaml.safe_load(handle)
src/batteryswapai/features.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from dataclasses import dataclass
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+
9
+ from .schema import leakage_columns
10
+
11
+
12
+ SIGNAL_NAME_WEIGHTS = (
13
+ "battery",
14
+ "voltage",
15
+ "vbat",
16
+ "temperature",
17
+ "temp",
18
+ "rssi",
19
+ "signal",
20
+ "current",
21
+ "power",
22
+ "humidity",
23
+ )
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class FeatureResult:
28
+ frame: pd.DataFrame
29
+ feature_columns: list[str]
30
+
31
+
32
+ def _signal_score(name: str) -> int:
33
+ lowered = name.lower()
34
+ return sum(max(1, 20 - index) for index, token in enumerate(SIGNAL_NAME_WEIGHTS) if token in lowered)
35
+
36
+
37
+ def choose_numeric_signals(
38
+ df: pd.DataFrame,
39
+ exclude: set[str],
40
+ max_signals: int,
41
+ ) -> list[str]:
42
+ candidates = []
43
+ for column in df.columns:
44
+ if column in exclude or not pd.api.types.is_numeric_dtype(df[column]):
45
+ continue
46
+ variance = float(pd.to_numeric(df[column], errors="coerce").var(skipna=True) or 0.0)
47
+ if not np.isfinite(variance) or variance <= 0:
48
+ continue
49
+ candidates.append((column, _signal_score(column), variance))
50
+ candidates.sort(key=lambda item: (item[1], math.log1p(item[2])), reverse=True)
51
+ return [column for column, _, _ in candidates[:max_signals]]
52
+
53
+
54
+ def daily_panel(
55
+ df: pd.DataFrame,
56
+ entity_col: str,
57
+ timestamp_col: str,
58
+ numeric_signals: list[str],
59
+ passthrough_cols: list[str] | None = None,
60
+ ) -> pd.DataFrame:
61
+ passthrough_cols = passthrough_cols or []
62
+ frame = df.copy()
63
+ frame[timestamp_col] = pd.to_datetime(frame[timestamp_col], errors="coerce", utc=True)
64
+ frame = frame.dropna(subset=[entity_col, timestamp_col]).sort_values([entity_col, timestamp_col])
65
+ frame["_day"] = frame[timestamp_col].dt.floor("D")
66
+
67
+ aggregation: dict[str, str] = {column: "mean" for column in numeric_signals}
68
+ for column in passthrough_cols:
69
+ if column in frame.columns and column not in aggregation:
70
+ aggregation[column] = "last"
71
+ panel = frame.groupby([entity_col, "_day"], as_index=False, sort=False).agg(aggregation)
72
+ counts = frame.groupby([entity_col, "_day"], sort=False).size().rename("observations_day").reset_index()
73
+ panel = panel.merge(counts, on=[entity_col, "_day"], how="left")
74
+ return panel.rename(columns={"_day": timestamp_col})
75
+
76
+
77
+ def _add_calendar_lags(
78
+ panel: pd.DataFrame,
79
+ entity_col: str,
80
+ timestamp_col: str,
81
+ signals: list[str],
82
+ lags_days: tuple[int, ...],
83
+ ) -> tuple[pd.DataFrame, list[str]]:
84
+ result = panel.copy()
85
+ feature_columns: list[str] = []
86
+ right = result[[entity_col, timestamp_col, *signals]].copy()
87
+ right = right.rename(columns={timestamp_col: "_source_time"})
88
+ right = right.sort_values(["_source_time", entity_col])
89
+
90
+ for lag in lags_days:
91
+ left = result[[entity_col, timestamp_col]].copy()
92
+ left["_row_id"] = np.arange(len(left))
93
+ left["_lookup_time"] = left[timestamp_col] - pd.Timedelta(days=lag)
94
+ left = left.sort_values(["_lookup_time", entity_col])
95
+ tolerance_days = max(1, min(3, lag // 7 if lag >= 7 else 1))
96
+
97
+ matched = pd.merge_asof(
98
+ left,
99
+ right,
100
+ left_on="_lookup_time",
101
+ right_on="_source_time",
102
+ by=entity_col,
103
+ direction="backward",
104
+ tolerance=pd.Timedelta(days=tolerance_days),
105
+ ).sort_values("_row_id")
106
+
107
+ actual_age_col = f"lag_age_{lag}d"
108
+ source_time = pd.to_datetime(matched["_source_time"], errors="coerce", utc=True).reset_index(drop=True)
109
+ current_time = result[timestamp_col].reset_index(drop=True)
110
+ result[actual_age_col] = (current_time - source_time).dt.total_seconds() / 86400.0
111
+ feature_columns.append(actual_age_col)
112
+
113
+ for signal in signals:
114
+ lag_col = f"{signal}__lag_{lag}d"
115
+ delta_col = f"{signal}__delta_{lag}d"
116
+ rate_col = f"{signal}__rate_{lag}d"
117
+ result[lag_col] = matched[signal].to_numpy()
118
+ result[delta_col] = result[signal] - result[lag_col]
119
+ denominator = result[actual_age_col].replace(0.0, np.nan)
120
+ result[rate_col] = result[delta_col] / denominator
121
+ feature_columns.extend([lag_col, delta_col, rate_col])
122
+
123
+ return result, feature_columns
124
+
125
+
126
+ def _add_time_rolling_features(
127
+ panel: pd.DataFrame,
128
+ entity_col: str,
129
+ timestamp_col: str,
130
+ signals: list[str],
131
+ windows_days: tuple[int, ...],
132
+ ) -> tuple[pd.DataFrame, list[str]]:
133
+ result = panel.copy()
134
+ feature_columns: list[str] = []
135
+ index = pd.MultiIndex.from_arrays(
136
+ [result[entity_col], result[timestamp_col]],
137
+ names=[entity_col, timestamp_col],
138
+ )
139
+
140
+ for window in windows_days:
141
+ min_periods = max(2, window // 3)
142
+ stats = (
143
+ result.groupby(entity_col, sort=False)
144
+ .rolling(f"{window}D", on=timestamp_col, min_periods=min_periods)[signals]
145
+ .agg(["mean", "std", "min", "max"])
146
+ )
147
+ for signal in signals:
148
+ for stat in ("mean", "std", "min", "max"):
149
+ column = f"{signal}__{stat}_{window}d"
150
+ result[column] = stats[(signal, stat)].reindex(index).to_numpy()
151
+ feature_columns.append(column)
152
+
153
+ return result, feature_columns
154
+
155
+
156
+ def build_features(
157
+ df: pd.DataFrame,
158
+ entity_col: str,
159
+ timestamp_col: str,
160
+ target_col: str | None = None,
161
+ site_col: str | None = None,
162
+ max_signals: int = 12,
163
+ lags_days: tuple[int, ...] = (1, 3, 7, 14, 30, 60, 90),
164
+ windows_days: tuple[int, ...] = (3, 7, 14, 30, 60, 90),
165
+ min_history_days: int = 7,
166
+ include_entity_id: bool = False,
167
+ include_site_id: bool = True,
168
+ ) -> FeatureResult:
169
+ blocked = set(leakage_columns(df, target_col)) | {entity_col, timestamp_col}
170
+ if site_col:
171
+ blocked.add(site_col)
172
+ signals = choose_numeric_signals(df, blocked, max_signals=max_signals)
173
+ if not signals:
174
+ raise ValueError("No usable numeric sensor signals were found")
175
+
176
+ passthrough = [column for column in (target_col, site_col) if column]
177
+ panel = daily_panel(df, entity_col, timestamp_col, signals, passthrough)
178
+ panel = panel.sort_values([entity_col, timestamp_col]).reset_index(drop=True)
179
+
180
+ grouped = panel.groupby(entity_col, sort=False)
181
+ first_seen = grouped[timestamp_col].transform("min")
182
+ panel["age_days"] = (panel[timestamp_col] - first_seen).dt.total_seconds() / 86400.0
183
+ panel["days_since_prev_observation"] = (
184
+ grouped[timestamp_col].diff().dt.total_seconds() / 86400.0
185
+ )
186
+ panel["dow"] = panel[timestamp_col].dt.dayofweek.astype("int8")
187
+ panel["month_sin"] = np.sin(2 * np.pi * panel[timestamp_col].dt.month / 12.0)
188
+ panel["month_cos"] = np.cos(2 * np.pi * panel[timestamp_col].dt.month / 12.0)
189
+
190
+ feature_columns = [
191
+ "age_days",
192
+ "days_since_prev_observation",
193
+ "dow",
194
+ "month_sin",
195
+ "month_cos",
196
+ "observations_day",
197
+ *signals,
198
+ ]
199
+
200
+ panel, lag_features = _add_calendar_lags(
201
+ panel,
202
+ entity_col=entity_col,
203
+ timestamp_col=timestamp_col,
204
+ signals=signals,
205
+ lags_days=lags_days,
206
+ )
207
+ feature_columns.extend(lag_features)
208
+
209
+ panel, rolling_features = _add_time_rolling_features(
210
+ panel,
211
+ entity_col=entity_col,
212
+ timestamp_col=timestamp_col,
213
+ signals=signals,
214
+ windows_days=windows_days,
215
+ )
216
+ feature_columns.extend(rolling_features)
217
+
218
+ if include_entity_id:
219
+ feature_columns.append(entity_col)
220
+ if include_site_id and site_col and site_col in panel.columns:
221
+ feature_columns.append(site_col)
222
+
223
+ panel = panel[panel["age_days"] >= min_history_days].copy()
224
+ feature_columns = [column for column in feature_columns if column in panel.columns]
225
+ return FeatureResult(frame=panel, feature_columns=feature_columns)
src/batteryswapai/hierarchical_fpt.py CHANGED
@@ -30,10 +30,7 @@ EOL_VOLTAGE = 2.40
30
  TERMINAL_WEEKS = 12
31
  MIN_WEEKLY_INCREMENTS = 4
32
  MAX_STATE_STALENESS_DAYS = 7.0
33
- RANK_RESIDUAL_WEIGHT = 0.25
34
- # Codex froze this at 0.15 with no_sweep=true, so the one dial on the mechanism
35
- # that actually works had never been tested. Over 48 dates x 27 bases, 0.25 is
36
- # -35.16 against 0.15, both calendar halves negative and all 27 bases improving.
37
 
38
 
39
  @dataclass(frozen=True)
 
30
  TERMINAL_WEEKS = 12
31
  MIN_WEEKLY_INCREMENTS = 4
32
  MAX_STATE_STALENESS_DAYS = 7.0
33
+ RANK_RESIDUAL_WEIGHT = 0.15
 
 
 
34
 
35
 
36
  @dataclass(frozen=True)
src/batteryswapai/io.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import pandas as pd
6
+ from huggingface_hub import snapshot_download
7
+
8
+ SUPPORTED_SUFFIXES = {".csv", ".parquet", ".json", ".jsonl", ".feather"}
9
+
10
+
11
+ def download_dataset(repo_id: str, data_dir: str | Path, revision: str = "main") -> Path:
12
+ destination = Path(data_dir)
13
+ destination.mkdir(parents=True, exist_ok=True)
14
+ snapshot_download(
15
+ repo_id=repo_id,
16
+ repo_type="dataset",
17
+ revision=revision,
18
+ local_dir=destination,
19
+ )
20
+ return destination
21
+
22
+
23
+ def discover_tables(data_dir: str | Path) -> list[Path]:
24
+ root = Path(data_dir)
25
+ if not root.exists():
26
+ return []
27
+ return sorted(path for path in root.rglob("*") if path.is_file() and path.suffix.lower() in SUPPORTED_SUFFIXES)
28
+
29
+
30
+ def read_table(path: str | Path) -> pd.DataFrame:
31
+ path = Path(path)
32
+ suffix = path.suffix.lower()
33
+ if suffix == ".csv":
34
+ return pd.read_csv(path)
35
+ if suffix == ".parquet":
36
+ return pd.read_parquet(path)
37
+ if suffix == ".json":
38
+ try:
39
+ return pd.read_json(path)
40
+ except ValueError:
41
+ return pd.read_json(path, lines=True)
42
+ if suffix == ".jsonl":
43
+ return pd.read_json(path, lines=True)
44
+ if suffix == ".feather":
45
+ return pd.read_feather(path)
46
+ raise ValueError(f"Unsupported data file: {path}")
47
+
48
+
49
+ def load_named_table(data_dir: str | Path, name_hint: str | None = None) -> tuple[Path, pd.DataFrame]:
50
+ tables = discover_tables(data_dir)
51
+ if not tables:
52
+ raise FileNotFoundError(f"No supported tables found under {data_dir}")
53
+
54
+ if name_hint:
55
+ matches = [path for path in tables if name_hint.lower() in path.name.lower()]
56
+ if len(matches) == 1:
57
+ return matches[0], read_table(matches[0])
58
+ if len(matches) > 1:
59
+ matches.sort(key=lambda path: path.stat().st_size, reverse=True)
60
+ return matches[0], read_table(matches[0])
61
+
62
+ tables.sort(key=lambda path: path.stat().st_size, reverse=True)
63
+ return tables[0], read_table(tables[0])
src/batteryswapai/lifecycles.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ import pandas as pd
5
+
6
+
7
+ def assign_lifecycles(
8
+ df: pd.DataFrame,
9
+ entity_col: str,
10
+ timestamp_col: str,
11
+ voltage_col: str,
12
+ reset_jump_volts: float = 0.35,
13
+ min_points: int = 14,
14
+ min_duration_days: int = 7,
15
+ ) -> pd.DataFrame:
16
+ frame = df.copy()
17
+ frame[timestamp_col] = pd.to_datetime(frame[timestamp_col], errors="coerce", utc=True)
18
+ frame = frame.dropna(subset=[entity_col, timestamp_col]).sort_values([entity_col, timestamp_col])
19
+
20
+ voltage = pd.to_numeric(frame[voltage_col], errors="coerce")
21
+ jump = voltage.groupby(frame[entity_col], sort=False).diff()
22
+ replacement = jump >= reset_jump_volts
23
+ episode = replacement.groupby(frame[entity_col], sort=False).cumsum().astype("int64")
24
+ frame["lifecycle_id"] = frame[entity_col].astype(str) + "::" + episode.astype(str)
25
+
26
+ stats = frame.groupby("lifecycle_id", sort=False).agg(
27
+ lifecycle_points=(timestamp_col, "size"),
28
+ lifecycle_start=(timestamp_col, "min"),
29
+ lifecycle_end=(timestamp_col, "max"),
30
+ )
31
+ stats["lifecycle_duration_days"] = (
32
+ (stats["lifecycle_end"] - stats["lifecycle_start"]).dt.total_seconds() / 86400.0
33
+ )
34
+ valid_ids = stats.index[
35
+ (stats["lifecycle_points"] >= min_points)
36
+ & (stats["lifecycle_duration_days"] >= min_duration_days)
37
+ ]
38
+ return frame[frame["lifecycle_id"].isin(valid_ids)].copy()
39
+
40
+
41
+ def add_rul_labels(
42
+ df: pd.DataFrame,
43
+ entity_col: str,
44
+ timestamp_col: str,
45
+ lifecycle_col: str = "lifecycle_id",
46
+ censor_last_episode: bool = True,
47
+ ) -> pd.DataFrame:
48
+ frame = df.copy()
49
+ frame[timestamp_col] = pd.to_datetime(frame[timestamp_col], errors="coerce", utc=True)
50
+ ends = frame.groupby(lifecycle_col, sort=False)[timestamp_col].transform("max")
51
+ frame["rul_days"] = (ends - frame[timestamp_col]).dt.total_seconds() / 86400.0
52
+ frame["event_observed"] = 1
53
+
54
+ if censor_last_episode:
55
+ last_lifecycle = (
56
+ frame.groupby(entity_col, sort=False)[lifecycle_col]
57
+ .last()
58
+ .astype(str)
59
+ .to_dict()
60
+ )
61
+ is_last = frame.apply(lambda row: str(row[lifecycle_col]) == last_lifecycle.get(row[entity_col]), axis=1)
62
+ frame.loc[is_last, "event_observed"] = 0
63
+ frame.loc[is_last, "rul_days"] = np.nan
64
+
65
+ return frame
src/batteryswapai/metrics.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ from sklearn.metrics import mean_absolute_error, mean_squared_error
5
+
6
+
7
+ def regression_metrics(y_true, y_pred) -> dict[str, float]:
8
+ y_true = np.asarray(y_true, dtype=float)
9
+ y_pred = np.asarray(y_pred, dtype=float)
10
+ return {
11
+ "mae": float(mean_absolute_error(y_true, y_pred)),
12
+ "rmse": float(np.sqrt(mean_squared_error(y_true, y_pred))),
13
+ "bias": float(np.mean(y_pred - y_true)),
14
+ "p90_abs_error": float(np.quantile(np.abs(y_pred - y_true), 0.90)),
15
+ }
src/batteryswapai/models.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+ from sklearn.ensemble import ExtraTreesRegressor, HistGradientBoostingRegressor
9
+
10
+ from .metrics import regression_metrics
11
+ from .validation import group_folds
12
+
13
+
14
+ @dataclass
15
+ class TrainedEnsemble:
16
+ histogram: HistGradientBoostingRegressor
17
+ extra_trees: ExtraTreesRegressor
18
+ feature_columns: list[str]
19
+ categorical_columns: list[str]
20
+ encoded_columns: list[str]
21
+ fill_values: pd.Series
22
+ weights: tuple[float, float] = (0.5, 0.5)
23
+
24
+ def predict(self, frame: pd.DataFrame) -> np.ndarray:
25
+ features, _, _ = encode_features(
26
+ frame[self.feature_columns],
27
+ self.categorical_columns,
28
+ encoded_columns=self.encoded_columns,
29
+ fill_values=self.fill_values,
30
+ )
31
+ histogram_prediction = self.histogram.predict(features)
32
+ tree_prediction = self.extra_trees.predict(features)
33
+ prediction = self.weights[0] * histogram_prediction + self.weights[1] * tree_prediction
34
+ return np.clip(prediction, 0.0, None)
35
+
36
+
37
+ def _categorical_columns(frame: pd.DataFrame, feature_columns: list[str]) -> list[str]:
38
+ categorical = []
39
+ for column in feature_columns:
40
+ dtype = frame[column].dtype
41
+ if (
42
+ pd.api.types.is_object_dtype(dtype)
43
+ or pd.api.types.is_string_dtype(dtype)
44
+ or isinstance(dtype, pd.CategoricalDtype)
45
+ ):
46
+ categorical.append(column)
47
+ return categorical
48
+
49
+
50
+ def encode_features(
51
+ frame: pd.DataFrame,
52
+ categorical_columns: list[str],
53
+ *,
54
+ encoded_columns: list[str] | None = None,
55
+ fill_values: pd.Series | None = None,
56
+ ) -> tuple[pd.DataFrame, list[str], pd.Series]:
57
+ result = frame.copy()
58
+ for column in categorical_columns:
59
+ if column in result:
60
+ result[column] = result[column].astype("string").fillna("__missing__")
61
+ result = pd.get_dummies(result, columns=categorical_columns, dummy_na=False, dtype=float)
62
+ result = result.apply(pd.to_numeric, errors="coerce").replace([np.inf, -np.inf], np.nan)
63
+ if encoded_columns is None:
64
+ encoded_columns = result.columns.tolist()
65
+ result = result.reindex(columns=encoded_columns)
66
+ if fill_values is None:
67
+ fill_values = result.median(numeric_only=True).fillna(0.0)
68
+ result = result.fillna(fill_values).fillna(0.0).astype(float)
69
+ return result, encoded_columns, fill_values
70
+
71
+
72
+ def _estimators(model_config: dict[str, Any], seed_offset: int = 0):
73
+ histogram_config = {
74
+ "loss": "absolute_error",
75
+ "learning_rate": 0.055,
76
+ "max_iter": 320,
77
+ "max_leaf_nodes": 31,
78
+ "min_samples_leaf": 25,
79
+ "l2_regularization": 2.0,
80
+ "random_state": 2026 + seed_offset,
81
+ **model_config.get("hist_gradient_boosting", {}),
82
+ }
83
+ extra_config = {
84
+ "n_estimators": 280,
85
+ "min_samples_leaf": 3,
86
+ "max_features": 0.8,
87
+ "n_jobs": -1,
88
+ "random_state": 2026 + seed_offset,
89
+ **model_config.get("extra_trees", {}),
90
+ }
91
+ return HistGradientBoostingRegressor(**histogram_config), ExtraTreesRegressor(**extra_config)
92
+
93
+
94
+ def cross_validate(
95
+ frame: pd.DataFrame,
96
+ feature_columns: list[str],
97
+ target_col: str,
98
+ group_col: str,
99
+ model_config: dict[str, Any],
100
+ folds: int = 5,
101
+ ) -> tuple[pd.DataFrame, dict[str, float]]:
102
+ work = frame.dropna(subset=[target_col]).reset_index(drop=True).copy()
103
+ categorical = _categorical_columns(work, feature_columns)
104
+ target = pd.to_numeric(work[target_col], errors="coerce").astype(float)
105
+
106
+ oof = np.full(len(work), np.nan, dtype=float)
107
+ for fold_number, fold in enumerate(group_folds(work, group_col=group_col, folds=folds)):
108
+ histogram, extra_trees = _estimators(model_config, seed_offset=fold_number)
109
+ train_frame = work.iloc[fold.train_idx][feature_columns]
110
+ valid_frame = work.iloc[fold.valid_idx][feature_columns]
111
+ x_train, encoded_columns, fill_values = encode_features(train_frame, categorical)
112
+ x_valid, _, _ = encode_features(
113
+ valid_frame,
114
+ categorical,
115
+ encoded_columns=encoded_columns,
116
+ fill_values=fill_values,
117
+ )
118
+ y_train = target.iloc[fold.train_idx]
119
+ histogram.fit(x_train, y_train)
120
+ extra_trees.fit(x_train, y_train)
121
+ prediction = 0.5 * histogram.predict(x_valid) + 0.5 * extra_trees.predict(x_valid)
122
+ oof[fold.valid_idx] = np.clip(prediction, 0.0, None)
123
+
124
+ metrics = regression_metrics(target, oof)
125
+ output = work[[target_col, group_col]].copy()
126
+ output["prediction"] = oof
127
+ output["abs_error"] = np.abs(output["prediction"] - output[target_col])
128
+ return output, metrics
129
+
130
+
131
+ def train_ensemble(
132
+ frame: pd.DataFrame,
133
+ feature_columns: list[str],
134
+ target_col: str,
135
+ model_config: dict[str, Any],
136
+ ) -> TrainedEnsemble:
137
+ work = frame.dropna(subset=[target_col]).copy()
138
+ categorical = _categorical_columns(work, feature_columns)
139
+ features, encoded_columns, fill_values = encode_features(work[feature_columns], categorical)
140
+ target = pd.to_numeric(work[target_col], errors="coerce").astype(float)
141
+ histogram, extra_trees = _estimators(model_config)
142
+ histogram.fit(features, target)
143
+ extra_trees.fit(features, target)
144
+ return TrainedEnsemble(
145
+ histogram,
146
+ extra_trees,
147
+ feature_columns,
148
+ categorical,
149
+ encoded_columns,
150
+ fill_values,
151
+ )
src/batteryswapai/pipeline.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import pandas as pd
7
+
8
+ from .config import load_config
9
+ from .features import build_features
10
+ from .io import load_named_table
11
+ from .models import cross_validate, train_ensemble
12
+ from .schema import describe_table, leakage_columns, resolve_schema
13
+
14
+
15
+ def inspect_dataset(config_path: str | Path, table_hint: str | None = None) -> dict:
16
+ config = load_config(config_path)
17
+ path, frame = load_named_table(config["dataset"]["data_dir"], table_hint)
18
+ schema = resolve_schema(frame, config["schema"])
19
+ report = {
20
+ "file": str(path),
21
+ "table": describe_table(frame),
22
+ "schema": schema.as_dict(),
23
+ "possible_leakage_columns": leakage_columns(frame, schema.target),
24
+ }
25
+ return report
26
+
27
+
28
+ def prepare_training_frame(config_path: str | Path, table_hint: str | None = None):
29
+ config = load_config(config_path)
30
+ path, frame = load_named_table(config["dataset"]["data_dir"], table_hint)
31
+ schema = resolve_schema(frame, config["schema"])
32
+ if not schema.timestamp or not schema.entity_id:
33
+ raise ValueError("Could not infer timestamp and entity ID. Set them in configs/default.yaml")
34
+ if not schema.target:
35
+ raise ValueError("Could not infer an RUL target. Set schema.target or construct lifecycle labels first")
36
+
37
+ feature_cfg = config["features"]
38
+ result = build_features(
39
+ frame,
40
+ entity_col=schema.entity_id,
41
+ timestamp_col=schema.timestamp,
42
+ target_col=schema.target,
43
+ site_col=schema.site_id,
44
+ max_signals=int(feature_cfg["max_signals"]),
45
+ lags_days=tuple(feature_cfg["lags_days"]),
46
+ windows_days=tuple(feature_cfg["windows_days"]),
47
+ min_history_days=int(feature_cfg["min_history_days"]),
48
+ include_entity_id=bool(feature_cfg["include_entity_id"]),
49
+ include_site_id=bool(feature_cfg["include_site_id"]),
50
+ )
51
+ return config, path, schema, result
52
+
53
+
54
+ def run_cv(config_path: str | Path, table_hint: str | None = None) -> dict:
55
+ config, _, schema, result = prepare_training_frame(config_path, table_hint)
56
+ oof, metrics = cross_validate(
57
+ result.frame,
58
+ result.feature_columns,
59
+ target_col=schema.target,
60
+ group_col=schema.entity_id,
61
+ model_config=config["model"],
62
+ folds=int(config["validation"]["folds"]),
63
+ )
64
+ artifacts = Path("artifacts")
65
+ artifacts.mkdir(exist_ok=True)
66
+ oof.to_csv(artifacts / "oof_predictions.csv", index=False)
67
+ (artifacts / "cv_metrics.json").write_text(json.dumps(metrics, indent=2), encoding="utf-8")
68
+ return metrics
69
+
70
+
71
+ def fit_final(config_path: str | Path, table_hint: str | None = None):
72
+ config, _, schema, result = prepare_training_frame(config_path, table_hint)
73
+ model = train_ensemble(result.frame, result.feature_columns, schema.target, config["model"])
74
+ return model, schema, result
src/batteryswapai/scheduling.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class ScheduleConfig:
11
+ failure_cost: float = 100.0
12
+ early_replacement_cost_per_day: float = 0.08
13
+ visit_cost: float = 15.0
14
+ max_replacements_per_day: int = 25
15
+ horizon_days: int = 120
16
+
17
+
18
+ def expected_replacement_cost(
19
+ replacement_day: int,
20
+ failure_samples: np.ndarray,
21
+ config: ScheduleConfig,
22
+ ) -> float:
23
+ failures_before_visit = failure_samples < replacement_day
24
+ failure_cost = config.failure_cost * failures_before_visit.mean()
25
+ unused_life = np.maximum(failure_samples - replacement_day, 0.0)
26
+ early_cost = config.early_replacement_cost_per_day * unused_life.mean()
27
+ return float(failure_cost + early_cost)
28
+
29
+
30
+ def choose_replacement_days(
31
+ predictions: pd.DataFrame,
32
+ id_col: str,
33
+ median_col: str = "rul_days",
34
+ uncertainty_fraction: float = 0.25,
35
+ config: ScheduleConfig | None = None,
36
+ seed: int = 2026,
37
+ ) -> pd.DataFrame:
38
+ config = config or ScheduleConfig()
39
+ rng = np.random.default_rng(seed)
40
+ rows = []
41
+
42
+ for _, row in predictions.iterrows():
43
+ median = max(float(row[median_col]), 0.1)
44
+ sigma = max(median * uncertainty_fraction, 1.0)
45
+ samples = np.clip(rng.normal(median, sigma, size=2000), 0.0, None)
46
+ costs = [expected_replacement_cost(day, samples, config) for day in range(config.horizon_days + 1)]
47
+ best_day = int(np.argmin(costs))
48
+ rows.append({id_col: row[id_col], "replacement_day": best_day, "expected_risk_cost": costs[best_day]})
49
+
50
+ schedule = pd.DataFrame(rows).sort_values(["replacement_day", "expected_risk_cost"], ascending=[True, False])
51
+ scheduled = []
52
+ day_load: dict[int, int] = {}
53
+ for _, row in schedule.iterrows():
54
+ day = int(row["replacement_day"])
55
+ while day_load.get(day, 0) >= config.max_replacements_per_day and day < config.horizon_days:
56
+ day += 1
57
+ day_load[day] = day_load.get(day, 0) + 1
58
+ record = row.to_dict()
59
+ record["replacement_day"] = day
60
+ scheduled.append(record)
61
+ return pd.DataFrame(scheduled).sort_values("replacement_day").reset_index(drop=True)
62
+
63
+
64
+ def group_site_visits(
65
+ schedule: pd.DataFrame,
66
+ site_col: str,
67
+ tolerance_days: int = 3,
68
+ ) -> pd.DataFrame:
69
+ if site_col not in schedule.columns:
70
+ return schedule
71
+ result = schedule.copy().sort_values([site_col, "replacement_day"])
72
+ for _, index in result.groupby(site_col, sort=False).groups.items():
73
+ positions = list(index)
74
+ anchor = int(result.loc[positions[0], "replacement_day"])
75
+ for position in positions[1:]:
76
+ current = int(result.loc[position, "replacement_day"])
77
+ if current - anchor <= tolerance_days:
78
+ result.loc[position, "replacement_day"] = anchor
79
+ else:
80
+ anchor = current
81
+ return result.sort_values("replacement_day").reset_index(drop=True)
src/batteryswapai/schema.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, asdict
4
+ from typing import Iterable
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+
9
+
10
+ ROLE_HINTS = {
11
+ "timestamp": ("timestamp", "datetime", "recorded_at", "created_at", "time", "date"),
12
+ "entity_id": ("sensor_id", "device_id", "serial_number", "serial", "sensor", "device", "uuid", "mac"),
13
+ "target": ("remaining_useful_life", "remaining_life", "rul_days", "rul", "remaining_days", "target"),
14
+ "voltage": ("battery_voltage", "voltage", "vbat", "battery_v", "volt"),
15
+ "site_id": ("building_id", "site_id", "location_id", "building", "site", "location"),
16
+ "event": ("battery_replaced", "replacement", "replaced", "failure", "failed", "event"),
17
+ }
18
+
19
+ FUTURE_NAME_FRAGMENTS = (
20
+ "rul",
21
+ "remaining",
22
+ "future",
23
+ "failure_time",
24
+ "failure_date",
25
+ "end_time",
26
+ "end_date",
27
+ "next_replacement",
28
+ )
29
+
30
+
31
+ def _norm(value: str) -> str:
32
+ return "".join(char.lower() if char.isalnum() else "_" for char in value).strip("_")
33
+
34
+
35
+ def _score_name(column: str, hints: Iterable[str]) -> int:
36
+ normalized = _norm(column)
37
+ score = 0
38
+ for rank, hint in enumerate(hints):
39
+ if normalized == hint:
40
+ score = max(score, 100 - rank)
41
+ elif hint in normalized:
42
+ score = max(score, 60 - rank)
43
+ return score
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class InferredSchema:
48
+ timestamp: str | None
49
+ entity_id: str | None
50
+ target: str | None
51
+ voltage: str | None
52
+ site_id: str | None
53
+ event: str | None
54
+
55
+ def as_dict(self) -> dict[str, str | None]:
56
+ return asdict(self)
57
+
58
+
59
+ def infer_schema(df: pd.DataFrame) -> InferredSchema:
60
+ candidates: dict[str, str | None] = {}
61
+ for role, hints in ROLE_HINTS.items():
62
+ ranked = sorted(
63
+ ((column, _score_name(column, hints)) for column in df.columns),
64
+ key=lambda item: item[1],
65
+ reverse=True,
66
+ )
67
+ candidates[role] = ranked[0][0] if ranked and ranked[0][1] > 0 else None
68
+
69
+ if candidates["timestamp"] is None:
70
+ for column in df.columns:
71
+ if pd.api.types.is_datetime64_any_dtype(df[column]):
72
+ candidates["timestamp"] = column
73
+ break
74
+
75
+ if candidates["entity_id"] is None:
76
+ object_columns = [
77
+ column
78
+ for column in df.columns
79
+ if pd.api.types.is_object_dtype(df[column]) or isinstance(df[column].dtype, pd.CategoricalDtype)
80
+ ]
81
+ if object_columns:
82
+ ratios = []
83
+ n = max(len(df), 1)
84
+ for column in object_columns:
85
+ unique_ratio = df[column].nunique(dropna=True) / n
86
+ score = 1.0 - abs(unique_ratio - 0.05)
87
+ ratios.append((column, score))
88
+ candidates["entity_id"] = max(ratios, key=lambda item: item[1])[0]
89
+
90
+ return InferredSchema(**candidates)
91
+
92
+
93
+ def resolve_schema(df: pd.DataFrame, config: dict) -> InferredSchema:
94
+ inferred = infer_schema(df)
95
+ resolved = {}
96
+ for role in ROLE_HINTS:
97
+ configured = config.get(role, "auto")
98
+ value = getattr(inferred, role) if configured in (None, "auto") else configured
99
+ if value is not None and value not in df.columns:
100
+ raise ValueError(f"Configured {role} column '{value}' does not exist")
101
+ resolved[role] = value
102
+ return InferredSchema(**resolved)
103
+
104
+
105
+ def leakage_columns(df: pd.DataFrame, target: str | None = None) -> list[str]:
106
+ blocked: list[str] = []
107
+ for column in df.columns:
108
+ normalized = _norm(column)
109
+ if target and column == target:
110
+ blocked.append(column)
111
+ continue
112
+ if any(fragment in normalized for fragment in FUTURE_NAME_FRAGMENTS):
113
+ blocked.append(column)
114
+ return sorted(set(blocked))
115
+
116
+
117
+ def describe_table(df: pd.DataFrame) -> dict:
118
+ return {
119
+ "rows": int(len(df)),
120
+ "columns": int(len(df.columns)),
121
+ "duplicate_rows": int(df.duplicated().sum()),
122
+ "missing_fraction": {
123
+ column: float(value)
124
+ for column, value in df.isna().mean().sort_values(ascending=False).head(15).items()
125
+ },
126
+ "dtypes": {column: str(dtype) for column, dtype in df.dtypes.items()},
127
+ }
src/batteryswapai/submission.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import pandas as pd
6
+
7
+
8
+ def write_predictions(
9
+ ids: pd.Series,
10
+ predictions,
11
+ output_path: str | Path,
12
+ id_name: str,
13
+ prediction_name: str = "rul_days",
14
+ ) -> Path:
15
+ path = Path(output_path)
16
+ path.parent.mkdir(parents=True, exist_ok=True)
17
+ output = pd.DataFrame({id_name: ids.to_numpy(), prediction_name: predictions})
18
+ output.to_csv(path, index=False)
19
+ return path
src/batteryswapai/synthetic.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ import pandas as pd
5
+
6
+
7
+ def make_synthetic_dataset(
8
+ n_devices: int = 24,
9
+ days: int = 180,
10
+ seed: int = 2026,
11
+ ) -> pd.DataFrame:
12
+ rng = np.random.default_rng(seed)
13
+ start = pd.Timestamp("2025-01-01", tz="UTC")
14
+ rows = []
15
+
16
+ for device in range(n_devices):
17
+ lifetime = int(rng.integers(days // 2, days + 60))
18
+ base_voltage = rng.normal(3.62, 0.025)
19
+ temperature_base = rng.normal(21.0, 3.0)
20
+ site = f"site_{device % 5}"
21
+ for day in range(days):
22
+ timestamp = start + pd.Timedelta(days=day)
23
+ health = max(0.0, 1.0 - day / lifetime)
24
+ knee = 1.0 / (1.0 + np.exp(-(day - 0.82 * lifetime) / 7.0))
25
+ voltage = base_voltage - 0.10 * (1.0 - health) - 0.48 * knee + rng.normal(0, 0.012)
26
+ temperature = temperature_base + 5.0 * np.sin(2 * np.pi * day / 365.0) + rng.normal(0, 1.0)
27
+ observations = max(1, int(rng.poisson(24)))
28
+ rul = max(float(lifetime - day), 0.0)
29
+ rows.append(
30
+ {
31
+ "device_id": f"device_{device:03d}",
32
+ "site_id": site,
33
+ "timestamp": timestamp,
34
+ "battery_voltage": voltage,
35
+ "temperature": temperature,
36
+ "message_count": observations,
37
+ "rul_days": rul,
38
+ }
39
+ )
40
+ return pd.DataFrame(rows)
src/batteryswapai/uncertainty.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+ from sklearn.ensemble import HistGradientBoostingRegressor
8
+
9
+ from .models import _categorical_columns, encode_features
10
+
11
+
12
+ @dataclass
13
+ class QuantileRULModel:
14
+ models: dict[float, HistGradientBoostingRegressor]
15
+ feature_columns: list[str]
16
+ categorical_columns: list[str]
17
+ encoded_columns: list[str]
18
+ fill_values: pd.Series
19
+
20
+ def predict(self, frame: pd.DataFrame) -> pd.DataFrame:
21
+ features, _, _ = encode_features(
22
+ frame[self.feature_columns],
23
+ self.categorical_columns,
24
+ encoded_columns=self.encoded_columns,
25
+ fill_values=self.fill_values,
26
+ )
27
+ output = {}
28
+ for quantile, model in sorted(self.models.items()):
29
+ output[f"q{int(round(quantile * 100)):02d}"] = np.clip(model.predict(features), 0.0, None)
30
+ result = pd.DataFrame(output, index=frame.index)
31
+ columns = list(result.columns)
32
+ result[columns] = np.maximum.accumulate(result[columns].to_numpy(), axis=1)
33
+ return result
34
+
35
+
36
+ def fit_quantile_models(
37
+ frame: pd.DataFrame,
38
+ feature_columns: list[str],
39
+ target_col: str,
40
+ categorical_columns: list[str] | None = None,
41
+ quantiles: tuple[float, ...] = (0.1, 0.5, 0.9),
42
+ iterations: int = 700,
43
+ depth: int = 8,
44
+ learning_rate: float = 0.04,
45
+ seed: int = 2026,
46
+ ) -> QuantileRULModel:
47
+ work = frame.dropna(subset=[target_col]).copy()
48
+ categorical_columns = categorical_columns or _categorical_columns(work, feature_columns)
49
+ features, encoded_columns, fill_values = encode_features(
50
+ work[feature_columns], categorical_columns
51
+ )
52
+ target = pd.to_numeric(work[target_col], errors="coerce").astype(float)
53
+ models = {}
54
+ for quantile in quantiles:
55
+ model = HistGradientBoostingRegressor(
56
+ loss="quantile",
57
+ quantile=quantile,
58
+ max_iter=iterations,
59
+ max_leaf_nodes=max(7, 2**depth - 1),
60
+ learning_rate=learning_rate,
61
+ l2_regularization=2.0,
62
+ random_state=seed,
63
+ )
64
+ model.fit(features, target)
65
+ models[quantile] = model
66
+ return QuantileRULModel(
67
+ models,
68
+ feature_columns,
69
+ categorical_columns,
70
+ encoded_columns,
71
+ fill_values,
72
+ )
73
+
74
+
75
+ def conformal_radius(y_true, y_pred, coverage: float = 0.9) -> float:
76
+ residuals = np.abs(np.asarray(y_true, dtype=float) - np.asarray(y_pred, dtype=float))
77
+ residuals = residuals[np.isfinite(residuals)]
78
+ if residuals.size == 0:
79
+ raise ValueError("No finite residuals available for conformal calibration")
80
+ return float(np.quantile(residuals, coverage, method="higher"))
81
+
82
+
83
+ def add_conformal_interval(predictions, radius: float) -> pd.DataFrame:
84
+ median = np.asarray(predictions, dtype=float)
85
+ return pd.DataFrame(
86
+ {
87
+ "lower": np.clip(median - radius, 0.0, None),
88
+ "median": np.clip(median, 0.0, None),
89
+ "upper": np.clip(median + radius, 0.0, None),
90
+ }
91
+ )
src/batteryswapai/validation.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+ from sklearn.model_selection import GroupKFold
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class Fold:
12
+ train_idx: np.ndarray
13
+ valid_idx: np.ndarray
14
+
15
+
16
+ def group_folds(df: pd.DataFrame, group_col: str, folds: int = 5) -> list[Fold]:
17
+ groups = df[group_col].astype(str)
18
+ unique_groups = groups.nunique()
19
+ if unique_groups < 2:
20
+ raise ValueError("At least two groups are required for leakage-safe validation")
21
+ n_splits = min(folds, unique_groups)
22
+ splitter = GroupKFold(n_splits=n_splits)
23
+ dummy = np.zeros(len(df))
24
+ return [Fold(train, valid) for train, valid in splitter.split(dummy, groups=groups)]
25
+
26
+
27
+ def temporal_holdout(
28
+ df: pd.DataFrame,
29
+ timestamp_col: str,
30
+ valid_fraction: float = 0.2,
31
+ ) -> Fold:
32
+ timestamps = pd.to_datetime(df[timestamp_col], errors="coerce", utc=True)
33
+ cutoff = timestamps.quantile(1.0 - valid_fraction)
34
+ train = np.flatnonzero((timestamps < cutoff).to_numpy())
35
+ valid = np.flatnonzero((timestamps >= cutoff).to_numpy())
36
+ if len(train) == 0 or len(valid) == 0:
37
+ raise ValueError("Temporal holdout produced an empty split")
38
+ return Fold(train, valid)
submission_artifacts/weekly_planner.joblib ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:228d48ca831b806c44b66c1e082ed4b8ec1e8ca2b43978bc14af15be731a2295
3
+ size 16259582