Commit ·
1a5021f
1
Parent(s): 58f71d2
Submission 010: weekly capacity margin on the 008 stack
Browse files- script.py +1 -1
- src/batteryswapai/audit.py +92 -0
- src/batteryswapai/cli.py +77 -0
- src/batteryswapai/config.py +11 -0
- src/batteryswapai/features.py +225 -0
- src/batteryswapai/hierarchical_fpt.py +1 -4
- src/batteryswapai/io.py +63 -0
- src/batteryswapai/lifecycles.py +65 -0
- src/batteryswapai/metrics.py +15 -0
- src/batteryswapai/models.py +151 -0
- src/batteryswapai/pipeline.py +74 -0
- src/batteryswapai/scheduling.py +81 -0
- src/batteryswapai/schema.py +127 -0
- src/batteryswapai/submission.py +19 -0
- src/batteryswapai/synthetic.py +40 -0
- src/batteryswapai/uncertainty.py +91 -0
- src/batteryswapai/validation.py +38 -0
- submission_artifacts/weekly_planner.joblib +3 -0
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/
|
| 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.
|
| 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
|