CarlAlbertCode commited on
Commit
2106fa0
·
1 Parent(s): 7752099

Submission 004: generalization-fixed v3 planner

Browse files
scripts/train_submission.py CHANGED
@@ -32,6 +32,11 @@ def parse_args() -> argparse.Namespace:
32
  parser.add_argument("--quantile", type=float, default=0.05)
33
  parser.add_argument("--event-risk-threshold", type=float, default=0.50)
34
  parser.add_argument("--prediction-offset-days", type=float, default=-5.0)
 
 
 
 
 
35
  parser.add_argument("--calibration-folds", type=int, default=5)
36
  parser.add_argument("--skip-evaluation", action="store_true")
37
  return parser.parse_args()
@@ -65,6 +70,11 @@ def main() -> None:
65
  event_risk_threshold=args.event_risk_threshold,
66
  prediction_offset_days=args.prediction_offset_days,
67
  use_expected_cost=True,
 
 
 
 
 
68
  capacity_lookahead_days=21,
69
  ),
70
  )
@@ -77,12 +87,19 @@ def main() -> None:
77
  "quantile": args.quantile,
78
  "event_risk_threshold": args.event_risk_threshold,
79
  "prediction_offset_days": args.prediction_offset_days,
 
 
 
 
 
80
  "calibration_folds": args.calibration_folds,
81
  "planner_policy": {
82
  "expected_cost": True,
83
  "risk_calibration_scale": planner.policy.risk_calibration_scale,
84
  "expected_service_cost_hours": planner.policy.expected_service_cost_hours,
85
  "expected_gain_margin": planner.policy.expected_gain_margin,
 
 
86
  "prediction_offset_days": planner.policy.prediction_offset_days,
87
  "stale_risk_cutoff_days": planner.policy.stale_risk_cutoff_days,
88
  "recent_gap_risk_factor": planner.policy.recent_gap_risk_factor,
 
32
  parser.add_argument("--quantile", type=float, default=0.05)
33
  parser.add_argument("--event-risk-threshold", type=float, default=0.50)
34
  parser.add_argument("--prediction-offset-days", type=float, default=-5.0)
35
+ parser.add_argument("--risk-calibration-scale", type=float, default=1.5)
36
+ parser.add_argument("--expected-gain-margin", type=float, default=10.0)
37
+ parser.add_argument("--expected-service-cost-hours", type=float, default=2.0)
38
+ parser.add_argument("--minimum-scheduled-batteries", type=int, default=16)
39
+ parser.add_argument("--maximum-scheduled-batteries", type=int, default=20)
40
  parser.add_argument("--calibration-folds", type=int, default=5)
41
  parser.add_argument("--skip-evaluation", action="store_true")
42
  return parser.parse_args()
 
70
  event_risk_threshold=args.event_risk_threshold,
71
  prediction_offset_days=args.prediction_offset_days,
72
  use_expected_cost=True,
73
+ risk_calibration_scale=args.risk_calibration_scale,
74
+ expected_gain_margin=args.expected_gain_margin,
75
+ expected_service_cost_hours=args.expected_service_cost_hours,
76
+ minimum_scheduled_batteries=args.minimum_scheduled_batteries,
77
+ maximum_scheduled_batteries=args.maximum_scheduled_batteries,
78
  capacity_lookahead_days=21,
79
  ),
80
  )
 
87
  "quantile": args.quantile,
88
  "event_risk_threshold": args.event_risk_threshold,
89
  "prediction_offset_days": args.prediction_offset_days,
90
+ "risk_calibration_scale": args.risk_calibration_scale,
91
+ "expected_gain_margin": args.expected_gain_margin,
92
+ "expected_service_cost_hours": args.expected_service_cost_hours,
93
+ "minimum_scheduled_batteries": args.minimum_scheduled_batteries,
94
+ "maximum_scheduled_batteries": args.maximum_scheduled_batteries,
95
  "calibration_folds": args.calibration_folds,
