diff --git a/client/client.gen.go b/client/client.gen.go index b4fc7a356..bce40aa71 100644 --- a/client/client.gen.go +++ b/client/client.gen.go @@ -47,6 +47,12 @@ const ( // BatteryConfig defines model for BatteryConfig. type BatteryConfig struct { + // 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 43c58e788..61d12b131 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -240,7 +240,16 @@ components: type: number minimum: 0 description: Maximum charge power in W - example: 11000 + example: 11000 + c_active: + type: boolean + default: false + description: | + 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 a355b2d1c..3d61ecba6 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_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.') @@ -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_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 cf3fa481c..f6157df0d 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_active: bool = False # Whether the device is charging at the start of the horizon @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_active) + 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..e28e246fe 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_active = True + 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_active = True + 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_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) + 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..30e65afaa 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_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), + '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)