Commit ·
3888717
1
Parent(s): 1a5021f
Restore the proven src tree: drop 15 unused modules from submission 010
Browse files- .gitignore +1 -0
- src/batteryswapai/audit.py +0 -92
- src/batteryswapai/cli.py +0 -77
- src/batteryswapai/config.py +0 -11
- src/batteryswapai/features.py +0 -225
- src/batteryswapai/io.py +0 -63
- src/batteryswapai/lifecycles.py +0 -65
- src/batteryswapai/metrics.py +0 -15
- src/batteryswapai/models.py +0 -151
- src/batteryswapai/pipeline.py +0 -74
- src/batteryswapai/scheduling.py +0 -81
- src/batteryswapai/schema.py +0 -127
- src/batteryswapai/submission.py +0 -19
- src/batteryswapai/synthetic.py +0 -40
- src/batteryswapai/uncertainty.py +0 -91
- src/batteryswapai/validation.py +0 -38
.gitignore
CHANGED
|
@@ -32,3 +32,4 @@ submission_artifacts/*
|
|
| 32 |
# Local training logs
|
| 33 |
catboost_info/
|
| 34 |
submission.csv
|
|
|
|
|
|
| 32 |
# Local training logs
|
| 33 |
catboost_info/
|
| 34 |
submission.csv
|
| 35 |
+
!submission_artifacts/weekly_planner.joblib
|
src/batteryswapai/audit.py
DELETED
|
@@ -1,92 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,77 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,11 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,225 +0,0 @@
|
|
| 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/io.py
DELETED
|
@@ -1,63 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,65 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,15 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,151 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,74 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,81 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,127 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,19 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,40 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,91 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,38 +0,0 @@
|
|
| 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|