From ec7e66a5ddcf57b2f36f8b7c0aa457b1d4995d1b Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 21 Aug 2026 12:11:47 +0200 Subject: [PATCH 1/4] fix: stop the cost stage starving the tie break, and give it a floor that always fits Peak levelling lives entirely in the tie break stage, so when that stage does not run the strategy silently does nothing: the schedule comes back cost optimal, the status says Feasible, and only the shape of the profile gives it away. On a captured 245 step attenuate_grid_peaks request it returned a 1591 W import peak and a 3609 W export peak against the 200 W / 1606 W the same model reaches with enough clock. The clock was budgeted three different ways and only the probe got a real share. PROBE_SHARE is a fixed slice off time_limit, the cost stage took `deadline - now`, all of it, and the tie break got `min(leftovers, time_limit * PREFERENCE_TIME_SHARE)`. The constant's own comment already said "the cost stage keeps the rest", meaning the rest after the tie break's share, but that subtraction was never written. The cost stage is anytime branch and bound, so on a request it cannot close it spends every second offered, which is exactly the shape of request the tie break matters on. It reached 'no time'. Two changes, doing different jobs: - the cost stage now has the tie break's slice taken off its budget up front, and PREFERENCE_TIME_SHARE goes 0.25 -> 0.4. A reserve too small to seat the stage is worse than none, it is idle time the cost stage could have used, and the MILP tie break needs 1.6 s on a model this size. - the tie break runs a cheap floor first: pin the binaries the cost stage already chose, leaving only the continuous variables free, which is a linear program and lands in 0.03 s mean / 0.165 s worst over the stored cases. It runs whatever the clock says. The MILP then gets to beat it on the reserved slice, and whichever is ahead is kept. Measured over 20 cases at three time limits. Money is unchanged everywhere, worst delta 0.000000, and p95 latency stays at 0.52 s. On the captured request, per levelled side: time limit main reserve only reserve + floor 3 s 1417 / 3609 1286 / 1606 1286 / 1606 5 s 1417 / 3609 1286 / 1606 200 / 1606 10 s 200 / 1606 200 / 1606 200 / 1606 The floor is what recovers the export side at every limit; the reserve is what seats the MILP and recovers the import side once there is clock for it. 0.5 was measured too and buys nothing over 0.4. preference_stage now names both solves, so a starved stage is legible in the logs rather than having to be inferred from the profile. Co-Authored-By: Claude Opus 5 (1M context) --- src/optimizer/optimizer.py | 160 +++++++++++++++++++++--------- tests/test_fractional_solution.py | 5 +- tests/test_objective_split.py | 57 ++++++++++- 3 files changed, 171 insertions(+), 51 deletions(-) diff --git a/src/optimizer/optimizer.py b/src/optimizer/optimizer.py index 7746e52ad..4741414d8 100644 --- a/src/optimizer/optimizer.py +++ b/src/optimizer/optimizer.py @@ -98,9 +98,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 @@ -712,10 +729,38 @@ def _solver(self, tmpdir, **options): solver.tmpDir = tmpdir return solver + 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 +769,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 +789,65 @@ 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: + 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. + 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: @@ -829,7 +891,11 @@ 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) + # 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) self.problem.solve(self._solver(tmpdir, timeLimit=remaining, gapAbs=None if gap_abs is None else gap_abs * scale)) 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}' From 62182050e3b5c6a9f6af3fd0c9f646ca25a9f249 Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 21 Aug 2026 12:19:45 +0200 Subject: [PATCH 2/4] test: add the long horizon case the tie break was starving on Every stored case is proved outright by the probe, so none of them ever reaches the tie break's own budget, which is where the levelling was being lost. All 19 return an identical profile at every time limit and every clock split tried - they gave no signal at all on the bug this branch fixes. 028 is the captured request: 245 steps, 4450 variables, 1263 binaries, attenuate_grid_peaks over both sides. Not marked strict. The default comparison covers status and objective value, and the tie break is cost neutral by contract, so the objective is identical whether the profile came back levelled or not - pinning 245 steps of schedule would only make the case brittle across CBC builds without testing the thing that matters. The peaks are what is worth asserting, so two tests in test_peak_leveling.py do it per levelled side, never max()ed across them: a max() lets a regression on one side hide behind the larger of the two, which is how the import peak went unnoticed at six times its optimum. - test_the_long_horizon_case_levels_both_sides pins the stored answer on the joint path. - test_the_long_horizon_case_still_levels_once_the_solve_splits forces the split and asserts the profile is levelled at all. The thresholds sit between the levelled and the unlevelled value rather than on a schedule, so the test says which of the two came back and stays true on a machine too slow to finish the MILP stage. Verified against main: the split test and the four other new ones fail there and pass here. Co-Authored-By: Claude Opus 5 (1M context) --- ...028-attenuate-grid-peaks-long-horizon.json | 3756 +++++++++++++++++ tests/test_peak_leveling.py | 63 + 2 files changed, 3819 insertions(+) create mode 100644 test_cases/028-attenuate-grid-peaks-long-horizon.json 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_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" From 17a1e100c2bcced1748e685750ea4b606b955af9 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 23 Aug 2026 11:18:08 +0200 Subject: [PATCH 3/4] feat: log where each solve spent its clock (#141) --- src/optimizer/app.py | 15 ++++++++++++++- src/optimizer/optimizer.py | 35 +++++++++++++++++++++++++++++------ tests/test_app.py | 17 +++++++++++++++++ tests/test_stage_timings.py | 30 ++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 7 deletions(-) create mode 100644 tests/test_stage_timings.py diff --git a/src/optimizer/app.py b/src/optimizer/app.py index a355b2d1c..202bbb350 100644 --- a/src/optimizer/app.py +++ b/src/optimizer/app.py @@ -252,7 +252,20 @@ 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. + print(json.dumps({"solve": { + "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 4741414d8..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 @@ -202,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 = {} @@ -729,6 +733,19 @@ 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. @@ -817,7 +834,8 @@ def keep(): # clock says and the strategies get something even when the search below never starts. pinned = self._pin_integers() try: - self.problem.solve(self._solver(tmpdir, timeLimit=LP_PREFERENCE_TIME_LIMIT)) + 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) @@ -839,7 +857,8 @@ def keep(): # 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. - self.problem.solve(self._solver(tmpdir, timeLimit=remaining)) + 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')) @@ -864,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 @@ -896,8 +916,9 @@ def _probe_then_split(self, tmpdir, deadline) -> None: 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) - self.problem.solve(self._solver(tmpdir, timeLimit=remaining, - gapAbs=None if gap_abs is None else gap_abs * scale)) + 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 @@ -926,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/tests/test_app.py b/tests/test_app.py index 1bd31293d..3c4053212 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -138,6 +138,23 @@ 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) + + 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 {"elapsed", "stages", "path", "preferences", "status", "steps"} <= set(solve) + 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_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()) From 622043cee45cc224080dbf367684a8247bd78125 Mon Sep 17 00:00:00 2001 From: andig Date: Thu, 10 Sep 2026 12:04:03 +0200 Subject: [PATCH 4/4] feat: log the client version with every solve evcc sends User-Agent evcc/ on every request. With it on the solve line, a change in the solve mix or the latency can be read against the release that sent the requests, and the version spread of the installed base is one query away. Co-Authored-By: Claude Fable 5.1 --- src/optimizer/app.py | 3 +++ tests/test_app.py | 5 +++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/optimizer/app.py b/src/optimizer/app.py index 202bbb350..1438ab88c 100644 --- a/src/optimizer/app.py +++ b/src/optimizer/app.py @@ -256,7 +256,10 @@ def post(self): # 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, diff --git a/tests/test_app.py b/tests/test_app.py index 3c4053212..2699b387e 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -143,13 +143,14 @@ def test_every_request_logs_a_solve_line(capsys): # 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) + 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 {"elapsed", "stages", "path", "preferences", "status", "steps"} <= set(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"])