diff --git a/src/optimizer/app.py b/src/optimizer/app.py index a355b2d1c..1438ab88c 100644 --- a/src/optimizer/app.py +++ b/src/optimizer/app.py @@ -252,7 +252,23 @@ def post(self): started = time.perf_counter() result = optimizer.solve() - dump_slow_request(data, time.perf_counter() - started) + elapsed = time.perf_counter() - started + + # one JSON line per request, so Log Analytics can attribute the response time to the + # solve stages. The access log only carries the total. + # evcc stamps its version on every request as evcc/, so a change in the + # solve mix can be read against the release that sent it + print(json.dumps({"solve": { + "client": request.headers.get('User-Agent'), + "elapsed": round(elapsed, 3), + "stages": optimizer.stage_seconds, + "path": optimizer.solve_path, + "preferences": optimizer.preference_stage, + "status": result.get('status'), + "steps": optimizer.T, + }}), flush=True) + + dump_slow_request(data, elapsed) return result except Exception as e: diff --git a/src/optimizer/optimizer.py b/src/optimizer/optimizer.py index 7746e52ad..44c4f0bfe 100644 --- a/src/optimizer/optimizer.py +++ b/src/optimizer/optimizer.py @@ -1,5 +1,6 @@ import shutil import time +from contextlib import contextmanager from dataclasses import dataclass from tempfile import TemporaryDirectory from typing import Dict, List, Optional @@ -98,9 +99,26 @@ def objective_scale(objective) -> float: # p95 on a joint solve lands near 1.5 s, so a fifth of a 10 s limit clears the ordinary traffic. PROBE_SHARE = 0.2 -# share of OPTIMIZER_TIME_LIMIT the tie break stage may use. The cost stage keeps the rest, so a -# request that is hard on money still gets the money right and only loses part of the tie break -PREFERENCE_TIME_SHARE = 0.25 +# share of OPTIMIZER_TIME_LIMIT reserved for the tie break stage, and the cap on what it spends. +# The cost stage is held to the rest, so a request that is hard on money still gets the money right +# and only loses part of the tie break. +# +# Reserved, not granted from what is left over. The cost stage is anytime branch and bound: on a +# request it cannot close it consumes every second it is offered, which is exactly the shape of +# request the tie break matters on. That left the tie break with 'no time' and the strategy silently +# doing nothing, visible only as a 'Feasible' status. Measured over the stored cases at three time +# limits, holding this back costs no money and no latency. +# +# 0.4 rather than 0.25 because a reserve too small to seat the stage is worse than none: it is idle +# time the cost stage could have used. The MILP tie break needs 1.6 s on a 245 step model, and 0.25 +# of a 5 s limit is 1.25 s. +PREFERENCE_TIME_SHARE = 0.4 + +# clock the pinned LP tie break may use. It is a linear program over a schedule that is already +# feasible, worst measured 0.165 s over the stored cases, so this is a guard against a pathological +# model rather than a budget. It runs even once the deadline is gone: without it a request that +# spent its whole clock on the money gets no strategy at all. +LP_PREFERENCE_TIME_LIMIT = 1.0 # 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 @@ -185,6 +203,9 @@ def __init__(self, strategy: OptimizationStrategy, grid: GridConfig, batteries: self.cost_stage_value = None # 'joint' when the probe proved the whole objective, 'split' when it fell back self.solve_path = None + # wall clock per stage of the last solve(), keyed build/probe/cost/tie_break. What the + # response time was spent on, where the access log only carries the total. + self.stage_seconds = {} # dictionary of optimizer variables self.variables = {} @@ -712,10 +733,51 @@ def _solver(self, tmpdir, **options): solver.tmpDir = tmpdir return solver + @contextmanager + def _timed(self, stage): + """Add the wall clock of the enclosed block to stage_seconds. + + Accumulating rather than assigning, because the tie break is two solves under one name. + """ + started = time.monotonic() + try: + yield + finally: + self.stage_seconds[stage] = round( + self.stage_seconds.get(stage, 0.) + time.monotonic() - started, 4) + + def _pin_integers(self): + """Freeze every integer variable on the value it currently holds, undo data returned. + + Lets the tie break ask a much cheaper question than the model it was handed: keep the + on/off pattern the cost stage settled on and move only the continuous variables. That is + a linear program, and it is the only form of this stage that reliably fits the clock. + """ + pinned = [] + for var in self.problem.variables(): + if var.cat == pulp.LpInteger and var.varValue is not None: + pinned.append((var, var.lowBound, var.upBound)) + var.lowBound = var.upBound = round(var.varValue) + var.cat = pulp.LpContinuous + return pinned + + @staticmethod + def _unpin_integers(pinned): + """Put back what _pin_integers changed, whatever the solve in between did.""" + for var, low, up in pinned: + var.lowBound, var.upBound, var.cat = low, up, pulp.LpInteger + def _solve_preferences(self, tmpdir, deadline) -> None: """ Second stage: maximize the preferences over the schedules the first stage left equally priced. The first stage solution stays as it is if this cannot improve on it. + + Two solves, cheapest first. The LP pins the binaries the cost stage chose and moves only + the continuous variables, which costs milliseconds and therefore always runs. The MILP then + gets to beat that on the reserved slice. Whichever is ahead is what the caller gets, so a + request too big to decide the tie properly still gets the part of it that comes for free. + Measured on a 245 step levelling request: the cost stage alone leaves 1417 W import and + 3609 W export, the LP reaches 1286 W and 1606 W, the MILP 200 W and 1606 W. """ # no preference terms means no strategy is configured, so there is nothing to decide and @@ -724,19 +786,9 @@ def _solve_preferences(self, tmpdir, deadline) -> None: self.preference_stage = 'none' return - remaining = None if deadline is None else deadline - time.monotonic() - if remaining is not None and remaining <= 0: - self.preference_stage = 'no time' - return - # deciding the tie to proven optimality is its own hard problem, as expensive as the cost - # optimum on the very requests this is meant to help, so it gets a slice of the clock - # rather than whatever is left of it. What it does not finish is still an improvement, see - # below, it just does not get to spend the whole budget on the last percent of it. - if remaining is not None: - remaining = min(remaining, self.settings.time_limit * PREFERENCE_TIME_SHARE) - - # what the first stage found, to fall back to and to bound the money by - solution = {var: var.varValue for var in self.problem.variables()} + # what the first stage found, to fall back to and to bound the money by. Every solve below + # that is kept replaces it, so this always holds the best schedule seen so far. + best = {var: var.varValue for var in self.problem.variables()} cost = pulp.value(self.cost_objective) undecided = pulp.value(self.preference_objective) @@ -754,38 +806,67 @@ def _solve_preferences(self, tmpdir, deadline) -> None: # in _setup_target_function was derived from, so reusing that one would hand the solver an # objective sitting at the bottom of its tolerance band self.problem.setObjective(self.preference_objective * objective_scale(self.preference_objective)) - # no warm start, although the first stage solution is right there and feasible. The CBC - # binary pulp ships, 2.10.3 built Dec 2019, mishandles a MIP start on this model: it - # returns a strictly worse schedule and reports it as proven optimal, and it declares the - # model infeasible over a cost bound the start itself satisfies. Measured on one captured - # request, preference -0.806 warm against -0.610 cold, the cold value matching a single - # joint solve to the last digit. Upstream has fixed it, the same LP and the same start file - # come back identical to the cold run on CBC 2.10.13, so this can go once pulp ships a - # newer binary or the image installs its own. It buys nothing today, this stage is cheap. - self.problem.solve(self._solver(tmpdir, timeLimit=remaining)) - - # keep what came back if it is an improvement that respects the money, proven optimal or - # not: a tie break stopped by the clock still holds an incumbent, and the alternative is - # the first stage schedule, which is no tie break at all. Both conditions are checked here - # rather than read off the status, so a solver that reports the wrong one cannot spend - # money. - self.preference_stage = pulp.LpStatus[self.problem.status] - # a stage that ran out of clock before it found an integer solution leaves the relaxation - # in the variables, and pulp reads that back like any other result. It looks like a large - # improvement precisely because it is one the model forbids: the binaries land between 0 - # and 1, and every rule they gate stops holding, c_min among them. Checked here beside the - # other two conditions, for the same reason they are checked here rather than read off the - # status: a solver that reports the wrong one must not be able to spend money, and it must - # not be able to hand back a schedule the model does not allow either. - integral = self.problem.sol_status in (pulp.LpSolutionOptimal, - pulp.LpSolutionIntegerFeasible) - improved = (integral + + def keep(): + """Adopt what the solver just returned, if it improves without spending money. + + Read off the variables rather than the status, so a solver that reports the wrong one + can neither spend money nor hand back a schedule the model does not allow. A solve that + ran out of clock before finding an integer solution leaves the relaxation behind and + pulp reads that back like any other result: it scores as a large improvement precisely + because it is one the model forbids, with every rule the binaries gate no longer + holding, c_min among them. That is what the sol_status check refuses. + """ + nonlocal undecided + integral = self.problem.sol_status in (pulp.LpSolutionOptimal, + pulp.LpSolutionIntegerFeasible) + if not (integral and pulp.value(self.preference_objective) > undecided - and pulp.value(self.cost_objective) >= cost - budget - COST_BOUND_TOLERANCE) - if not improved: + and pulp.value(self.cost_objective) >= cost - budget - COST_BOUND_TOLERANCE): + return False + best.update({var: var.varValue for var in self.problem.variables()}) + undecided = pulp.value(self.preference_objective) + return True + + stages = [] + + # the floor. Same binaries, continuous variables free, so it runs regardless of what the + # clock says and the strategies get something even when the search below never starts. + pinned = self._pin_integers() + try: + with self._timed('tie_break'): + self.problem.solve(self._solver(tmpdir, timeLimit=LP_PREFERENCE_TIME_LIMIT)) + stages.append('LP ' + pulp.LpStatus[self.problem.status] + ('' if keep() else ' unused')) + finally: + self._unpin_integers(pinned) + + # the tie break proper, on the slice _probe_then_split held back for it. Deciding the tie + # to proven optimality is its own hard problem, as expensive as the cost optimum on the + # very requests this is meant to help. What it does not finish is still an improvement. + remaining = None if deadline is None else deadline - time.monotonic() + if remaining is not None and remaining <= 0: + stages.append('no time') + else: + if remaining is not None: + remaining = min(remaining, self.settings.time_limit * PREFERENCE_TIME_SHARE) + # no warm start, although a feasible solution is right there in the variables. The CBC + # binary pulp ships, 2.10.3 built Dec 2019, mishandles a MIP start on this model: it + # returns a strictly worse schedule and reports it as proven optimal, and it declares + # the model infeasible over a cost bound the start itself satisfies. Measured on one + # captured request, preference -0.806 warm against -0.610 cold, the cold value matching + # a single joint solve to the last digit. Fixed upstream, the same LP and the same start + # file come back identical to the cold run on CBC 2.10.13, but the image ships 2.10.10 + # and that one has not been checked, so this stays until it is. + with self._timed('tie_break'): + self.problem.solve(self._solver(tmpdir, timeLimit=remaining)) + stages.append('MILP ' + pulp.LpStatus[self.problem.status] + + ('' if keep() else ' unused')) + + self.preference_stage = ', '.join(stages) + if all(stage.endswith('unused') or stage == 'no time' for stage in stages): self.preference_stage += ', kept the first stage' - for var, value in solution.items(): - var.varValue = value + for var, value in best.items(): + var.varValue = value self.problem.status = pulp.LpStatusOptimal def _probe_then_split(self, tmpdir, deadline) -> None: @@ -802,7 +883,8 @@ def _probe_then_split(self, tmpdir, deadline) -> None: if probe is None and self.settings.time_limit is not None: probe = self.settings.time_limit * PROBE_SHARE if probe != 0: - self.problem.solve(self._solver(tmpdir, timeLimit=probe)) + with self._timed('probe'): + self.problem.solve(self._solver(tmpdir, timeLimit=probe)) # sol_status, not status: pulp reports LpStatusOptimal whenever CBC came back with any # feasible solution, including one it stopped on at the time limit. Measured on a captured @@ -829,9 +911,14 @@ def _probe_then_split(self, tmpdir, deadline) -> None: gap_abs = self.settings.gap_abs scale = self.objective_scale self.problem.setObjective(self.cost_objective * scale) - remaining = None if deadline is None else max(deadline - time.monotonic(), 0.1) - self.problem.solve(self._solver(tmpdir, timeLimit=remaining, - gapAbs=None if gap_abs is None else gap_abs * scale)) + # the tie break's slice comes off here rather than being whatever the cost stage did not + # use, see PREFERENCE_TIME_SHARE + reserve = (0. if self.settings.time_limit is None + else self.settings.time_limit * PREFERENCE_TIME_SHARE) + remaining = None if deadline is None else max(deadline - reserve - time.monotonic(), 0.1) + with self._timed('cost'): + self.problem.solve(self._solver(tmpdir, timeLimit=remaining, + gapAbs=None if gap_abs is None else gap_abs * scale)) if pulp.LpStatus[self.problem.status] == 'Optimal': # the cost stage is allowed to stop on its gap, so LpSolutionOptimal is not required of @@ -860,8 +947,10 @@ def solve(self) -> Dict: Returns a dictionary with the optimization results """ + self.stage_seconds = {} if self.problem is None: - self.create_model() + with self._timed('build'): + self.create_model() # both stages share one wall clock, so a second solve cannot double the response time deadline = None if self.settings.time_limit is None else time.monotonic() + self.settings.time_limit diff --git a/test_cases/028-attenuate-grid-peaks-long-horizon.json b/test_cases/028-attenuate-grid-peaks-long-horizon.json new file mode 100644 index 000000000..35788a627 --- /dev/null +++ b/test_cases/028-attenuate-grid-peaks-long-horizon.json @@ -0,0 +1,3756 @@ +{ + "request": { + "batteries": [ + { + "c_max": 11040, + "c_min": 1380, + "charge_from_grid": true, + "d_max": 0, + "p_a": 0.0002896641, + "p_demand": [ + 64.78333, + 345, + 345, + 345, + 345, + 345, + 345, + 345, + 345, + 345, + 345, + 345, + 345, + 345, + 345, + 345, + 345, + 345, + 345, + 345, + 345, + 345, + 345, + 345, + 345, + 345, + 345, + 345, + 345, + 345, + 345, + 345, + 345, + 345, + 345, + 345, + 345, + 345, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "s_initial": 24594.15, + "s_max": 36000, + "s_min": 0 + }, + { + "c_max": 2400, + "c_min": 0, + "d_max": 2400, + "p_a": 0.0002896641, + "s_capacity": 8200, + "s_initial": 4510, + "s_max": 7380, + "s_min": 820 + } + ], + "eta_c": 0.9, + "eta_d": 0.9, + "grid": {}, + "strategy": { + "charging_strategy": "attenuate_grid_peaks", + "discharging_strategy": "discharge_before_import" + }, + "time_series": { + "dt": [ + 169, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900, + 900 + ], + "ft": [ + 165.71179, + 880.83215, + 929.9778, + 952.85706, + 841.3343, + 780.0314, + 712.4026, + 666.0515, + 679.0482, + 599.22784, + 519.7525, + 582.43567, + 673.98755, + 702.9713, + 690.20465, + 701.01605, + 692.38995, + 660.5308, + 644.4287, + 619.1254, + 586.57623, + 509.17114, + 376.3289, + 266.02957, + 214.96294, + 213.2377, + 248.77733, + 288.57248, + 295.93344, + 273.50552, + 239.80615, + 196.3305, + 154.005, + 115.014946, + 75.90987, + 42.670544, + 18.172361, + 5.9807773, + 1.6102092, + 0.11501494, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0.11501494, + 2.4153137, + 10.23633, + 24.728212, + 44.855827, + 64.86843, + 76.254906, + 78.21016, + 78.44019, + 95.117355, + 148.82933, + 235.4356, + 338.604, + 444.53275, + 477.65707, + 439.58713, + 488.1234, + 630.28186, + 757.3734, + 796.2485, + 763.3542, + 677.208, + 537.92487, + 467.8808, + 456.72433, + 436.5967, + 362.06705, + 296.73856, + 340.9043, + 367.81778, + 387.83038, + 545.8609, + 768.7599, + 896.0814, + 955.7742, + 925.0652, + 782.7917, + 746.44696, + 738.5109, + 674.1026, + 681.2335, + 581.2855, + 395.99646, + 383.80487, + 518.94745, + 624.41614, + 588.41644, + 471.33124, + 362.98715, + 267.17972, + 201.6212, + 157.68549, + 110.18432, + 68.77894, + 39.220097, + 23.233019, + 13.456748, + 5.1756725, + 0.9201195, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0.11501494, + 2.6453438, + 10.351345, + 20.93272, + 30.824005, + 45.315887, + 74.18464, + 114.439865, + 160.7909, + 217.37825, + 282.4767, + 351.14062, + 422.79492, + 496.86456, + 571.8543, + 638.908, + 682.8437, + 701.47614, + 704.9266, + 710.10223, + 737.82086, + 787.5073, + 842.25446, + 890.5607, + 925.0652, + 945.9979, + 959.6847, + 974.75165, + 1000.39996, + 1034.0994, + 1066.5336, + 1085.051, + 1073.0895, + 1032.4891, + 979.23724, + 926.90546, + 886.6502, + 856.74634, + 826.7274, + 790.4977, + 744.60675, + 690.4347, + 633.04224, + 578.87024, + 534.1294, + 495.5994, + 454.65408, + 406.57782, + 350.3355, + 288.91754, + 227.38454, + 169.53203, + 117.31524, + 73.149506, + 40.71529, + 19.667555, + 6.9008965, + 1.1501495, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "gt": [ + 12.992036, + 104.81743, + 144.73691, + 184.16762, + 215.85017, + 239.73296, + 219.25914, + 243.17719, + 206.08418, + 221.15329, + 208.35065, + 205.21796, + 182.72697, + 213.32954, + 206.5893, + 214.5395, + 214.38773, + 189.26447, + 198.18681, + 195.2109, + 186.28732, + 207.56212, + 233.04512, + 242.49014, + 267.7245, + 230.57376, + 201.24088, + 192.57613, + 179.89603, + 169.0903, + 170.39003, + 158.46527, + 140.46552, + 123.96016, + 150.61497, + 134.64069, + 118.8865, + 133.91118, + 123.77734, + 94.174034, + 91.550674, + 92.79191, + 96.170334, + 100.101746, + 101.7305, + 97.719765, + 93.231766, + 92.046814, + 95.47972, + 86.45521, + 67.5782, + 61.646862, + 62.53183, + 59.703945, + 57.474735, + 55.08789, + 54.08614, + 54.3922, + 52.804222, + 53.11454, + 51.712296, + 51.655346, + 53.162395, + 52.181477, + 53.877956, + 50.611256, + 51.675583, + 53.51644, + 52.55884, + 54.72023, + 51.24569, + 50.499542, + 53.070423, + 52.405045, + 55.125618, + 51.04171, + 51.27051, + 53.787533, + 52.180485, + 61.16134, + 60.676308, + 66.96407, + 61.574535, + 107.87212, + 92.621056, + 78.739204, + 75.63983, + 86.34111, + 128.17094, + 161.77434, + 125.19141, + 144.17003, + 126.57218, + 131.70126, + 126.764206, + 171.84827, + 182.94926, + 211.70464, + 220.28546, + 222.49403, + 215.85017, + 239.73296, + 219.25914, + 243.17719, + 206.08418, + 221.15329, + 208.35065, + 205.21796, + 182.72697, + 213.32954, + 206.5893, + 214.5395, + 214.38773, + 189.26447, + 198.18681, + 195.2109, + 186.28732, + 207.56212, + 233.04512, + 242.49014, + 267.7245, + 230.57376, + 201.24088, + 192.57613, + 179.89603, + 169.0903, + 170.39003, + 158.46527, + 140.46552, + 123.96016, + 150.61497, + 134.64069, + 118.8865, + 133.91118, + 123.77734, + 94.174034, + 91.550674, + 92.79191, + 96.170334, + 100.101746, + 101.7305, + 97.719765, + 93.231766, + 92.046814, + 95.47972, + 86.45521, + 67.5782, + 61.646862, + 62.53183, + 59.703945, + 57.474735, + 55.08789, + 54.08614, + 54.3922, + 52.804222, + 53.11454, + 51.712296, + 51.655346, + 53.162395, + 52.181477, + 53.877956, + 50.611256, + 51.675583, + 53.51644, + 52.55884, + 54.72023, + 51.24569, + 50.499542, + 53.070423, + 52.405045, + 55.125618, + 51.04171, + 51.27051, + 53.787533, + 52.180485, + 61.16134, + 60.676308, + 66.96407, + 61.574535, + 107.87212, + 92.621056, + 78.739204, + 75.63983, + 86.34111, + 128.17094, + 161.77434, + 125.19141, + 144.17003, + 126.57218, + 131.70126, + 126.764206, + 171.84827, + 182.94926, + 211.70464, + 220.28546, + 222.49403, + 215.85017, + 239.73296, + 219.25914, + 243.17719, + 206.08418, + 221.15329, + 208.35065, + 205.21796, + 182.72697, + 213.32954, + 206.5893, + 214.5395, + 214.38773, + 189.26447, + 198.18681, + 195.2109, + 186.28732, + 207.56212, + 233.04512, + 242.49014, + 267.7245, + 230.57376, + 201.24088, + 192.57613, + 179.89603, + 169.0903, + 170.39003, + 158.46527, + 140.46552, + 123.96016, + 150.61497, + 134.64069, + 118.8865, + 133.91118, + 123.77734, + 94.174034, + 91.550674, + 92.79191, + 96.170334, + 100.101746, + 101.7305, + 97.719765, + 93.231766, + 92.046814, + 95.47972, + 86.45521, + 67.5782, + 61.646862, + 62.53183 + ], + "p_E": [ + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012, + 0.00012 + ], + "p_N": [ + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251, + 0.0003251 + ] + } + }, + "expected_response": { + "status": "Optimal", + "objective_value": 6.037596089425657, + "limit_violations": { + "grid_import_limit_exceeded": false, + "grid_export_limit_hit": false + }, + "batteries": [ + { + "charging_power": [ + 152.71975, + 776.01472, + 785.24089, + 768.68944, + 625.48413, + 540.29844, + 493.14346, + 422.87431, + 472.96402, + 378.07455, + 345.0, + 377.21771, + 491.26058, + 489.64176, + 483.61535, + 486.47655, + 478.00222, + 471.26633, + 446.24189, + 423.9145, + 400.28891, + 345.0, + 345.0, + 345.0, + 345.0, + 345.0, + 345.0, + 294.73715, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + "discharging_power": [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + "state_of_charge": [ + 24731.598, + 25430.011, + 26136.728, + 26828.548, + 27391.484, + 27877.753, + 28321.582, + 28702.169, + 29127.836, + 29468.103, + 29778.603, + 30118.099, + 30560.234, + 31000.911, + 31436.165, + 31873.994, + 32304.196, + 32728.336, + 33129.953, + 33511.477, + 33871.737, + 34182.237, + 34492.737, + 34803.237, + 35113.737, + 35424.237, + 35734.737, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0, + 36000.0 + ] + }, + { + "charging_power": [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 116.03741, + 104.41522, + 69.41612, + 37.86523, + 13.53948, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 3.293895, + 0.0, + 0.0, + 0.0, + 19.477525, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 356.42214, + 102.09306, + 184.10054, + 211.87465, + 150.22497, + 55.497949, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 363.13393, + 154.00577, + 288.06751, + 339.81011, + 309.25288, + 192.10264, + 146.83556, + 141.87541, + 86.390689, + 72.246789, + 0.0, + 153.50632, + 0.0, + 288.37369, + 21.750669, + 67.167802, + 291.43521, + 0.0, + 96.78969, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 26.259679, + 110.91123, + 149.71785, + 173.28734, + 131.65374, + 125.72838, + 124.69163, + 165.79725, + 218.33584, + 273.28594, + 283.90765, + 325.31417, + 315.08292, + 367.24288, + 377.82208, + 424.32416, + 459.89105, + 500.89944, + 458.33537, + 424.47521, + 363.27315, + 311.09314, + 295.96114, + 257.13494, + 230.09191, + 202.78579, + 135.62004, + 55.964989, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + "discharging_power": [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 33.59815, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 43.39098, + 201.71622, + 321.46057, + 397.76156, + 362.33606, + 297.46355, + 198.7408, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 8.945214, + 74.7051, + 91.970146, + 100.71414, + 127.9304, + 122.16713, + 94.059019, + 91.550674, + 92.79191, + 96.170334, + 100.10175, + 101.7305, + 97.719765, + 93.231766, + 92.046814, + 95.47972, + 86.45521, + 67.5782, + 11.641347, + 12.526315, + 9.6984297, + 7.4692197, + 5.0823747, + 4.0806247, + 4.3866847, + 2.7987067, + 3.1090247, + 1.7067807, + 1.6498307, + 3.1568797, + 2.1759617, + 3.8724407, + 0.60574065, + 1.6700677, + 3.5109247, + 2.5533247, + 4.7147147, + 1.2401747, + 0.49402665, + 3.0649077, + 2.3995297, + 5.1201027, + 1.0361947, + 1.2649947, + 3.6670027, + 0.0, + 0.91949465, + 0.0, + 0.0, + 0.0, + 2.668055, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 13.77584, + 81.83603, + 95.420593, + 95.653481, + 120.45443, + 118.60167, + 93.253914, + 91.550674, + 92.79191, + 96.170334, + 100.10175, + 101.7305, + 97.719765, + 93.231766, + 92.046814, + 95.47972, + 86.45521, + 67.5782, + 61.646862, + 62.53183, + 59.703945, + 57.474735, + 55.08789, + 54.08614, + 54.3922, + 52.804222, + 53.11454, + 51.712296, + 51.655346, + 53.162395, + 52.181477, + 53.877956, + 50.611256, + 51.675583, + 53.51644, + 52.55884, + 54.72023, + 51.24569, + 50.499542, + 53.070423, + 52.405045, + 55.125618, + 51.04171, + 51.27051, + 53.672518, + 49.535141, + 50.809995, + 39.743588, + 36.140065, + 16.258648, + 33.68748, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 6.64492, + 77.465464, + 93.9254, + 99.218945, + 127.01028, + 122.62719, + 94.174034, + 91.550674, + 92.79191, + 96.170334, + 100.10175, + 101.7305, + 97.719765, + 93.231766, + 92.046814, + 95.47972, + 86.45521, + 67.5782, + 61.646862, + 62.53183 + ], + "state_of_charge": [ + 4510.0, + 4510.0, + 4510.0, + 4510.0, + 4510.0, + 4510.0, + 4510.0, + 4510.0, + 4510.0, + 4510.0, + 4472.6687, + 4472.6687, + 4472.6687, + 4472.6687, + 4472.6687, + 4472.6687, + 4472.6687, + 4472.6687, + 4472.6687, + 4472.6687, + 4472.6687, + 4424.4565, + 4200.3274, + 3843.149, + 3401.1917, + 2998.5961, + 2668.081, + 2447.2579, + 2551.6916, + 2645.6653, + 2708.1398, + 2742.2185, + 2754.404, + 2744.4649, + 2661.4592, + 2559.2702, + 2447.3656, + 2305.2207, + 2169.4794, + 2064.9694, + 1963.2464, + 1860.1443, + 1753.2884, + 1642.0642, + 1529.0303, + 1420.4528, + 1316.862, + 1214.5877, + 1108.4991, + 1012.4378, + 937.35091, + 924.41608, + 910.49795, + 899.72192, + 891.42279, + 885.7757, + 881.24168, + 876.36758, + 873.25791, + 869.80344, + 867.90701, + 866.07387, + 862.56623, + 860.14849, + 855.84578, + 855.17273, + 853.3171, + 849.41607, + 846.57905, + 841.34048, + 839.9625, + 839.41359, + 836.00813, + 833.34199, + 827.65299, + 826.50166, + 825.09611, + 821.02166, + 821.02166, + 820.0, + 820.0, + 820.0, + 822.96451, + 820.0, + 820.0, + 820.0, + 837.52977, + 837.52977, + 837.52977, + 837.52977, + 837.52977, + 837.52977, + 837.52977, + 1158.3097, + 1250.1935, + 1415.8839, + 1606.5711, + 1741.7736, + 1791.7218, + 1791.7218, + 1791.7218, + 1791.7218, + 1791.7218, + 1791.7218, + 1791.7218, + 1791.7218, + 1791.7218, + 1791.7218, + 2118.5423, + 2257.1475, + 2516.4082, + 2822.2373, + 3100.5649, + 3273.4573, + 3405.6093, + 3533.2972, + 3611.0488, + 3676.0709, + 3676.0709, + 3814.2266, + 3814.2266, + 4073.7629, + 4093.3385, + 4153.7895, + 4416.0812, + 4416.0812, + 4503.1919, + 4503.1919, + 4503.1919, + 4487.8855, + 4396.9565, + 4290.9337, + 4184.652, + 4050.8138, + 3919.0341, + 3815.4187, + 3713.6957, + 3610.5936, + 3503.7376, + 3392.5135, + 3279.4796, + 3170.9021, + 3067.3112, + 2965.037, + 2858.9484, + 2762.8871, + 2687.8002, + 2619.3037, + 2549.8238, + 2483.4861, + 2419.6253, + 2358.4165, + 2298.3208, + 2237.8851, + 2179.2137, + 2120.1975, + 2062.7394, + 2005.3446, + 1946.2753, + 1888.2959, + 1828.4315, + 1772.1967, + 1714.7794, + 1655.3167, + 1596.918, + 1536.1177, + 1479.1781, + 1423.0675, + 1364.1004, + 1305.8725, + 1244.6218, + 1187.9088, + 1130.9416, + 1071.3055, + 1016.2664, + 959.81087, + 915.65133, + 875.4957, + 857.43053, + 820.0, + 820.0, + 820.0, + 820.0, + 820.0, + 820.0, + 820.0, + 820.0, + 843.63371, + 943.45382, + 1078.1999, + 1234.1585, + 1352.6469, + 1465.8024, + 1578.0249, + 1727.2424, + 1923.7446, + 2169.702, + 2425.2189, + 2718.0016, + 3001.5763, + 3332.0948, + 3672.1347, + 4054.0265, + 4467.9284, + 4918.7379, + 5331.2397, + 5713.2674, + 6040.2133, + 6320.1971, + 6586.5621, + 6817.9835, + 7025.0663, + 7207.5735, + 7329.6315, + 7380.0, + 7380.0, + 7380.0, + 7380.0, + 7380.0, + 7380.0, + 7380.0, + 7380.0, + 7380.0, + 7380.0, + 7380.0, + 7372.6168, + 7286.544, + 7182.1825, + 7071.9392, + 6930.8167, + 6794.5642, + 6689.9264, + 6588.2034, + 6485.1013, + 6378.2454, + 6267.0212, + 6153.9873, + 6045.4098, + 5941.819, + 5839.5447, + 5733.4561, + 5637.3948, + 5562.3079, + 5493.8114, + 5424.3316 + ] + } + ], + "grid_import": [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 50.005515, + 50.005515, + 50.005515, + 50.005515, + 50.005515, + 50.005515, + 50.005515, + 50.005515, + 50.005515, + 50.005515, + 50.005515, + 50.005515, + 50.005515, + 50.005515, + 50.005515, + 50.005515, + 50.005515, + 50.005515, + 50.005515, + 50.005515, + 50.005515, + 50.005515, + 50.005515, + 50.005515, + 50.005515, + 50.005515, + 50.005515, + 49.765171, + 50.005515, + 35.948096, + 22.108243, + 0.0, + 28.949159, + 14.410896, + 0.299014, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + "grid_export": [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 62.48822, + 107.26466, + 176.82966, + 319.34134, + 333.48704, + 313.01495, + 0.0, + 401.42459, + 401.42459, + 401.42459, + 401.42459, + 401.42459, + 315.43084, + 252.03063, + 216.99137, + 217.33756, + 118.88986, + 90.65438, + 119.75101, + 159.46713, + 182.61242, + 0.0, + 401.42459, + 401.42459, + 401.42459, + 401.42459, + 401.42459, + 401.42459, + 401.42459, + 401.42459, + 401.42459, + 348.24038, + 0.0, + 116.08037, + 0.0, + 401.42459, + 328.67251, + 0.0, + 193.89685, + 0.0, + 43.15593, + 17.21997, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 21.818809, + 82.051696, + 141.73842, + 196.13559, + 222.96968, + 261.02058, + 371.67315, + 401.42459, + 401.42459, + 401.42459, + 401.42459, + 401.42459, + 401.42459, + 401.42459, + 401.42459, + 401.42459, + 401.42459, + 401.42459, + 401.42459, + 401.42459, + 401.42459, + 401.42459, + 401.42459, + 401.42459, + 401.42459, + 401.42459, + 401.42459, + 401.42459, + 401.42459, + 401.42459, + 401.42459, + 401.42459, + 401.42459, + 401.42459, + 401.42459, + 390.5521, + 311.14574, + 303.55564, + 294.35852, + 262.07795, + 226.68179, + 181.2452, + 118.52751, + 68.91927, + 29.06651, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + "flow_direction": [ + 1, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "grid_import_overshoot": [], + "grid_export_overshoot": [] + } +} \ No newline at end of file diff --git a/tests/test_app.py b/tests/test_app.py index 1bd31293d..2699b387e 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -138,6 +138,24 @@ def test_slow_requests_are_dumped(tmp_path, monkeypatch): assert json.loads(lines[1])["elapsed"] > 0 +def test_every_request_logs_a_solve_line(capsys): + # the key names are the Log Analytics contract: the dashboard's KQL queries parse this line, + # so renaming one breaks production attribution silently + request = json.loads(pathlib.Path('test_cases/009-discharge-before-import.json').read_text())["request"] + client = app.test_client() + client.post("/optimize/charge-schedule", json=request, headers={"User-Agent": "evcc/0.311.1"}) + + lines = [json.loads(line) for line in capsys.readouterr().out.splitlines() + if line.startswith('{"solve"')] + assert len(lines) == 1, "every request logs exactly one solve line" + solve = lines[0]["solve"] + assert {"client", "elapsed", "stages", "path", "preferences", "status", "steps"} <= set(solve) + assert solve["client"] == "evcc/0.311.1" + assert solve["elapsed"] > 0 + assert solve["stages"] and set(solve["stages"]) <= {"build", "probe", "cost", "tie_break"} + assert solve["steps"] == len(request["time_series"]["dt"]) + + def test_abort_returns_json_message(): # message-only api.abort(400, ...) must return a JSON body, not an empty response client = app.test_client() diff --git a/tests/test_fractional_solution.py b/tests/test_fractional_solution.py index 4bf6707eb..035089644 100644 --- a/tests/test_fractional_solution.py +++ b/tests/test_fractional_solution.py @@ -37,7 +37,7 @@ def solve(*args, **kwargs): calls.append(1) if len(calls) == 1: # the cost stage, left alone return real_solve(*args, **kwargs) - relax(optimizer) # the preference stage, out of time and empty handed + relax(optimizer) # both tie break solves, out of time and empty handed optimizer.problem.status = pulp.LpStatusNotSolved optimizer.problem.sol_status = pulp.LpSolutionNoSolutionFound return optimizer.problem.status @@ -45,7 +45,8 @@ def solve(*args, **kwargs): monkeypatch.setattr(optimizer.problem, 'solve', solve) optimizer.solve() - assert len(calls) == 2, f'the preference stage did not run, {len(calls)} solves' + # the cost stage, then the two the tie break makes: the pinned LP floor and the MILP + assert len(calls) == 3, f'the preference stage did not run both solves, {len(calls)} solves' assert optimizer.preference_stage.endswith('kept the first stage'), \ f'preference stage ended as {optimizer.preference_stage}' for var in binaries(optimizer): diff --git a/tests/test_objective_split.py b/tests/test_objective_split.py index b7e203279..de11adca6 100644 --- a/tests/test_objective_split.py +++ b/tests/test_objective_split.py @@ -1,11 +1,13 @@ import json import pathlib +import time +from tempfile import TemporaryDirectory import numpy import pulp import pytest -from optimizer.optimizer import COST_BOUND_SLACK, BatteryConfig, GridConfig, OptimizationStrategy, Optimizer, TimeSeriesData +from optimizer.optimizer import COST_BOUND_SLACK, PREFERENCE_TIME_SHARE, BatteryConfig, GridConfig, OptimizationStrategy, Optimizer, TimeSeriesData # a plain case, one that leans on the priorities, and one that levels grid peaks. The last is the # one where the preferences are worth enough real money to be tempted to buy some @@ -69,7 +71,12 @@ def test_preferences_are_not_paid_for_with_money(case): optimizer.settings.probe_seconds = 0 assert optimizer.solve()['status'] == 'Optimal' assert optimizer.solve_path == 'split', f'took the {optimizer.solve_path} path' - assert optimizer.preference_stage == 'Optimal', f'preference stage ended as {optimizer.preference_stage}' + # two solves now, the pinned LP floor and the MILP on top of it. What matters here is that the + # stage produced a schedule of its own rather than handing back the one the cost stage left. + assert optimizer.preference_stage.startswith('LP Optimal'), \ + f'preference stage ended as {optimizer.preference_stage}' + assert not optimizer.preference_stage.endswith('kept the first stage'), \ + f'preference stage ended as {optimizer.preference_stage}' # measured against what the cost stage had before the tie break ran, so the gap the cost stage # is allowed to stop on does not enter. The slack is what _solve_preferences hands over, plus @@ -112,3 +119,49 @@ def test_easy_requests_never_reach_the_split(case): joint_total = pulp.value(optimizer.cost_objective) + pulp.value(optimizer.preference_objective) split_total = pulp.value(split.cost_objective) + pulp.value(split.preference_objective) assert joint_total >= split_total - 1e-9, f'joint {joint_total}, split {split_total}' + + +@pytest.mark.parametrize('case', CASES) +def test_the_cost_stage_leaves_the_tie_break_its_slice(case, monkeypatch): + # the starvation this reserve fixes. The cost stage used to be handed `deadline - now`, all of + # it, and it is anytime branch and bound: on a request it cannot close it spends every second + # offered. The tie break then found the clock gone, reported 'no time', and the strategy did + # nothing at all, visible only as a 'Feasible' status on an otherwise ordinary looking answer. + optimizer = build(case) + optimizer.settings.probe_seconds = 0 + optimizer.settings.time_limit = 4. + + limits = [] + real_solver = optimizer._solver + + def solver(tmpdir, **options): + limits.append(options.get('timeLimit')) + return real_solver(tmpdir, **options) + + monkeypatch.setattr(optimizer, '_solver', solver) + optimizer.solve() + + # the cost stage is first with the probe off, and it may not be offered the whole limit + assert limits, 'no solve ran' + reserved = optimizer.settings.time_limit * PREFERENCE_TIME_SHARE + assert limits[0] <= optimizer.settings.time_limit - reserved + 1e-6, \ + f'cost stage was offered {limits[0]} s of a {optimizer.settings.time_limit} s limit' + + +@pytest.mark.parametrize('case', CASES) +def test_the_lp_floor_decides_the_tie_with_no_clock_left(case): + # the tie break has to give the strategies something even when nothing is left for the search. + # Pinning the binaries the cost stage already chose leaves only the continuous variables free, + # which is a linear program and fits in milliseconds, so it runs whatever the clock says. + optimizer = solve_cost_only(build(case)) + undecided = pulp.value(optimizer.preference_objective) + + with TemporaryDirectory() as tmpdir: + optimizer._solve_preferences(tmpdir, deadline=time.monotonic() - 1) + + assert optimizer.preference_stage.startswith('LP Optimal'), \ + f'preference stage ended as {optimizer.preference_stage}' + assert optimizer.preference_stage.endswith('no time'), \ + f'the MILP stage ran anyway, {optimizer.preference_stage}' + assert pulp.value(optimizer.preference_objective) > undecided, \ + f'preferences after the floor {pulp.value(optimizer.preference_objective)}, before {undecided}' diff --git a/tests/test_peak_leveling.py b/tests/test_peak_leveling.py index b8cd686d5..f5cdd1fcc 100644 --- a/tests/test_peak_leveling.py +++ b/tests/test_peak_leveling.py @@ -1,8 +1,29 @@ +import json +import pathlib + import numpy import pytest +from test_objective_split import build as build_case from optimizer.optimizer import BatteryConfig, GridConfig, OptimizationStrategy, Optimizer, TimeSeriesData +# the one stored case long enough to make the solve split. The small ones are all proved outright by +# the probe, so none of them ever reaches the tie break's own budget, which is where levelling was +# being lost. 245 steps, 4450 variables, 1263 binaries. +LONG_CASE = '028-attenuate-grid-peaks-long-horizon' + + +def grid_peaks(request, response): + """max grid power per side [W]. Kept per side on purpose: attenuate_grid_peaks levels both, + and a max() across them lets a regression on one hide behind the larger of the two.""" + dt = numpy.array(request['time_series']['dt'], float) + return {key: float((numpy.array(response[key], float) * 3600 / dt).max()) + for key in ('grid_import', 'grid_export')} + + +def long_case(): + return json.loads(pathlib.Path(f'test_cases/{LONG_CASE}.json').read_text()) + def build(strategy='attenuate_demand_peaks', dt=None, gt=None, ft=None, c_max=8000., d_max=0., charge_from_grid=True, discharge_to_grid=False, p_max_imp=None, p_max_exp=None): @@ -90,3 +111,45 @@ def test_a_pinned_peak_leaves_the_steps_below_it_unordered(): assert (flat_out * dt).sum() == pytest.approx((spread * dt).sum(), rel=1e-3) assert flat_out.max() == pytest.approx(spread.max()) assert spread.std() < flat_out.std() / 1.5 + + +def test_the_long_horizon_case_levels_both_sides(): + # the stored expectation only compares status and objective value, and the tie break is cost + # neutral by contract, so the objective is identical whether the profile was levelled or not. + # The peaks are the part worth pinning. + case = long_case() + model = build_case(LONG_CASE) + result = model.solve() + + assert result['status'] == 'Optimal' + peaks = grid_peaks(case['request'], result) + stored = grid_peaks(case['request'], case['expected_response']) + for key, value in peaks.items(): + assert value <= stored[key] + 1, f'{key} peaked at {value} W, stored is {stored[key]} W' + + +def test_the_long_horizon_case_still_levels_once_the_solve_splits(): + # the regression this guards. The probe cannot prove this request on a production core, so the + # split runs, and the cost stage used to be handed the whole clock: it is anytime branch and + # bound and spent all of it, leaving the tie break with 'no time'. The answer came back cost + # optimal with no levelling at all, 3609 W of export peak against the 1606 W the same money + # buys, and only a 'Feasible' status to show for it. + # + # The threshold sits between the two so the test states which of them came back rather than + # pinning a schedule: CBC may pick a different optimum of equal value on another build. + case = long_case() + model = build_case(LONG_CASE) + model.settings.probe_seconds = 0 # force the split the probe would otherwise avoid here + model.settings.time_limit = 5. + result = model.solve() + + assert result['status'] in ('Optimal', 'Feasible'), result['status'] + peaks = grid_peaks(case['request'], result) + assert peaks['grid_export'] < 2400, \ + f"export peaked at {peaks['grid_export']} W, unlevelled is 3609 W and levelled 1606 W" + + # the import side is levelled by the MILP tie break, and only reaches 200 W when that stage + # gets to finish. The pinned LP floor alone reaches 1286 W, which is what a slower machine + # will see here, so this asserts the floor ran rather than the peak the search would find. + assert peaks['grid_import'] < 1350, \ + f"import peaked at {peaks['grid_import']} W, unlevelled is 1417 W and the LP floor 1286 W" diff --git a/tests/test_stage_timings.py b/tests/test_stage_timings.py new file mode 100644 index 000000000..5c2737429 --- /dev/null +++ b/tests/test_stage_timings.py @@ -0,0 +1,30 @@ +"""The stage clock: every solve leaves behind where its wall time went. + +The access log only carries the total response time, so stage_seconds is what production +attributes latency with. These tests pin the keys each solve path must produce. +""" +from test_objective_split import build + +from optimizer.settings import OptimizerSettings + + +def test_joint_path_times_build_and_probe(): + # a small case the probe proves outright: no split, so no cost or tie break stage ran + optimizer = build('012-early-charging-not-perfect') + optimizer.solve() + + assert optimizer.solve_path == 'joint' + assert set(optimizer.stage_seconds) == {'build', 'probe'} + assert all(seconds >= 0 for seconds in optimizer.stage_seconds.values()) + + +def test_split_path_times_every_stage(): + # probe_seconds=0 forces the split, and this case carries a strategy so the tie break runs + optimizer = build('026-attenuate-grid-peaks') + optimizer.settings = OptimizerSettings(probe_seconds=0, time_limit=10) + optimizer.solve() + + assert optimizer.solve_path == 'split' + # no probe ran, so no probe key: an absent stage must be absent, not zero + assert set(optimizer.stage_seconds) == {'build', 'cost', 'tie_break'} + assert all(seconds >= 0 for seconds in optimizer.stage_seconds.values())