From 912cf57c08b4a75f8c0a6d853fe627cf44685206 Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 5 Sep 2026 15:26:51 +0200 Subject: [PATCH 1/9] feat: prefer fewer charging interruptions --- src/optimizer/continuity.py | 81 +++++++++++++++ src/optimizer/optimizer.py | 2 + tests/test_continuity.py | 202 ++++++++++++++++++++++++++++++++++++ 3 files changed, 285 insertions(+) create mode 100644 src/optimizer/continuity.py create mode 100644 tests/test_continuity.py diff --git a/src/optimizer/continuity.py b/src/optimizer/continuity.py new file mode 100644 index 000000000..25a4ea8d1 --- /dev/null +++ b/src/optimizer/continuity.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import time +from typing import TYPE_CHECKING + +import numpy as np +import pulp + +if TYPE_CHECKING: + from .optimizer import Optimizer + +TIME_LIMIT = 1.0 +TOLERANCE = 1e-5 + + +def minimize_interruptions(model: Optimizer, tmpdir: str, deadline: float | None) -> None: + """Prefer fewer charge starts without trading away economics or existing preferences.""" + if model.problem.sol_status not in (pulp.LpSolutionOptimal, pulp.LpSolutionIntegerFeasible) or not model._is_integral(): + return + + eligible = [i for i, active in model.variables['z_c'].items() if active is not None] + + def count_starts() -> list[int]: + counts = [] + for i in eligible: + active = np.array([pulp.value(v) for v in model.variables['c'][i]]) > TOLERANCE + counts.append(int(np.count_nonzero(active & ~np.r_[False, active[:-1]]))) + return counts + + before = count_starts() + if not any(count > 1 for count in before): + return + remaining = TIME_LIMIT if deadline is None else min(TIME_LIMIT, deadline - time.monotonic()) + if remaining <= 0: + return + + solution = {var: var.varValue for var in model.problem.variables()} + cost = pulp.value(model.cost_objective) + preference = pulp.LpAffineExpression(model.preference_objective) + # Normalize tiny preference coefficients so CBC's row tolerance cannot erase their bound. + scale = 1 / max((abs(value) for value in preference.values() if value), default=1) + preference *= scale + preferred = pulp.value(preference) + peaks = [model.variables[f'p_{side}_peak'] for side in model.peak_sides] + peak_values = [pulp.value(peak) for peak in peaks] + + # A shallow copy shares solution variables but leaves the reusable model's constraints intact. + candidate = model.problem.copy() + candidate += model.cost_objective >= cost - TOLERANCE + candidate += preference >= preferred - TOLERANCE + for peak, value in zip(peaks, peak_values): + candidate += peak <= value + TOLERANCE + starts = [] + for i in eligible: + active = model.variables['z_c'][i] + for t in model.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) + starts.append(start) + candidate.setObjective(-pulp.lpSum(starts)) + + improved = False + try: + if deadline is not None: + remaining = min(remaining, deadline - time.monotonic()) + if remaining <= 0: + return + candidate.solve(model._solver(tmpdir, timeLimit=remaining)) + improved = (candidate.sol_status in (pulp.LpSolutionOptimal, pulp.LpSolutionIntegerFeasible) + and candidate.valid(TOLERANCE) + and model._is_integral() + and pulp.value(model.cost_objective) >= cost - 2 * TOLERANCE + and pulp.value(preference) >= preferred - 2 * TOLERANCE + and all(pulp.value(peak) <= value + 2 * TOLERANCE for peak, value in zip(peaks, peak_values)) + and sum(count_starts()) < sum(before)) + except pulp.PulpSolverError: + return + finally: + if not improved: + for var, value in solution.items(): + var.varValue = value diff --git a/src/optimizer/optimizer.py b/src/optimizer/optimizer.py index 7746e52ad..08545cc1f 100644 --- a/src/optimizer/optimizer.py +++ b/src/optimizer/optimizer.py @@ -7,6 +7,7 @@ import numpy as np import pulp +from .continuity import minimize_interruptions from .settings import OptimizerSettings @@ -868,6 +869,7 @@ def solve(self) -> Dict: with TemporaryDirectory() as tmpdir: self._probe_then_split(tmpdir, deadline) + minimize_interruptions(self, tmpdir, deadline) # back to the total worth of the solution, neither stage objective on its own self.problem.setObjective((self.cost_objective + self.preference_objective) * self.objective_scale) diff --git a/tests/test_continuity.py b/tests/test_continuity.py new file mode 100644 index 000000000..f228ea624 --- /dev/null +++ b/tests/test_continuity.py @@ -0,0 +1,202 @@ +import time +from tempfile import TemporaryDirectory + +import numpy as np +import pulp +import pytest + +from optimizer.continuity import minimize_interruptions +from optimizer.optimizer import BatteryConfig, GridConfig, OptimizationStrategy, Optimizer, TimeSeriesData + + +def build(strategy: str = 'none') -> Optimizer: + return Optimizer( + strategy=OptimizationStrategy(charging_strategy=strategy, discharging_strategy='none'), + grid=GridConfig(p_max_imp=None, p_max_exp=None, prc_p_exc_imp=None), + batteries=[BatteryConfig(charge_from_grid=True, discharge_to_grid=False, + s_capacity=5000, s_min=0, s_max=5000, s_initial=0, + c_min=1000, c_max=2000, d_max=0, p_a=0, + s_goal=[0, 0, 0, 0, 0, 1500])], + time_series=TimeSeriesData(dt=[900] * 6, gt=[0] * 6, ft=[0] * 6, + p_N=[0.001] + [0.0003] * 5, p_E=[0] * 6), + eta_c=1, eta_d=1, + ) + + +def starts(charging: list[float]) -> int: + active = np.array(charging) > 0.01 + return int(np.count_nonzero(active & ~np.r_[False, active[:-1]])) + + +def seed_fragmented(model: Optimizer, monkeypatch: pytest.MonkeyPatch) -> 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]): + model.problem += model.variables['c'][0][t] == energy, f'seed_{t}' + original(tmpdir, deadline) + for t in model.time_steps: + del model.problem.constraints[f'seed_{t}'] + + monkeypatch.setattr(model, '_probe_then_split', seeded) + + +@pytest.mark.parametrize('probe_seconds', [None, 0]) +def test_equal_prices_prefer_one_session(monkeypatch: pytest.MonkeyPatch, probe_seconds: float | None): + model = build() + model.settings.probe_seconds = probe_seconds + seed_fragmented(model, monkeypatch) + + result = model.solve() + + assert result['status'] == 'Optimal' + assert starts(result['batteries'][0]['charging_power']) == 1 + assert result['batteries'][0]['state_of_charge'][-1] == pytest.approx(1500, abs=0.1) + assert pulp.value(model.cost_objective) == pytest.approx(-0.45, abs=1e-5) + + +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] + seed_fragmented(model, monkeypatch) + + result = model.solve() + + assert starts(result['batteries'][0]['charging_power']) == 3 + assert pulp.value(model.cost_objective) == pytest.approx(-0.45, abs=1e-5) + + +def test_grid_shaping_takes_priority(monkeypatch: pytest.MonkeyPatch): + model = build('attenuate_demand_peaks') + model.time_series.gt = [0, 0, 2000, 0, 2000, 0] + seed_fragmented(model, monkeypatch) + + result = model.solve() + + assert starts(result['batteries'][0]['charging_power']) == 3 + assert max(result['grid_import']) == pytest.approx(2000, abs=0.01) + + +def test_short_first_slot(monkeypatch: pytest.MonkeyPatch): + model = build() + model.time_series.dt[0] = 100 + seed_fragmented(model, monkeypatch) + + result = model.solve() + + assert starts(result['batteries'][0]['charging_power']) == 1 + assert result['batteries'][0]['charging_power'][0] == pytest.approx(0, abs=0.01) + + +def test_repeated_solve_does_not_keep_polishing_constraints(monkeypatch: pytest.MonkeyPatch): + model = build() + seed_fragmented(model, monkeypatch) + model.solve() + constraints = set(model.problem.constraints) + variables = {v.name for v in model.problem.variables()} + + result = model.solve() + + assert starts(result['batteries'][0]['charging_power']) == 1 + assert set(model.problem.constraints) == constraints + assert {v.name for v in model.problem.variables()} == variables + + +@pytest.mark.parametrize('price', [0, -0.0003]) +def test_nonpositive_prices(monkeypatch: pytest.MonkeyPatch, price: float): + model = build() + model.time_series.p_N = [0.001] + [price] * 5 + model.batteries[0].s_max = 1500 + seed_fragmented(model, monkeypatch) + + result = model.solve() + + assert starts(result['batteries'][0]['charging_power']) == 1 + assert pulp.value(model.cost_objective) == pytest.approx(-price * 1500, abs=2e-5) + + +def test_forced_demand_is_preserved(monkeypatch: pytest.MonkeyPatch): + model = build() + model.batteries[0].p_demand = [0, 500, 0, 500, 0, 500] + seed_fragmented(model, monkeypatch) + + result = model.solve() + + assert result['batteries'][0]['charging_power'] == pytest.approx([0, 500, 0, 500, 0, 500], abs=0.01) + + +@pytest.mark.parametrize('c_min', [0, 1000]) +def test_uninterrupted_or_unrestricted_batteries_skip_the_solver(monkeypatch: pytest.MonkeyPatch, c_min: float): + model = build() + model.batteries[0].c_min = c_min + model.batteries[0].s_goal = None + model.solve() + + def unexpected_solver(*args, **kwargs): + pytest.fail('continuity should not invoke CBC') + + monkeypatch.setattr(model, '_solver', unexpected_solver) + with TemporaryDirectory() as tmpdir: + minimize_interruptions(model, tmpdir, None) + + +def fragmented_model(monkeypatch: pytest.MonkeyPatch) -> Optimizer: + model = build() + seed_fragmented(model, monkeypatch) + with monkeypatch.context() as context: + context.setattr('optimizer.optimizer.minimize_interruptions', lambda *args: None) + model.solve() + return model + + +def test_expired_deadline_keeps_incumbent(monkeypatch: pytest.MonkeyPatch): + model = fragmented_model(monkeypatch) + solution = {var: var.varValue for var in model.problem.variables()} + + def unexpected_solver(*args, **kwargs): + pytest.fail('continuity should not invoke CBC after the deadline') + + monkeypatch.setattr(model, '_solver', unexpected_solver) + with TemporaryDirectory() as tmpdir: + minimize_interruptions(model, tmpdir, time.monotonic() - 1) + + assert {var: var.varValue for var in model.problem.variables()} == solution + + +@pytest.mark.parametrize('outcome', ['infeasible', 'fractional', 'error', 'invalid']) +def test_failed_polish_restores_incumbent(monkeypatch: pytest.MonkeyPatch, outcome: str): + model = fragmented_model(monkeypatch) + solution = {var: var.varValue for var in model.problem.variables()} + status = model.problem.status, model.problem.sol_status + + def failed_solve(candidate: pulp.LpProblem, *args, **kwargs): + for var in candidate.variables(): + var.varValue = 0.3 if var.cat == pulp.LpInteger else 0 + match outcome: + case 'error': + raise pulp.PulpSolverError('CBC unavailable') + case 'infeasible': + candidate.sol_status = pulp.LpSolutionInfeasible + case 'fractional': + candidate.sol_status = pulp.LpSolutionNoSolutionFound + case 'invalid': + candidate.sol_status = pulp.LpSolutionOptimal + return pulp.LpStatusNotSolved + + monkeypatch.setattr(pulp.LpProblem, 'solve', failed_solve) + with TemporaryDirectory() as tmpdir: + minimize_interruptions(model, tmpdir, None) + + assert {var: var.varValue for var in model.problem.variables()} == solution + assert (model.problem.status, model.problem.sol_status) == status + + +def test_polish_does_not_upgrade_feasible_status(monkeypatch: pytest.MonkeyPatch): + model = fragmented_model(monkeypatch) + model.problem.sol_status = pulp.LpSolutionIntegerFeasible + + with TemporaryDirectory() as tmpdir: + minimize_interruptions(model, tmpdir, None) + + assert starts([pulp.value(v) for v in model.variables['c'][0]]) == 1 + assert model.problem.sol_status == pulp.LpSolutionIntegerFeasible From 975191c0d6fb7669fc2c57539719b8560e1ccfe8 Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 5 Sep 2026 15:27:04 +0200 Subject: [PATCH 2/9] docs: explain the charging continuity preference --- README.md | 6 ++++++ docs/comparison_objective_terms.md | 7 ++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a1ac2ebcb..a7fcfe600 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,12 @@ The maximum is a single value out of the horizon, which leaves one gap: a load s Grid exchange over 24 hours without a strategy and with attenuate_grid_peaks, showing the import peak dropping from 11.5 kW to 3.6 kW +## Fewer charging interruptions + +For batteries with a positive minimum charge power (`c_min`), a final tie-break pass prefers fewer charging starts. It preserves the achieved economic objective, existing strategy preferences, and grid peaks within numerical tolerances. This is automatic and needs no additional API setting. + +The pass only runs when a battery has multiple charging sessions and gets at most one second of solver time within the remaining request budget. If it cannot find a valid improvement, the previous schedule is retained. Power may still vary within a session, and cheaper prices, charging demands, or grid shaping may still require interruptions. This is a preference, not a guarantee of one continuous session. + ## API `POST /optimize/charge-schedule` takes the whole problem as one JSON document and returns the schedule. `GET /optimize/health` is the liveness probe. Every field is documented in [`openapi.yaml`](openapi.yaml). diff --git a/docs/comparison_objective_terms.md b/docs/comparison_objective_terms.md index 8300a8656..56d3fef8c 100644 --- a/docs/comparison_objective_terms.md +++ b/docs/comparison_objective_terms.md @@ -38,4 +38,9 @@ then maximizes tier 3 over the schedules that keep that value, so the distance between the tiers no longer decides whether a preference is respected. The ranges listed above are what the second stage works on, and it normalizes them off its own largest coefficient before solving. - +- After those objectives are decided, a final pass minimizes charging starts for batteries with + `c_min > 0`. It bounds both achieved objectives and each leveled grid peak separately, so fewer + interruptions cannot compensate for worse economics, preferences, or peaks beyond numerical + tolerances. This pass adds no term to either earlier objective. It runs only for fragmented + schedules, uses at most one second of solver time within the remaining request budget, and + retains the incumbent unless a valid schedule has fewer actual starts. From 8c1d0d3bb6c547608e6227ff9c1af5e3a40f3d1c Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 5 Sep 2026 15:31:44 +0200 Subject: [PATCH 3/9] fix: skip continuity for incomplete incumbents --- src/optimizer/continuity.py | 9 +++++- tests/test_continuity.py | 15 ++++++++++ tests/test_continuity_api.py | 53 ++++++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 tests/test_continuity_api.py diff --git a/src/optimizer/continuity.py b/src/optimizer/continuity.py index 25a4ea8d1..c8b5eaa36 100644 --- a/src/optimizer/continuity.py +++ b/src/optimizer/continuity.py @@ -1,6 +1,7 @@ from __future__ import annotations import time +from math import isfinite from typing import TYPE_CHECKING import numpy as np @@ -13,9 +14,14 @@ TOLERANCE = 1e-5 +def _complete_solution(problem: pulp.LpProblem) -> bool: + return all(var.name == '__dummy' or (var.varValue is not None and isfinite(var.varValue)) for var in problem.variables()) + + def minimize_interruptions(model: Optimizer, tmpdir: str, deadline: float | None) -> None: """Prefer fewer charge starts without trading away economics or existing preferences.""" - if model.problem.sol_status not in (pulp.LpSolutionOptimal, pulp.LpSolutionIntegerFeasible) or not model._is_integral(): + if (model.problem.sol_status not in (pulp.LpSolutionOptimal, pulp.LpSolutionIntegerFeasible) + or not _complete_solution(model.problem) or not model._is_integral()): return eligible = [i for i, active in model.variables['z_c'].items() if active is not None] @@ -67,6 +73,7 @@ def count_starts() -> list[int]: return candidate.solve(model._solver(tmpdir, timeLimit=remaining)) improved = (candidate.sol_status in (pulp.LpSolutionOptimal, pulp.LpSolutionIntegerFeasible) + and _complete_solution(candidate) and candidate.valid(TOLERANCE) and model._is_integral() and pulp.value(model.cost_objective) >= cost - 2 * TOLERANCE diff --git a/tests/test_continuity.py b/tests/test_continuity.py index f228ea624..723418b0c 100644 --- a/tests/test_continuity.py +++ b/tests/test_continuity.py @@ -200,3 +200,18 @@ def test_polish_does_not_upgrade_feasible_status(monkeypatch: pytest.MonkeyPatch assert starts([pulp.value(v) for v in model.variables['c'][0]]) == 1 assert model.problem.sol_status == pulp.LpSolutionIntegerFeasible + + +@pytest.mark.parametrize('value', [None, float('nan'), float('inf')]) +def test_incomplete_incumbent_skips_polishing(monkeypatch: pytest.MonkeyPatch, value: float | None): + model = fragmented_model(monkeypatch) + model.variables['c'][0][1].varValue = value + + def unexpected_solver(*args, **kwargs): + pytest.fail('continuity should not invoke CBC without a complete incumbent') + + monkeypatch.setattr(model, '_solver', unexpected_solver) + with TemporaryDirectory() as tmpdir: + minimize_interruptions(model, tmpdir, None) + + assert model.variables['c'][0][1].varValue is value diff --git a/tests/test_continuity_api.py b/tests/test_continuity_api.py new file mode 100644 index 000000000..dfad7d3f5 --- /dev/null +++ b/tests/test_continuity_api.py @@ -0,0 +1,53 @@ +from copy import deepcopy +from dataclasses import asdict + +import numpy as np +import pytest +from test_continuity import build, seed_fragmented, starts + +from optimizer.app import app + + +@pytest.mark.parametrize('second_c_min', [None, 0, 1000]) +def test_api_returns_continuous_equal_price_sessions(second_c_min: float | None): + model = build() + if second_c_min is not None: + battery = deepcopy(model.batteries[0]) + battery.c_min = second_c_min + model.batteries.append(battery) + request = { + 'batteries': [{key: value for key, value in asdict(battery).items() if value is not None} for battery in model.batteries], + '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 + result = response.get_json() + assert result['status'] == 'Optimal' + assert result['objective_value'] == pytest.approx(-0.45 * len(model.batteries), abs=2e-5) + for config, battery in zip(model.batteries, result['batteries']): + if config.c_min > 0: + assert starts(battery['charging_power']) == 1 + assert battery['state_of_charge'][-1] == pytest.approx(1500, abs=0.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) + model.time_series.gt = [0, 0, 2000, 0, 0, 0] + model.time_series.ft = [0, 0, 0, 0, 2000, 0] + seed_fragmented(model, monkeypatch) + with monkeypatch.context() as context: + context.setattr('optimizer.optimizer.minimize_interruptions', lambda *args: None) + original = model.solve() + + result = model.solve() + + for side, key in [('imp', 'grid_import'), ('exp', 'grid_export')]: + if side in model.peak_sides: + assert max(result[key]) <= max(original[key]) + 0.01 + assert starts(result['batteries'][0]['charging_power']) <= starts(original['batteries'][0]['charging_power']) + assert np.isfinite(result['objective_value']) From e6a2c315d4bb1ab03d5f0d8583d1d03104c76826 Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 5 Sep 2026 17:40:49 +0200 Subject: [PATCH 4/9] refactor: align continuity with optimizer solve stages --- src/optimizer/continuity.py | 88 ------------------------------------ src/optimizer/optimizer.py | 75 +++++++++++++++++++++++++++++- tests/test_continuity.py | 55 ++++++++++++++++++---- tests/test_continuity_api.py | 10 ++-- 4 files changed, 124 insertions(+), 104 deletions(-) delete mode 100644 src/optimizer/continuity.py diff --git a/src/optimizer/continuity.py b/src/optimizer/continuity.py deleted file mode 100644 index c8b5eaa36..000000000 --- a/src/optimizer/continuity.py +++ /dev/null @@ -1,88 +0,0 @@ -from __future__ import annotations - -import time -from math import isfinite -from typing import TYPE_CHECKING - -import numpy as np -import pulp - -if TYPE_CHECKING: - from .optimizer import Optimizer - -TIME_LIMIT = 1.0 -TOLERANCE = 1e-5 - - -def _complete_solution(problem: pulp.LpProblem) -> bool: - return all(var.name == '__dummy' or (var.varValue is not None and isfinite(var.varValue)) for var in problem.variables()) - - -def minimize_interruptions(model: Optimizer, tmpdir: str, deadline: float | None) -> None: - """Prefer fewer charge starts without trading away economics or existing preferences.""" - if (model.problem.sol_status not in (pulp.LpSolutionOptimal, pulp.LpSolutionIntegerFeasible) - or not _complete_solution(model.problem) or not model._is_integral()): - return - - eligible = [i for i, active in model.variables['z_c'].items() if active is not None] - - def count_starts() -> list[int]: - counts = [] - for i in eligible: - active = np.array([pulp.value(v) for v in model.variables['c'][i]]) > TOLERANCE - counts.append(int(np.count_nonzero(active & ~np.r_[False, active[:-1]]))) - return counts - - before = count_starts() - if not any(count > 1 for count in before): - return - remaining = TIME_LIMIT if deadline is None else min(TIME_LIMIT, deadline - time.monotonic()) - if remaining <= 0: - return - - solution = {var: var.varValue for var in model.problem.variables()} - cost = pulp.value(model.cost_objective) - preference = pulp.LpAffineExpression(model.preference_objective) - # Normalize tiny preference coefficients so CBC's row tolerance cannot erase their bound. - scale = 1 / max((abs(value) for value in preference.values() if value), default=1) - preference *= scale - preferred = pulp.value(preference) - peaks = [model.variables[f'p_{side}_peak'] for side in model.peak_sides] - peak_values = [pulp.value(peak) for peak in peaks] - - # A shallow copy shares solution variables but leaves the reusable model's constraints intact. - candidate = model.problem.copy() - candidate += model.cost_objective >= cost - TOLERANCE - candidate += preference >= preferred - TOLERANCE - for peak, value in zip(peaks, peak_values): - candidate += peak <= value + TOLERANCE - starts = [] - for i in eligible: - active = model.variables['z_c'][i] - for t in model.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) - starts.append(start) - candidate.setObjective(-pulp.lpSum(starts)) - - improved = False - try: - if deadline is not None: - remaining = min(remaining, deadline - time.monotonic()) - if remaining <= 0: - return - candidate.solve(model._solver(tmpdir, timeLimit=remaining)) - improved = (candidate.sol_status in (pulp.LpSolutionOptimal, pulp.LpSolutionIntegerFeasible) - and _complete_solution(candidate) - and candidate.valid(TOLERANCE) - and model._is_integral() - and pulp.value(model.cost_objective) >= cost - 2 * TOLERANCE - and pulp.value(preference) >= preferred - 2 * TOLERANCE - and all(pulp.value(peak) <= value + 2 * TOLERANCE for peak, value in zip(peaks, peak_values)) - and sum(count_starts()) < sum(before)) - except pulp.PulpSolverError: - return - finally: - if not improved: - for var, value in solution.items(): - var.varValue = value diff --git a/src/optimizer/optimizer.py b/src/optimizer/optimizer.py index 08545cc1f..a99c47f55 100644 --- a/src/optimizer/optimizer.py +++ b/src/optimizer/optimizer.py @@ -1,13 +1,13 @@ import shutil import time from dataclasses import dataclass +from math import isfinite from tempfile import TemporaryDirectory from typing import Dict, List, Optional import numpy as np import pulp -from .continuity import minimize_interruptions from .settings import OptimizerSettings @@ -68,6 +68,10 @@ def objective_scale(objective) -> float: return OBJECTIVE_TARGET / max(coefficients) +def _complete_solution(problem: pulp.LpProblem) -> bool: + return all(var.name == '__dummy' or (var.varValue is not None and isfinite(var.varValue)) for var in problem.variables()) + + # name of the constraint the second stage adds to keep the money the first stage found COST_BOUND = 'cost_bound' @@ -103,6 +107,9 @@ def objective_scale(objective) -> float: # request that is hard on money still gets the money right and only loses part of the tie break PREFERENCE_TIME_SHARE = 0.25 +CONTINUITY_TIME_LIMIT = 1.0 +CONTINUITY_TOLERANCE = 1e-5 + # a cbc on PATH is preferred over the one pulp bundles, which is 2.10.3 built Dec 2019 and gets a # MIP start wrong on this model, see _solve_preferences. None falls back to the bundled binary, so # a checkout without cbc installed still runs. The Dockerfile installs one. @@ -789,6 +796,70 @@ def _solve_preferences(self, tmpdir, deadline) -> None: var.varValue = value 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.""" + 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 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]]))) + return counts + + before = count_starts() + if not any(count > 1 for count in before): + return + remaining = CONTINUITY_TIME_LIMIT if deadline is None else min(CONTINUITY_TIME_LIMIT, deadline - time.monotonic()) + if remaining <= 0: + return + + solution = {var: var.varValue for var in self.problem.variables()} + cost = pulp.value(self.cost_objective) + preference = pulp.LpAffineExpression(self.preference_objective) + # Normalize tiny preference coefficients so CBC's row tolerance cannot erase their bound. + scale = 1 / max((abs(value) for value in preference.values() if value), default=1) + preference *= scale + preferred = pulp.value(preference) + + # A shallow copy shares solution variables but leaves the reusable model's constraints intact. + candidate = self.problem.copy() + candidate += self.cost_objective >= cost - CONTINUITY_TOLERANCE + candidate += preference >= preferred - CONTINUITY_TOLERANCE + for side in self.peak_sides: + peak = self.variables[f'p_{side}_peak'] + candidate += peak <= pulp.value(peak) + CONTINUITY_TOLERANCE + starts = [] + for i in eligible: + 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) + starts.append(start) + candidate.setObjective(-pulp.lpSum(starts)) + + improved = False + try: + if deadline is not None: + remaining = min(remaining, deadline - time.monotonic()) + if remaining <= 0: + return + candidate.solve(self._solver(tmpdir, timeLimit=remaining)) + if (candidate.sol_status not in (pulp.LpSolutionOptimal, pulp.LpSolutionIntegerFeasible) + or not _complete_solution(candidate) or not candidate.valid(CONTINUITY_TOLERANCE)): + return + improved = sum(count_starts()) < sum(before) + except pulp.PulpSolverError: + return + finally: + if not improved: + for var, value in solution.items(): + var.varValue = value + def _probe_then_split(self, tmpdir, deadline) -> None: """ Solve the whole objective if it can be proved quickly, otherwise fall back to the split. @@ -869,7 +940,7 @@ def solve(self) -> Dict: with TemporaryDirectory() as tmpdir: self._probe_then_split(tmpdir, deadline) - minimize_interruptions(self, tmpdir, deadline) + self._solve_continuity(tmpdir, deadline) # back to the total worth of the solution, neither stage objective on its own self.problem.setObjective((self.cost_objective + self.preference_objective) * self.objective_scale) diff --git a/tests/test_continuity.py b/tests/test_continuity.py index 723418b0c..7d959efe6 100644 --- a/tests/test_continuity.py +++ b/tests/test_continuity.py @@ -1,11 +1,11 @@ import time from tempfile import TemporaryDirectory +from typing import Literal, assert_never import numpy as np import pulp import pytest -from optimizer.continuity import minimize_interruptions from optimizer.optimizer import BatteryConfig, GridConfig, OptimizationStrategy, Optimizer, TimeSeriesData @@ -137,14 +137,14 @@ def unexpected_solver(*args, **kwargs): monkeypatch.setattr(model, '_solver', unexpected_solver) with TemporaryDirectory() as tmpdir: - minimize_interruptions(model, tmpdir, None) + model._solve_continuity(tmpdir, None) def fragmented_model(monkeypatch: pytest.MonkeyPatch) -> Optimizer: model = build() seed_fragmented(model, monkeypatch) with monkeypatch.context() as context: - context.setattr('optimizer.optimizer.minimize_interruptions', lambda *args: None) + context.setattr(Optimizer, '_solve_continuity', lambda *args: None) model.solve() return model @@ -158,13 +158,13 @@ def unexpected_solver(*args, **kwargs): monkeypatch.setattr(model, '_solver', unexpected_solver) with TemporaryDirectory() as tmpdir: - minimize_interruptions(model, tmpdir, time.monotonic() - 1) + model._solve_continuity(tmpdir, time.monotonic() - 1) assert {var: var.varValue for var in model.problem.variables()} == solution @pytest.mark.parametrize('outcome', ['infeasible', 'fractional', 'error', 'invalid']) -def test_failed_polish_restores_incumbent(monkeypatch: pytest.MonkeyPatch, outcome: str): +def test_failed_polish_restores_incumbent(monkeypatch: pytest.MonkeyPatch, outcome: Literal['infeasible', 'fractional', 'error', 'invalid']): model = fragmented_model(monkeypatch) solution = {var: var.varValue for var in model.problem.variables()} status = model.problem.status, model.problem.sol_status @@ -181,11 +181,13 @@ def failed_solve(candidate: pulp.LpProblem, *args, **kwargs): candidate.sol_status = pulp.LpSolutionNoSolutionFound case 'invalid': candidate.sol_status = pulp.LpSolutionOptimal + case _: + assert_never(outcome) return pulp.LpStatusNotSolved monkeypatch.setattr(pulp.LpProblem, 'solve', failed_solve) with TemporaryDirectory() as tmpdir: - minimize_interruptions(model, tmpdir, None) + model._solve_continuity(tmpdir, None) assert {var: var.varValue for var in model.problem.variables()} == solution assert (model.problem.status, model.problem.sol_status) == status @@ -196,7 +198,7 @@ def test_polish_does_not_upgrade_feasible_status(monkeypatch: pytest.MonkeyPatch model.problem.sol_status = pulp.LpSolutionIntegerFeasible with TemporaryDirectory() as tmpdir: - minimize_interruptions(model, tmpdir, None) + model._solve_continuity(tmpdir, None) assert starts([pulp.value(v) for v in model.variables['c'][0]]) == 1 assert model.problem.sol_status == pulp.LpSolutionIntegerFeasible @@ -212,6 +214,43 @@ def unexpected_solver(*args, **kwargs): monkeypatch.setattr(model, '_solver', unexpected_solver) with TemporaryDirectory() as tmpdir: - minimize_interruptions(model, tmpdir, None) + model._solve_continuity(tmpdir, None) assert model.variables['c'][0][1].varValue is value + + +@pytest.mark.parametrize('bound', ['cost', 'preference', 'peak']) +def test_invalid_solver_result_cannot_bypass_bounds(monkeypatch: pytest.MonkeyPatch, bound: str): + model = build('attenuate_demand_peaks') + if bound == 'cost': + model.time_series.p_N = [0.001, 0.0003, 0.001, 0.0003, 0.001, 0.0003] + if bound == 'peak': + model.time_series.gt = [0, 0, 2000, 0, 2000, 0] + seed_fragmented(model, monkeypatch) + with monkeypatch.context() as context: + context.setattr(Optimizer, '_solve_continuity', lambda *args: None) + model.solve() + model.preference_objective = model.variables['c'][0][5] if bound == 'preference' else 0 + if bound != 'peak': + model.variables['p_imp_peak'].varValue = 10000 + solution = {var: var.varValue for var in model.problem.variables()} + + def forged_result(candidate: pulp.LpProblem, *args, **kwargs): + for var in candidate.variables(): + var.varValue = 0 + energy = [0, 500, 500, 500, 0, 0] + for t, charge in enumerate(energy): + model.variables['c'][0][t].varValue = charge + model.variables['s'][0][t].varValue = sum(energy[:t + 1]) + model.variables['n'][t].varValue = charge + model.time_series.gt[t] + model.variables['z_c'][0][t].varValue = int(charge > 0) + candidate.variablesDict()['charge_start_0_1'].varValue = 1 + model.variables['p_imp_peak'].varValue = max(pulp.value(v) for v in model.variables['n']) * 4 + candidate.sol_status = pulp.LpSolutionOptimal + return pulp.LpStatusOptimal + + monkeypatch.setattr(pulp.LpProblem, 'solve', forged_result) + with TemporaryDirectory() as tmpdir: + model._solve_continuity(tmpdir, None) + + assert {var: var.varValue for var in model.problem.variables()} == solution diff --git a/tests/test_continuity_api.py b/tests/test_continuity_api.py index dfad7d3f5..ca3187d96 100644 --- a/tests/test_continuity_api.py +++ b/tests/test_continuity_api.py @@ -1,20 +1,18 @@ -from copy import deepcopy -from dataclasses import asdict +from dataclasses import asdict, replace import numpy as np import pytest from test_continuity import build, seed_fragmented, starts from optimizer.app import app +from optimizer.optimizer import Optimizer @pytest.mark.parametrize('second_c_min', [None, 0, 1000]) def test_api_returns_continuous_equal_price_sessions(second_c_min: float | None): model = build() if second_c_min is not None: - battery = deepcopy(model.batteries[0]) - battery.c_min = second_c_min - model.batteries.append(battery) + model.batteries.append(replace(model.batteries[0], c_min=second_c_min)) request = { 'batteries': [{key: value for key, value in asdict(battery).items() if value is not None} for battery in model.batteries], 'time_series': asdict(model.time_series), @@ -41,7 +39,7 @@ def test_each_grid_peak_is_preserved(monkeypatch: pytest.MonkeyPatch, strategy: model.time_series.ft = [0, 0, 0, 0, 2000, 0] seed_fragmented(model, monkeypatch) with monkeypatch.context() as context: - context.setattr('optimizer.optimizer.minimize_interruptions', lambda *args: None) + context.setattr(Optimizer, '_solve_continuity', lambda *args: None) original = model.solve() result = model.solve() From 8333607c038dbc9c475e9274304d263da301c641 Mon Sep 17 00:00:00 2001 From: andig Date: Thu, 10 Sep 2026 09:19:51 +0200 Subject: [PATCH 5/9] fix: stop the continuity gate rejecting every candidate with a large SOC The continuity stage validated its candidate with candidate.valid(1e-5), which re-evaluates every model row from the values CBC wrote to its solution file. CBC prints eight significant digits, so a 40 kWh SOC comes back rounded to 1e-3 and the balance rows miss the tolerance by up to 8e-4. The candidate was discarded on every request with a sizeable EV battery, silently, and the schedule kept its interruptions. Measured on the request from #146: the candidate found one charge start at identical cost and was thrown away because 79 rows were off. CBC already held the model rows within its own tolerance. Check only what this stage adds, the cost, preference and peak bounds, and the integrality of what came back. A test with a 40 kWh battery pins it; the existing cases stay below 10 kWh, where eight digits are still enough. Co-Authored-By: Claude Fable 5.1 --- src/optimizer/optimizer.py | 15 +++++++++++---- tests/test_continuity.py | 15 +++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/optimizer/optimizer.py b/src/optimizer/optimizer.py index a99c47f55..cf3fa481c 100644 --- a/src/optimizer/optimizer.py +++ b/src/optimizer/optimizer.py @@ -830,9 +830,9 @@ def count_starts() -> list[int]: candidate = self.problem.copy() candidate += self.cost_objective >= cost - CONTINUITY_TOLERANCE candidate += preference >= preferred - CONTINUITY_TOLERANCE + peak_values = {side: pulp.value(self.variables[f'p_{side}_peak']) for side in self.peak_sides} for side in self.peak_sides: - peak = self.variables[f'p_{side}_peak'] - candidate += peak <= pulp.value(peak) + CONTINUITY_TOLERANCE + candidate += self.variables[f'p_{side}_peak'] <= peak_values[side] + CONTINUITY_TOLERANCE starts = [] for i in eligible: active = self.variables['z_c'][i] @@ -850,9 +850,16 @@ def count_starts() -> list[int]: return candidate.solve(self._solver(tmpdir, timeLimit=remaining)) if (candidate.sol_status not in (pulp.LpSolutionOptimal, pulp.LpSolutionIntegerFeasible) - or not _complete_solution(candidate) or not candidate.valid(CONTINUITY_TOLERANCE)): + or not _complete_solution(candidate) or not self._is_integral()): return - improved = sum(count_starts()) < sum(before) + # CBC writes its solution with 8 significant digits, so a balance row over a 40 kWh + # SOC comes back off by up to 1e-3 and candidate.valid(1e-5) rejects every candidate + # (#146). CBC already held the model rows; only the bounds added here need checking. + improved = (pulp.value(self.cost_objective) >= cost - 2 * CONTINUITY_TOLERANCE + and pulp.value(preference) >= preferred - 2 * CONTINUITY_TOLERANCE + and all(pulp.value(self.variables[f'p_{side}_peak']) <= peak_values[side] + 2 * CONTINUITY_TOLERANCE + for side in self.peak_sides) + and sum(count_starts()) < sum(before)) except pulp.PulpSolverError: return finally: diff --git a/tests/test_continuity.py b/tests/test_continuity.py index 7d959efe6..3c1363810 100644 --- a/tests/test_continuity.py +++ b/tests/test_continuity.py @@ -254,3 +254,18 @@ def forged_result(candidate: pulp.LpProblem, *args, **kwargs): model._solve_continuity(tmpdir, None) assert {var: var.varValue for var in model.problem.variables()} == solution + + +def test_large_soc_survives_solution_precision(monkeypatch: pytest.MonkeyPatch): + # CBC writes eight significant digits, so a 40 kWh SOC comes back rounded to 1e-3 and every + # balance row is off by more than the gate tolerance. The candidate must still be kept (#146). + model = build() + model.batteries[0].s_capacity = model.batteries[0].s_max = 45000 + model.batteries[0].s_initial = 40000.0123 + model.batteries[0].s_goal = [0, 0, 0, 0, 0, 41500.0123] + seed_fragmented(model, monkeypatch) + + result = model.solve() + + assert starts(result['batteries'][0]['charging_power']) == 1 + assert pulp.value(model.cost_objective) == pytest.approx(-0.45, abs=1e-5) From d96d94a63a5360a66aacc0549d9f9d2e688a5997 Mon Sep 17 00:00:00 2001 From: andig Date: Thu, 10 Sep 2026 12:01:05 +0200 Subject: [PATCH 6/9] docs: table the solve stages, what each does and what bounds it Five solver runs share one clock and the constants that size them sit across three modules. One table in the README names each stage, when it runs, and the setting or constant that bounds it, next to the log fields that report it. Co-Authored-By: Claude Fable 5.1 --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index a7fcfe600..5c660ad38 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,21 @@ For batteries with a positive minimum charge power (`c_min`), a final tie-break The pass only runs when a battery has multiple charging sessions and gets at most one second of solver time within the remaining request budget. If it cannot find a valid improvement, the previous schedule is retained. Power may still vary within a session, and cheaper prices, charging demands, or grid shaping may still require interruptions. This is a preference, not a guarantee of one continuous session. +## How a request is solved + +One request is up to five solver runs under one wall clock, `OPTIMIZER_TIME_LIMIT` (10 s in production). Each stage keeps what the previous one found unless it can improve on it without spending money. The request log carries where the clock went (`stages`), which path was taken (`path`), and what the tie break (`preferences`) and the continuity pass (`continuity`) did. + +| Stage | Task | Runs when | Parameters | +|---|---|---|---| +| `build` | Build the MILP and scale its objective so the largest coefficient sits at `OBJECTIVE_TARGET`. | Always. | `OBJECTIVE_TARGET` 1e6 | +| `probe` | Solve cost and preferences together. Proven optimal means the tie is decided in one solve: path `joint`, nothing else runs on the money. | Unless `OPTIMIZER_PROBE_SECONDS` is 0. | `OPTIMIZER_PROBE_SECONDS`, default `PROBE_SHARE` 0.2 of the limit | +| `cost` | Money only, stopped on an absolute gap. Holds back a slice of the clock for the tie break instead of taking whatever is left. | The probe did not prove its answer: path `split`. | `OPTIMIZER_GAP_ABS` 0.01 currency, `PREFERENCE_TIME_SHARE` 0.25 of the limit reserved | +| `tie_break`, LP | Pin the binaries the cost stage chose and move only the continuous variables, under a bound that keeps the cost found. Milliseconds, so it runs whatever the clock says. If CBC calls the bound infeasible the slack is widened tenfold per retry. | Path `split` and a strategy is configured. | `OPTIMIZER_PREFERENCE_BUDGET` 0, `COST_BOUND_SLACK` 1e-5 up to `COST_BOUND_SLACK_CEILING` 1e-2, `COST_BOUND_TOLERANCE` 1e-4, `LP_PREFERENCE_TIME_LIMIT` 1 s | +| `tie_break`, MILP | Search the whole model under the same bound to beat the LP. Whichever is ahead is returned. | After the LP, clock permitting. | The reserved slice, capped at `MILP_PREFERENCE_TIME_LIMIT` 2.5 s; uncapped without a time limit | +| `continuity` | Fewest charge starts for batteries with `c_min > 0`, bounded by the cost, the preference value and each levelled grid peak already reached. | A battery has more than one charging session, and the solve so far took less than the stage may spend. | `CONTINUITY_TIME_LIMIT` 1 s, `CONTINUITY_TOLERANCE` 1e-5 | + +A schedule the solver stopped on at the limit is reported as `Feasible` rather than `Optimal`. A solve that comes back off the integers is refused and reported as `Not Solved`. + ## API `POST /optimize/charge-schedule` takes the whole problem as one JSON document and returns the schedule. `GET /optimize/health` is the liveness probe. Every field is documented in [`openapi.yaml`](openapi.yaml). From e016fe75f5b5d8770bf5e613eafa6bfb04b0b9b0 Mon Sep 17 00:00:00 2001 From: andig Date: Thu, 10 Sep 2026 12:03:56 +0200 Subject: [PATCH 7/9] docs: fold the continuity section into the stage table The table already says when the pass runs and what bounds it. The one sentence the section added, that this is a preference and not a guarantee, moves into its row. Co-Authored-By: Claude Fable 5.1 --- README.md | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/README.md b/README.md index 5c660ad38..0f28f5276 100644 --- a/README.md +++ b/README.md @@ -46,12 +46,6 @@ The maximum is a single value out of the horizon, which leaves one gap: a load s Grid exchange over 24 hours without a strategy and with attenuate_grid_peaks, showing the import peak dropping from 11.5 kW to 3.6 kW -## Fewer charging interruptions - -For batteries with a positive minimum charge power (`c_min`), a final tie-break pass prefers fewer charging starts. It preserves the achieved economic objective, existing strategy preferences, and grid peaks within numerical tolerances. This is automatic and needs no additional API setting. - -The pass only runs when a battery has multiple charging sessions and gets at most one second of solver time within the remaining request budget. If it cannot find a valid improvement, the previous schedule is retained. Power may still vary within a session, and cheaper prices, charging demands, or grid shaping may still require interruptions. This is a preference, not a guarantee of one continuous session. - ## How a request is solved One request is up to five solver runs under one wall clock, `OPTIMIZER_TIME_LIMIT` (10 s in production). Each stage keeps what the previous one found unless it can improve on it without spending money. The request log carries where the clock went (`stages`), which path was taken (`path`), and what the tie break (`preferences`) and the continuity pass (`continuity`) did. @@ -63,7 +57,7 @@ One request is up to five solver runs under one wall clock, `OPTIMIZER_TIME_LIMI | `cost` | Money only, stopped on an absolute gap. Holds back a slice of the clock for the tie break instead of taking whatever is left. | The probe did not prove its answer: path `split`. | `OPTIMIZER_GAP_ABS` 0.01 currency, `PREFERENCE_TIME_SHARE` 0.25 of the limit reserved | | `tie_break`, LP | Pin the binaries the cost stage chose and move only the continuous variables, under a bound that keeps the cost found. Milliseconds, so it runs whatever the clock says. If CBC calls the bound infeasible the slack is widened tenfold per retry. | Path `split` and a strategy is configured. | `OPTIMIZER_PREFERENCE_BUDGET` 0, `COST_BOUND_SLACK` 1e-5 up to `COST_BOUND_SLACK_CEILING` 1e-2, `COST_BOUND_TOLERANCE` 1e-4, `LP_PREFERENCE_TIME_LIMIT` 1 s | | `tie_break`, MILP | Search the whole model under the same bound to beat the LP. Whichever is ahead is returned. | After the LP, clock permitting. | The reserved slice, capped at `MILP_PREFERENCE_TIME_LIMIT` 2.5 s; uncapped without a time limit | -| `continuity` | Fewest charge starts for batteries with `c_min > 0`, bounded by the cost, the preference value and each levelled grid peak already reached. | A battery has more than one charging session, and the solve so far took less than the stage may spend. | `CONTINUITY_TIME_LIMIT` 1 s, `CONTINUITY_TOLERANCE` 1e-5 | +| `continuity` | Fewest charge starts for batteries with `c_min > 0`, bounded by the cost, the preference value and each levelled grid peak already reached. A preference, not a guarantee: prices, charge demands and grid shaping still win, and power may vary within a session. | A battery has more than one charging session, and the solve so far took less than the stage may spend. | `CONTINUITY_TIME_LIMIT` 1 s, `CONTINUITY_TOLERANCE` 1e-5 | A schedule the solver stopped on at the limit is reported as `Feasible` rather than `Optimal`. A solve that comes back off the integers is refused and reported as `Not Solved`. From 343eb40fb13e00655e30cf64e48a7f663b828d55 Mon Sep 17 00:00:00 2001 From: andig Date: Thu, 17 Sep 2026 10:54:05 +0200 Subject: [PATCH 8/9] 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 9/9] 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),