diff --git a/README.md b/README.md index a1ac2ebcb..0f28f5276 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,21 @@ 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 +## 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 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`. + ## 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/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/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. 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 7746e52ad..f6157df0d 100644 --- a/src/optimizer/optimizer.py +++ b/src/optimizer/optimizer.py @@ -1,6 +1,7 @@ import shutil import time from dataclasses import dataclass +from math import isfinite from tempfile import TemporaryDirectory from typing import Dict, List, Optional @@ -67,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' @@ -102,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. @@ -136,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 @@ -788,6 +797,85 @@ 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. + + 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_[bool(charging_now(i)), active[:-1]]))) + return counts + + before = count_starts() + # 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: + 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 + peak_values = {side: pulp.value(self.variables[f'p_{side}_peak']) for side in self.peak_sides} + for side in self.peak_sides: + candidate += self.variables[f'p_{side}_peak'] <= peak_values[side] + 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 charging_now(i)) + 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 self._is_integral()): + return + # 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: + 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. @@ -868,6 +956,7 @@ def solve(self) -> Dict: with TemporaryDirectory() as tmpdir: self._probe_then_split(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 new file mode 100644 index 000000000..e28e246fe --- /dev/null +++ b/tests/test_continuity.py @@ -0,0 +1,315 @@ +import time +from tempfile import TemporaryDirectory +from typing import Literal, assert_never + +import numpy as np +import pulp +import pytest + +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, 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(schedule): + 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) + + +@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] + 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: + 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) + with monkeypatch.context() as context: + context.setattr(Optimizer, '_solve_continuity', 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: + 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: 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 + + 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 + case _: + assert_never(outcome) + return pulp.LpStatusNotSolved + + monkeypatch.setattr(pulp.LpProblem, 'solve', failed_solve) + with TemporaryDirectory() as tmpdir: + 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 + + +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: + 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 + + +@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: + 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 + + +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) diff --git a/tests/test_continuity_api.py b/tests/test_continuity_api.py new file mode 100644 index 000000000..30e65afaa --- /dev/null +++ b/tests/test_continuity_api.py @@ -0,0 +1,70 @@ +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: + 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), + '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) + + +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) + 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, '_solve_continuity', 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'])