From 343eb40fb13e00655e30cf64e48a7f663b828d55 Mon Sep 17 00:00:00 2001 From: andig Date: Thu, 17 Sep 2026 10:54:05 +0200 Subject: [PATCH 1/2] feat: anchor charge continuity on the running session The continuity stage assumed every device enters the horizon switched off, so keeping a running session on scored the same as stopping it and restarting one slot later. A device reporting c_initial above zero now enters the horizon on: continuing costs no start, interrupting costs one, and a plan that only moves the start earlier is worth polishing. --- client/client.gen.go | 4 +++ openapi.yaml | 10 +++++++- src/optimizer/app.py | 2 ++ src/optimizer/optimizer.py | 17 ++++++++++--- tests/test_continuity.py | 48 ++++++++++++++++++++++++++++++++++-- tests/test_continuity_api.py | 19 ++++++++++++++ 6 files changed, 93 insertions(+), 7 deletions(-) diff --git a/client/client.gen.go b/client/client.gen.go index b4fc7a356..7fb9d7f1b 100644 --- a/client/client.gen.go +++ b/client/client.gen.go @@ -47,6 +47,10 @@ const ( // BatteryConfig defines model for BatteryConfig. type BatteryConfig struct { + // CInitial Charge power at the start of the time horizon in W. Greater than zero means the device is + // charging right now, so keeping it on costs no charge start and interrupting it does. + CInitial float32 `json:"c_initial,omitempty"` + // CMax Maximum charge power in W CMax float32 `json:"c_max"` diff --git a/openapi.yaml b/openapi.yaml index 43c58e788..5a3eb2bc7 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -240,7 +240,15 @@ components: type: number minimum: 0 description: Maximum charge power in W - example: 11000 + example: 11000 + c_initial: + type: number + minimum: 0 + default: 0 + description: | + Charge power at the start of the time horizon in W. Greater than zero means the device is + charging right now, so keeping it on costs no charge start and interrupting it does. + example: 4140 d_max: type: number minimum: 0 diff --git a/src/optimizer/app.py b/src/optimizer/app.py index a355b2d1c..d6cbbd4cd 100644 --- a/src/optimizer/app.py +++ b/src/optimizer/app.py @@ -114,6 +114,7 @@ def handle_validation_error(error): 's_goal': fields.List(fields.Float, required=False, description='Goal state of charge at each time step (Wh)'), 'c_min': fields.Float(required=True, description='Minimum charge power (W)'), 'c_max': fields.Float(required=True, description='Maximum charge power (W)'), + 'c_initial': fields.Float(required=False, description='Charge power at the start of the horizon (W). Greater than zero means the device charges now.'), 'd_max': fields.Float(required=True, description='Maximum discharge power (W)'), 'p_a': fields.Float(required=True, description='Monetary value per Wh at end of the optimization horizon'), 'c_priority': fields.Integer(required=False, description='Charging and discharging priority compared to other batteries. 2 = highest priority.') @@ -204,6 +205,7 @@ def post(self): s_goal=bat_data.get('s_goal'), c_min=bat_data['c_min'], c_max=bat_data['c_max'], + c_initial=bat_data.get('c_initial', 0.), d_max=bat_data['d_max'], p_a=bat_data['p_a'], c_priority=bat_data.get('c_priority', 0), diff --git a/src/optimizer/optimizer.py b/src/optimizer/optimizer.py index cf3fa481c..c862c8c51 100644 --- a/src/optimizer/optimizer.py +++ b/src/optimizer/optimizer.py @@ -144,6 +144,7 @@ class BatteryConfig: p_demand: Optional[List[float]] = None # Minimum charge demand (Wh) s_goal: Optional[List[float]] = None # Goal state of charge (Wh) c_priority: int = 0 + c_initial: float = 0. # Charge power at the start of the horizon (W) @dataclass @@ -797,22 +798,30 @@ def _solve_preferences(self, tmpdir, deadline) -> None: self.problem.status = pulp.LpStatusOptimal def _solve_continuity(self, tmpdir: str, deadline: float | None) -> None: - """Prefer fewer charge starts without trading away economics or existing preferences.""" + """Prefer fewer charge starts without trading away economics or existing preferences. + + A device reported as charging enters the horizon switched on, so keeping it on is free and + interrupting it costs a start. + """ if (self.problem.sol_status not in (pulp.LpSolutionOptimal, pulp.LpSolutionIntegerFeasible) or not _complete_solution(self.problem) or not self._is_integral()): return eligible = [i for i, active in self.variables['z_c'].items() if active is not None] + def charging_now(i: int) -> int: + return int(self.batteries[i].c_initial > 0) + def count_starts() -> list[int]: counts = [] for i in eligible: active = np.array([pulp.value(v) for v in self.variables['c'][i]]) > CONTINUITY_TOLERANCE - counts.append(int(np.count_nonzero(active & ~np.r_[False, active[:-1]]))) + counts.append(int(np.count_nonzero(active & ~np.r_[bool(charging_now(i)), active[:-1]]))) return counts before = count_starts() - if not any(count > 1 for count in before): + # a device that is charging already can reach zero starts, one that is idle needs one + if all(count <= 1 - charging_now(i) for i, count in zip(eligible, before)): return remaining = CONTINUITY_TIME_LIMIT if deadline is None else min(CONTINUITY_TIME_LIMIT, deadline - time.monotonic()) if remaining <= 0: @@ -838,7 +847,7 @@ def count_starts() -> list[int]: active = self.variables['z_c'][i] for t in self.time_steps: start = pulp.LpVariable(f'charge_start_{i}_{t}', lowBound=0, upBound=1) - candidate += start >= active[t] - (active[t - 1] if t else 0) + candidate += start >= active[t] - (active[t - 1] if t else charging_now(i)) starts.append(start) candidate.setObjective(-pulp.lpSum(starts)) diff --git a/tests/test_continuity.py b/tests/test_continuity.py index 3c1363810..15a983e94 100644 --- a/tests/test_continuity.py +++ b/tests/test_continuity.py @@ -28,11 +28,11 @@ def starts(charging: list[float]) -> int: return int(np.count_nonzero(active & ~np.r_[False, active[:-1]])) -def seed_fragmented(model: Optimizer, monkeypatch: pytest.MonkeyPatch) -> None: +def seed_fragmented(model: Optimizer, monkeypatch: pytest.MonkeyPatch, schedule: tuple[float, ...] = (0, 500, 0, 500, 0, 500)) -> None: original = model._probe_then_split def seeded(tmpdir: str, deadline: float | None) -> None: - for t, energy in enumerate([0, 500, 0, 500, 0, 500]): + for t, energy in enumerate(schedule): model.problem += model.variables['c'][0][t] == energy, f'seed_{t}' original(tmpdir, deadline) for t in model.time_steps: @@ -55,6 +55,33 @@ def test_equal_prices_prefer_one_session(monkeypatch: pytest.MonkeyPatch, probe_ assert pulp.value(model.cost_objective) == pytest.approx(-0.45, abs=1e-5) +@pytest.mark.parametrize('schedule', [(0, 500, 0, 500, 0, 500), (0, 500, 500, 500, 0, 0)]) +def test_running_session_is_not_interrupted(monkeypatch: pytest.MonkeyPatch, schedule: tuple[float, ...]): + model = build() + model.time_series.p_N = [0.0003] * 6 + model.batteries[0].c_initial = 1000 + seed_fragmented(model, monkeypatch, schedule) + + result = model.solve() + + charging = result['batteries'][0]['charging_power'] + assert charging[0] > 0 + assert starts(charging) == 1 + assert result['batteries'][0]['state_of_charge'][-1] == pytest.approx(1500, abs=0.1) + + +def test_running_session_still_yields_to_price(monkeypatch: pytest.MonkeyPatch): + model = build() + model.batteries[0].c_initial = 1000 + seed_fragmented(model, monkeypatch) + + result = model.solve() + + charging = result['batteries'][0]['charging_power'] + assert charging[0] == pytest.approx(0, abs=0.01) + assert starts(charging) == 1 + + def test_price_gaps_keep_interruptions(monkeypatch: pytest.MonkeyPatch): model = build() model.time_series.p_N = [0.001, 0.0003, 0.001, 0.0003, 0.001, 0.0003] @@ -140,6 +167,23 @@ def unexpected_solver(*args, **kwargs): model._solve_continuity(tmpdir, None) +def test_running_session_without_gap_skips_the_solver(monkeypatch: pytest.MonkeyPatch): + model = build() + model.time_series.p_N = [0.0003] * 6 + model.batteries[0].c_initial = 1000 + seed_fragmented(model, monkeypatch, (500, 500, 500, 0, 0, 0)) + with monkeypatch.context() as context: + context.setattr(Optimizer, '_solve_continuity', lambda *args: None) + model.solve() + + def unexpected_solver(*args, **kwargs): + pytest.fail('continuity should not invoke CBC for an uninterrupted session') + + monkeypatch.setattr(model, '_solver', unexpected_solver) + with TemporaryDirectory() as tmpdir: + model._solve_continuity(tmpdir, None) + + def fragmented_model(monkeypatch: pytest.MonkeyPatch) -> Optimizer: model = build() seed_fragmented(model, monkeypatch) diff --git a/tests/test_continuity_api.py b/tests/test_continuity_api.py index ca3187d96..ce4da4b43 100644 --- a/tests/test_continuity_api.py +++ b/tests/test_continuity_api.py @@ -32,6 +32,25 @@ def test_api_returns_continuous_equal_price_sessions(second_c_min: float | None) assert battery['state_of_charge'][-1] == pytest.approx(1500, abs=0.1) +def test_api_keeps_a_running_session_charging(): + model = build() + model.time_series.p_N = [0.0003] * 6 + model.batteries[0].c_initial = 1000 + request = { + 'batteries': [{key: value for key, value in asdict(model.batteries[0]).items() if value is not None}], + 'time_series': asdict(model.time_series), + 'eta_c': 1, + 'eta_d': 1, + } + + response = app.test_client().post('/optimize/charge-schedule', json=request) + + assert response.status_code == 200 + charging = response.get_json()['batteries'][0]['charging_power'] + assert charging[0] > 0 + assert starts(charging) == 1 + + @pytest.mark.parametrize('strategy', ['attenuate_demand_peaks', 'attenuate_feedin_peaks', 'attenuate_grid_peaks']) def test_each_grid_peak_is_preserved(monkeypatch: pytest.MonkeyPatch, strategy: str): model = build(strategy) From c8bccdad612b034e8c346c9e0c8a53d29dd36aa0 Mon Sep 17 00:00:00 2001 From: andig Date: Thu, 17 Sep 2026 10:55:06 +0200 Subject: [PATCH 2/2] refactor: report the running session as a boolean Only the on/off state decides a charge start, so c_active says that directly instead of making the model derive it from a power value it never uses. --- client/client.gen.go | 8 +++++--- openapi.yaml | 15 ++++++++------- src/optimizer/app.py | 4 ++-- src/optimizer/optimizer.py | 4 ++-- tests/test_continuity.py | 6 +++--- tests/test_continuity_api.py | 2 +- 6 files changed, 21 insertions(+), 18 deletions(-) diff --git a/client/client.gen.go b/client/client.gen.go index 7fb9d7f1b..bce40aa71 100644 --- a/client/client.gen.go +++ b/client/client.gen.go @@ -47,9 +47,11 @@ const ( // BatteryConfig defines model for BatteryConfig. type BatteryConfig struct { - // CInitial Charge power at the start of the time horizon in W. Greater than zero means the device is - // charging right now, so keeping it on costs no charge start and interrupting it does. - CInitial float32 `json:"c_initial,omitempty"` + // CActive Whether the device is charging at the start of the time horizon. + // - True: the device enters the horizon switched on, so keeping it on costs no charge + // start and interrupting it costs one. + // - False: (default) the device is idle and any charging starts a new session. + CActive bool `json:"c_active,omitempty"` // CMax Maximum charge power in W CMax float32 `json:"c_max"` diff --git a/openapi.yaml b/openapi.yaml index 5a3eb2bc7..61d12b131 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -241,14 +241,15 @@ components: minimum: 0 description: Maximum charge power in W example: 11000 - c_initial: - type: number - minimum: 0 - default: 0 + c_active: + type: boolean + default: false description: | - Charge power at the start of the time horizon in W. Greater than zero means the device is - charging right now, so keeping it on costs no charge start and interrupting it does. - example: 4140 + Whether the device is charging at the start of the time horizon. + - True: the device enters the horizon switched on, so keeping it on costs no charge + start and interrupting it costs one. + - False: (default) the device is idle and any charging starts a new session. + example: true d_max: type: number minimum: 0 diff --git a/src/optimizer/app.py b/src/optimizer/app.py index d6cbbd4cd..3d61ecba6 100644 --- a/src/optimizer/app.py +++ b/src/optimizer/app.py @@ -114,7 +114,7 @@ def handle_validation_error(error): 's_goal': fields.List(fields.Float, required=False, description='Goal state of charge at each time step (Wh)'), 'c_min': fields.Float(required=True, description='Minimum charge power (W)'), 'c_max': fields.Float(required=True, description='Maximum charge power (W)'), - 'c_initial': fields.Float(required=False, description='Charge power at the start of the horizon (W). Greater than zero means the device charges now.'), + 'c_active': fields.Boolean(required=False, description='Whether the device is charging at the start of the time horizon.'), 'd_max': fields.Float(required=True, description='Maximum discharge power (W)'), 'p_a': fields.Float(required=True, description='Monetary value per Wh at end of the optimization horizon'), 'c_priority': fields.Integer(required=False, description='Charging and discharging priority compared to other batteries. 2 = highest priority.') @@ -205,7 +205,7 @@ def post(self): s_goal=bat_data.get('s_goal'), c_min=bat_data['c_min'], c_max=bat_data['c_max'], - c_initial=bat_data.get('c_initial', 0.), + c_active=bat_data.get('c_active', False), d_max=bat_data['d_max'], p_a=bat_data['p_a'], c_priority=bat_data.get('c_priority', 0), diff --git a/src/optimizer/optimizer.py b/src/optimizer/optimizer.py index c862c8c51..f6157df0d 100644 --- a/src/optimizer/optimizer.py +++ b/src/optimizer/optimizer.py @@ -144,7 +144,7 @@ class BatteryConfig: p_demand: Optional[List[float]] = None # Minimum charge demand (Wh) s_goal: Optional[List[float]] = None # Goal state of charge (Wh) c_priority: int = 0 - c_initial: float = 0. # Charge power at the start of the horizon (W) + c_active: bool = False # Whether the device is charging at the start of the horizon @dataclass @@ -810,7 +810,7 @@ def _solve_continuity(self, tmpdir: str, deadline: float | None) -> None: eligible = [i for i, active in self.variables['z_c'].items() if active is not None] def charging_now(i: int) -> int: - return int(self.batteries[i].c_initial > 0) + return int(self.batteries[i].c_active) def count_starts() -> list[int]: counts = [] diff --git a/tests/test_continuity.py b/tests/test_continuity.py index 15a983e94..e28e246fe 100644 --- a/tests/test_continuity.py +++ b/tests/test_continuity.py @@ -59,7 +59,7 @@ def test_equal_prices_prefer_one_session(monkeypatch: pytest.MonkeyPatch, probe_ def test_running_session_is_not_interrupted(monkeypatch: pytest.MonkeyPatch, schedule: tuple[float, ...]): model = build() model.time_series.p_N = [0.0003] * 6 - model.batteries[0].c_initial = 1000 + model.batteries[0].c_active = True seed_fragmented(model, monkeypatch, schedule) result = model.solve() @@ -72,7 +72,7 @@ def test_running_session_is_not_interrupted(monkeypatch: pytest.MonkeyPatch, sch def test_running_session_still_yields_to_price(monkeypatch: pytest.MonkeyPatch): model = build() - model.batteries[0].c_initial = 1000 + model.batteries[0].c_active = True seed_fragmented(model, monkeypatch) result = model.solve() @@ -170,7 +170,7 @@ def unexpected_solver(*args, **kwargs): def test_running_session_without_gap_skips_the_solver(monkeypatch: pytest.MonkeyPatch): model = build() model.time_series.p_N = [0.0003] * 6 - model.batteries[0].c_initial = 1000 + model.batteries[0].c_active = True seed_fragmented(model, monkeypatch, (500, 500, 500, 0, 0, 0)) with monkeypatch.context() as context: context.setattr(Optimizer, '_solve_continuity', lambda *args: None) diff --git a/tests/test_continuity_api.py b/tests/test_continuity_api.py index ce4da4b43..30e65afaa 100644 --- a/tests/test_continuity_api.py +++ b/tests/test_continuity_api.py @@ -35,7 +35,7 @@ def test_api_returns_continuous_equal_price_sessions(second_c_min: float | None) def test_api_keeps_a_running_session_charging(): model = build() model.time_series.p_N = [0.0003] * 6 - model.batteries[0].c_initial = 1000 + model.batteries[0].c_active = True request = { 'batteries': [{key: value for key, value in asdict(model.batteries[0]).items() if value is not None}], 'time_series': asdict(model.time_series),