96
  "planner_policy": {
97
  "expected_cost": True,
98
  "risk_calibration_scale": planner.policy.risk_calibration_scale,
99
  "expected_service_cost_hours": planner.policy.expected_service_cost_hours,
100
  "expected_gain_margin": planner.policy.expected_gain_margin,
101
+ "minimum_scheduled_batteries": planner.policy.minimum_scheduled_batteries,
102
+ "maximum_scheduled_batteries": planner.policy.maximum_scheduled_batteries,
103
  "prediction_offset_days": planner.policy.prediction_offset_days,
104
  "stale_risk_cutoff_days": planner.policy.stale_risk_cutoff_days,
105
  "recent_gap_risk_factor": planner.policy.recent_gap_risk_factor,
scripts/validate_submission.py CHANGED
@@ -25,16 +25,47 @@ def _numbers(value: str) -> list[float]:
25
  return [float(item.strip()) for item in value.split(",") if item.strip()]
26
 
27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  def parse_args() -> argparse.Namespace:
29
  parser = argparse.ArgumentParser()
30
  parser.add_argument("--dataset-path", type=Path, default=Path("data/raw/train"))
31
  parser.add_argument("--folds", type=int, default=5)
32
  parser.add_argument("--quantile", type=float, default=0.05)
33
  parser.add_argument("--inner-calibration-folds", type=int, default=3)
 
 
 
 
 
 
 
 
 
34
  parser.add_argument("--risk-scales", default="1.50")
35
  parser.add_argument("--gain-margins", default="10")
36
  parser.add_argument("--service-costs", default="2")
37
  parser.add_argument("--offsets", default="-5")
 
 
 
 
 
 
 
38
  parser.add_argument(
39
  "--output", type=Path, default=Path("artifacts/submission_cv_final.json")
40
  )
@@ -63,21 +94,30 @@ def main() -> None:
63
 
64
  oof_risk = np.full(len(training), np.nan)
65
  oof_rul = np.full(len(training), np.nan)
66
- splitter = GroupKFold(n_splits=args.folds)
67
- groups = training["battery"].astype(str)
68
- for fold_number, (train_index, valid_index) in enumerate(
69
- splitter.split(training, groups=groups), start=1
70
- ):
71
- model = fit_event_time_model(
72
- training.iloc[train_index],
73
- quantile=args.quantile,
74
- dataset_revision=DATASET_REVISION,
75
- random_state=2026 + fold_number,
76
- calibration_folds=args.inner_calibration_folds,
77
- )
78
- valid = training.iloc[valid_index]
79
- oof_risk[valid_index] = model.predict_event_risk(valid)
80
- oof_rul[valid_index] = model.predict_rul(valid)
 
 
 
 
 
 
 
 
 
81
 
82
  due = (
83
  training["event_observed"].astype(bool)
@@ -114,76 +154,84 @@ def main() -> None:
114
  classification["due_within_horizon_rul_mae_days"] = float(np.mean(due_rul_error))
115
 
116
  policy_results = []
117
- placeholder_model = model
118
  for risk_scale in _numbers(args.risk_scales):
119
  for gain_margin in _numbers(args.gain_margins):
120
  for service_cost in _numbers(args.service_costs):
121
  for offset in _numbers(args.offsets):
122
- planner = CompetitionPlanner(
123
- placeholder_model,
124
- PlannerPolicy(
125
- event_risk_threshold=0.50,
126
- prediction_offset_days=offset,
127
- use_expected_cost=True,
128
- risk_calibration_scale=risk_scale,
129
- expected_service_cost_hours=service_cost,
130
- expected_gain_margin=gain_margin,
131
- capacity_lookahead_days=21,
132
- ),
133
- )
134
- scores = []
135
- scheduled_counts = []
136
- for scenario_name, (scenario, locs, not_dead) in scenario_inputs.items():
137
- mask = training["scenario"].eq(scenario_name).to_numpy()
138
- snapshot = training.loc[mask]
139
- risk = oof_risk[mask]
140
- rul = oof_rul[mask]
141
- plan = planner.plan_snapshot(
142
- snapshot,
143
- locs,
144
- scenario["travel_costs"],
145
- scenario["settings"],
146
- scenario["start_time"],
147
- predicted_rul=rul,
148
- predicted_risk=risk,
149
- )
150
- start = pd.Timestamp(scenario["start_time"])
151
- horizon_end = start + pd.Timedelta(
152
- days=scenario["settings"].planning_window_days
153
  )
154
- scheduled_counts.append(int(plan["day"].le(horizon_end).sum()))
155
- _, _, score = evaluate_plan(
156
- plan,
157
- locs,
158
- scenario["travel_costs"],
159
- scenario["settings"],
160
- eol_times=not_dead,
161
- start_time=start,
162
- verbose=0,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  )
164
- scores.append(score)
165
- mean_score = pd.concat(scores, axis=1).mean(axis=1)
166
- policy_results.append(
167
- {
168
- "risk_calibration_scale": risk_scale,
169
- "expected_gain_margin": gain_margin,
170
- "expected_service_cost_hours": service_cost,
171
- "prediction_offset_days": offset,
172
- "stale_risk_cutoff_days": planner.policy.stale_risk_cutoff_days,
173
- "recent_gap_risk_factor": planner.policy.recent_gap_risk_factor,
174
- "stale_risk_factor": planner.policy.stale_risk_factor,
175
- "building_batch_window_days": planner.policy.building_batch_window_days,
176
- "capacity_operational_cost_weight": planner.policy.capacity_operational_cost_weight,
177
- "mean_scheduled_batteries": float(np.mean(scheduled_counts)),
178
- **{key: float(value) for key, value in mean_score.items()},
179
- }
180
- )
181
 
182
  policy_results.sort(key=lambda item: item["total_cost"])
183
  report = {
184
  "dataset_revision": DATASET_REVISION,
185
  "folds": args.folds,
186
  "inner_calibration_folds": args.inner_calibration_folds,
 
187
  "quantile": args.quantile,
188
  "classification": classification,
189
  "policies": policy_results,
 
25
  return [float(item.strip()) for item in value.split(",") if item.strip()]
26
 
27
 
28
+ def _integers(value: str) -> list[int]:
29
+ return [int(item.strip()) for item in value.split(",") if item.strip()]
30
+
31
+
32
+ def _schedule_bands(quotas: str, bands: str) -> list[tuple[int, int]]:
33
+ values = [(quota, quota) for quota in _integers(quotas)]
34
+ for item in bands.split(","):
35
+ item = item.strip()
36
+ if not item:
37
+ continue
38
+ minimum, maximum = item.split(":", maxsplit=1)
39
+ values.append((int(minimum), int(maximum)))
40
+ return list(dict.fromkeys(values))
41
+
42
+
43
  def parse_args() -> argparse.Namespace:
44
  parser = argparse.ArgumentParser()
45
  parser.add_argument("--dataset-path", type=Path, default=Path("data/raw/train"))
46
  parser.add_argument("--folds", type=int, default=5)
47
  parser.add_argument("--quantile", type=float, default=0.05)
48
  parser.add_argument("--inner-calibration-folds", type=int, default=3)
49
+ parser.add_argument(
50
+ "--outer-group",
51
+ choices=("battery", "building"),
52
+ default="building",
53
+ help=(
54
+ "Hold out entire buildings by default to emulate hidden-domain "
55
+ "generalization; use battery only to reproduce legacy reports."
56
+ ),
57
+ )
58
  parser.add_argument("--risk-scales", default="1.50")
59
  parser.add_argument("--gain-margins", default="10")
60
  parser.add_argument("--service-costs", default="2")
61
  parser.add_argument("--offsets", default="-5")
62
+ parser.add_argument("--schedule-quotas", default="")
63
+ parser.add_argument("--schedule-bands", default="16:20")
64
+ parser.add_argument(
65
+ "--predictions-csv",
66
+ type=Path,
67
+ help="Reuse previously generated OOF predictions for policy-only sweeps.",
68
+ )
69
  parser.add_argument(
70
  "--output", type=Path, default=Path("artifacts/submission_cv_final.json")
71
  )
 
94
 
95
  oof_risk = np.full(len(training), np.nan)
96
  oof_rul = np.full(len(training), np.nan)
97
+ if args.predictions_csv is not None:
98
+ cached = pd.read_csv(args.predictions_csv).set_index(["scenario", "battery"])
99
+ keys = pd.MultiIndex.from_frame(training[["scenario", "battery"]])
100
+ aligned = cached.reindex(keys)
101
+ if aligned[["oof_event_risk", "oof_rul_days"]].isna().any().any():
102
+ raise ValueError("Cached OOF predictions do not cover the validation rows")
103
+ oof_risk[:] = aligned["oof_event_risk"].to_numpy(dtype=float)
104
+ oof_rul[:] = aligned["oof_rul_days"].to_numpy(dtype=float)
105
+ else:
106
+ splitter = GroupKFold(n_splits=args.folds)
107
+ groups = training[args.outer_group].astype(str)
108
+ for fold_number, (train_index, valid_index) in enumerate(
109
+ splitter.split(training, groups=groups), start=1
110
+ ):
111
+ model = fit_event_time_model(
112
+ training.iloc[train_index],
113
+ quantile=args.quantile,
114
+ dataset_revision=DATASET_REVISION,
115
+ random_state=2026 + fold_number,
116
+ calibration_folds=args.inner_calibration_folds,
117
+ )
118
+ valid = training.iloc[valid_index]
119
+ oof_risk[valid_index] = model.predict_event_risk(valid)
120
+ oof_rul[valid_index] = model.predict_rul(valid)
121
 
122
  due = (
123
  training["event_observed"].astype(bool)
 
154
  classification["due_within_horizon_rul_mae_days"] = float(np.mean(due_rul_error))
155
 
156
  policy_results = []
157
+ placeholder_model = None
158
  for risk_scale in _numbers(args.risk_scales):
159
  for gain_margin in _numbers(args.gain_margins):
160
  for service_cost in _numbers(args.service_costs):
161
  for offset in _numbers(args.offsets):
162
+ for minimum, maximum in _schedule_bands(
163
+ args.schedule_quotas, args.schedule_bands
164
+ ):
165
+ planner = CompetitionPlanner(
166
+ placeholder_model,
167
+ PlannerPolicy(
168
+ event_risk_threshold=0.50,
169
+ prediction_offset_days=offset,
170
+ use_expected_cost=True,
171
+ risk_calibration_scale=risk_scale,
172
+ expected_service_cost_hours=service_cost,
173
+ expected_gain_margin=gain_margin,
174
+ capacity_lookahead_days=21,
175
+ minimum_scheduled_batteries=minimum,
176
+ maximum_scheduled_batteries=maximum,
177
+ ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  )
179
+ scores = []
180
+ scheduled_counts = []
181
+ for scenario_name, (scenario, locs, not_dead) in scenario_inputs.items():
182
+ mask = training["scenario"].eq(scenario_name).to_numpy()
183
+ snapshot = training.loc[mask]
184
+ risk = oof_risk[mask]
185
+ rul = oof_rul[mask]
186
+ plan = planner.plan_snapshot(
187
+ snapshot,
188
+ locs,
189
+ scenario["travel_costs"],
190
+ scenario["settings"],
191
+ scenario["start_time"],
192
+ predicted_rul=rul,
193
+ predicted_risk=risk,
194
+ )
195
+ start = pd.Timestamp(scenario["start_time"])
196
+ horizon_end = start + pd.Timedelta(
197
+ days=scenario["settings"].planning_window_days
198
+ )
199
+ scheduled_counts.append(int(plan["day"].le(horizon_end).sum()))
200
+ _, _, score = evaluate_plan(
201
+ plan,
202
+ locs,
203
+ scenario["travel_costs"],
204
+ scenario["settings"],
205
+ eol_times=not_dead,
206
+ start_time=start,
207
+ verbose=0,
208
+ )
209
+ scores.append(score)
210
+ mean_score = pd.concat(scores, axis=1).mean(axis=1)
211
+ policy_results.append(
212
+ {
213
+ "risk_calibration_scale": risk_scale,
214
+ "expected_gain_margin": gain_margin,
215
+ "expected_service_cost_hours": service_cost,
216
+ "prediction_offset_days": offset,
217
+ "minimum_scheduled_batteries": minimum,
218
+ "maximum_scheduled_batteries": maximum,
219
+ "stale_risk_cutoff_days": planner.policy.stale_risk_cutoff_days,
220
+ "recent_gap_risk_factor": planner.policy.recent_gap_risk_factor,
221
+ "stale_risk_factor": planner.policy.stale_risk_factor,
222
+ "building_batch_window_days": planner.policy.building_batch_window_days,
223
+ "capacity_operational_cost_weight": planner.policy.capacity_operational_cost_weight,
224
+ "mean_scheduled_batteries": float(np.mean(scheduled_counts)),
225
+ **{key: float(value) for key, value in mean_score.items()},
226
+ }
227
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
 
229
  policy_results.sort(key=lambda item: item["total_cost"])
230
  report = {
231
  "dataset_revision": DATASET_REVISION,
232
  "folds": args.folds,
233
  "inner_calibration_folds": args.inner_calibration_folds,
234
+ "outer_group": args.outer_group,
235
  "quantile": args.quantile,
236
  "classification": classification,
237
  "policies": policy_results,
src/batteryswapai/competition_features.py CHANGED
@@ -20,6 +20,12 @@ NON_FEATURE_COLUMNS = {
20
  "effective_eol",
21
  "target_rul_days",
22
  "event_observed",
 
 
 
 
 
 
23
  }
24
 
25
 
 
20
  "effective_eol",
21
  "target_rul_days",
22
  "event_observed",
23
+ # Dataset/scenario boundary fields are useful to the planner and reports,
24
+ # but they are not battery-health measurements. Learning from them makes
25
+ # same-scenario validation optimistic and breaks on a shifted hidden split.
26
+ "censor_proxy_rul_days",
27
+ "scenario_month_sin",
28
+ "scenario_month_cos",
29
  }
30
 
31
 
src/batteryswapai/competition_planner.py CHANGED
@@ -31,6 +31,8 @@ class PlannerPolicy:
31
  stale_risk_cutoff_days: float = 7.0
32
  recent_gap_risk_factor: float = 0.75
33
  stale_risk_factor: float = 0.10
 
 
34
 
35
 
36
  class CompetitionPlanner(Planner):
@@ -90,16 +92,6 @@ class CompetitionPlanner(Planner):
90
  work["predicted_risk"] = risks
91
  work["target_day"] = np.floor(np.clip(predictions, 0.0, horizon + 1.0)).astype(int)
92
  if self.policy.use_expected_cost:
93
- if "censor_proxy_rul_days" in snapshot.columns:
94
- proxy_rul = pd.to_numeric(
95
- snapshot["censor_proxy_rul_days"], errors="coerce"
96
- ).to_numpy(dtype=float)
97
- else:
98
- proxy_rul = (
99
- pd.to_datetime(snapshot["end_time"], errors="coerce").dt.normalize()
100
- + pd.to_timedelta(float(settings.unobserved_eol_days), unit="D")
101
- - start
102
- ).dt.total_seconds().to_numpy() / 86400.0
103
  planned_day = np.clip(predictions, 0.0, float(horizon))
104
  expected_event_day = np.clip(
105
  raw_predictions + self.policy.expected_event_day_shift,
@@ -123,9 +115,12 @@ class CompetitionPlanner(Planner):
123
  * np.maximum(emergency_day - expected_event_day, 0.0)
124
  + self.policy.expected_service_cost_hours
125
  )
126
- healthy_early_cost = float(settings.early_replacement_penalty_daily) * np.maximum(
127
- proxy_rul - planned_day,
128
- 0.0,
 
 
 
129
  )
130
  expected_gain = (
131
  calibrated_risk * (no_schedule_event_cost - scheduled_event_cost)
@@ -142,6 +137,26 @@ class CompetitionPlanner(Planner):
142
  (risks >= self.policy.event_risk_threshold) & (predictions <= horizon)
143
  )
144
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  candidates = work[work["scheduled"]].copy()
146
  if not candidates.empty:
147
  candidates = self._schedule_candidates(
 
31
  stale_risk_cutoff_days: float = 7.0
32
  recent_gap_risk_factor: float = 0.75
33
  stale_risk_factor: float = 0.10
34
+ minimum_scheduled_batteries: int = 0
35
+ maximum_scheduled_batteries: int | None = None
36
 
37
 
38
  class CompetitionPlanner(Planner):
 
92
  work["predicted_risk"] = risks
93
  work["target_day"] = np.floor(np.clip(predictions, 0.0, horizon + 1.0)).astype(int)
94
  if self.policy.use_expected_cost:
 
 
 
 
 
 
 
 
 
 
95
  planned_day = np.clip(predictions, 0.0, float(horizon))
96
  expected_event_day = np.clip(
97
  raw_predictions + self.policy.expected_event_day_shift,
 
115
  * np.maximum(emergency_day - expected_event_day, 0.0)
116
  + self.policy.expected_service_cost_hours
117
  )
118
+ # Conditional on no event within the planning horizon, EOL is
119
+ # later than the horizon. Location end_time is only a dataset
120
+ # boundary and shifted between train and the hidden submission.
121
+ healthy_early_cost = (
122
+ float(settings.early_replacement_penalty_daily)
123
+ * np.maximum(float(horizon) - planned_day, 0.0)
124
  )
125
  expected_gain = (
126
  calibrated_risk * (no_schedule_event_cost - scheduled_event_cost)
 
137
  (risks >= self.policy.event_risk_threshold) & (predictions <= horizon)
138
  )
139
 
140
+ # Absolute probabilities drifted sharply between train and the public
141
+ # split. Keep the workload stable and use the model primarily as a
142
+ # ranking function, which is substantially more robust to calibration
143
+ # shift. Thresholding still decides the set when it lies in the band.
144
+ priority_column = "expected_gain" if self.policy.use_expected_cost else "predicted_risk"
145
+ ranked = work.sort_values(
146
+ [priority_column, "predicted_risk", "predicted_rul", "battery"],
147
+ ascending=[False, False, True, True],
148
+ )
149
+ minimum = min(max(int(self.policy.minimum_scheduled_batteries), 0), len(work))
150
+ maximum = self.policy.maximum_scheduled_batteries
151
+ selected = set(work.index[work["scheduled"]])
152
+ if len(selected) < minimum:
153
+ selected.update(ranked.index[:minimum])
154
+ if maximum is not None and len(selected) > int(maximum):
155
+ selected = set(
156
+ ranked.loc[ranked.index.isin(selected)].index[: int(maximum)]
157
+ )
158
+ work["scheduled"] = work.index.isin(selected)
159
+
160
  candidates = work[work["scheduled"]].copy()
161
  if not candidates.empty:
162
  candidates = self._schedule_candidates(
submission_artifacts/planner.joblib CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:75ed285b5e64b69543d58dc800bf50f508cdab056ed4ec20a2e65af3cb5bcf62
3
- size 3944622
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:88a27b7667a13cf00bf0643bf1cfabb2c186f4456de887228d9bc0e4331459fd
3
+ size 3965521
submission_artifacts/planner.json CHANGED
@@ -3,17 +3,21 @@
3
  "artifact": "/work/submission_artifacts/planner.joblib",
4
  "quantile": 0.05,
5
  "event_risk_threshold": 0.5,
6
- "prediction_offset_days": -4.0,
7
- "risk_calibration_scale": 1.85,
8
- "expected_gain_margin": 2.5,
9
  "expected_service_cost_hours": 2.0,
 
 
10
  "calibration_folds": 5,
11
  "planner_policy": {
12
  "expected_cost": true,
13
- "risk_calibration_scale": 1.85,
14
  "expected_service_cost_hours": 2.0,
15
- "expected_gain_margin": 2.5,
16
- "prediction_offset_days": -4.0,
 
 
17
  "stale_risk_cutoff_days": 7.0,
18
  "recent_gap_risk_factor": 0.75,
19
  "stale_risk_factor": 0.1,
@@ -97,16 +101,13 @@
97
  "voltage_range_day",
98
  "location_age_days",
99
  "data_gap_days",
100
- "censor_proxy_rul_days",
101
- "scenario_month_sin",
102
- "scenario_month_cos",
103
  "voltage_rank_global",
104
  "voltage_rank_building",
105
  "voltage_rank_room",
106
  "building_battery_count",
107
  "room_battery_count"
108
  ],
109
- "artifact_sha256": "75ed285b5e64b69543d58dc800bf50f508cdab056ed4ec20a2e65af3cb5bcf62",
110
  "runtime_versions": {
111
  "batteryswap_public": "0.3.4",
112
  "fastparquet": "2026.5.0",
 
3
  "artifact": "/work/submission_artifacts/planner.joblib",
4
  "quantile": 0.05,
5
  "event_risk_threshold": 0.5,
6
+ "prediction_offset_days": -5.0,
7
+ "risk_calibration_scale": 1.5,
8
+ "expected_gain_margin": 10.0,
9
  "expected_service_cost_hours": 2.0,
10
+ "minimum_scheduled_batteries": 16,
11
+ "maximum_scheduled_batteries": 20,
12
  "calibration_folds": 5,
13
  "planner_policy": {
14
  "expected_cost": true,
15
+ "risk_calibration_scale": 1.5,
16
  "expected_service_cost_hours": 2.0,
17
+ "expected_gain_margin": 10.0,
18
+ "minimum_scheduled_batteries": 16,
19
+ "maximum_scheduled_batteries": 20,
20
+ "prediction_offset_days": -5.0,
21
  "stale_risk_cutoff_days": 7.0,
22
  "recent_gap_risk_factor": 0.75,
23
  "stale_risk_factor": 0.1,
 
101
  "voltage_range_day",
102
  "location_age_days",
103
  "data_gap_days",
 
 
 
104
  "voltage_rank_global",
105
  "voltage_rank_building",
106
  "voltage_rank_room",
107
  "building_battery_count",
108
  "room_battery_count"
109
  ],
110
+ "artifact_sha256": "88a27b7667a13cf00bf0643bf1cfabb2c186f4456de887228d9bc0e4331459fd",
111
  "runtime_versions": {
112
  "batteryswap_public": "0.3.4",
113
  "fastparquet": "2026.5.0",