Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions client/client.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 10 additions & 1 deletion openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,16 @@ components:
type: number
minimum: 0
description: Maximum charge power in W
example: 11000
example: 11000
c_active:
type: boolean
default: false
description: |
Whether the device is charging at the start of the time horizon.
- True: the device enters the horizon switched on, so keeping it on costs no charge
start and interrupting it costs one.
- False: (default) the device is idle and any charging starts a new session.
example: true
d_max:
type: number
minimum: 0
Expand Down
2 changes: 2 additions & 0 deletions src/optimizer/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ def handle_validation_error(error):
's_goal': fields.List(fields.Float, required=False, description='Goal state of charge at each time step (Wh)'),
'c_min': fields.Float(required=True, description='Minimum charge power (W)'),
'c_max': fields.Float(required=True, description='Maximum charge power (W)'),
'c_active': fields.Boolean(required=False, description='Whether the device is charging at the start of the time horizon.'),
'd_max': fields.Float(required=True, description='Maximum discharge power (W)'),
'p_a': fields.Float(required=True, description='Monetary value per Wh at end of the optimization horizon'),
'c_priority': fields.Integer(required=False, description='Charging and discharging priority compared to other batteries. 2 = highest priority.')
Expand Down Expand Up @@ -204,6 +205,7 @@ def post(self):
s_goal=bat_data.get('s_goal'),
c_min=bat_data['c_min'],
c_max=bat_data['c_max'],
c_active=bat_data.get('c_active', False),
d_max=bat_data['d_max'],
p_a=bat_data['p_a'],
c_priority=bat_data.get('c_priority', 0),
Expand Down
17 changes: 13 additions & 4 deletions src/optimizer/optimizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ class BatteryConfig:
p_demand: Optional[List[float]] = None # Minimum charge demand (Wh)
s_goal: Optional[List[float]] = None # Goal state of charge (Wh)
c_priority: int = 0
c_active: bool = False # Whether the device is charging at the start of the horizon


@dataclass
Expand Down Expand Up @@ -797,22 +798,30 @@ def _solve_preferences(self, tmpdir, deadline) -> None:
self.problem.status = pulp.LpStatusOptimal

def _solve_continuity(self, tmpdir: str, deadline: float | None) -> None:
"""Prefer fewer charge starts without trading away economics or existing preferences."""
"""Prefer fewer charge starts without trading away economics or existing preferences.

A device reported as charging enters the horizon switched on, so keeping it on is free and
interrupting it costs a start.
"""
if (self.problem.sol_status not in (pulp.LpSolutionOptimal, pulp.LpSolutionIntegerFeasible)
or not _complete_solution(self.problem) or not self._is_integral()):
return

eligible = [i for i, active in self.variables['z_c'].items() if active is not None]

def charging_now(i: int) -> int:
return int(self.batteries[i].c_active)

def count_starts() -> list[int]:
counts = []
for i in eligible:
active = np.array([pulp.value(v) for v in self.variables['c'][i]]) > CONTINUITY_TOLERANCE
counts.append(int(np.count_nonzero(active & ~np.r_[False, active[:-1]])))
counts.append(int(np.count_nonzero(active & ~np.r_[bool(charging_now(i)), active[:-1]])))
return counts

before = count_starts()
if not any(count > 1 for count in before):
# a device that is charging already can reach zero starts, one that is idle needs one
if all(count <= 1 - charging_now(i) for i, count in zip(eligible, before)):
return
remaining = CONTINUITY_TIME_LIMIT if deadline is None else min(CONTINUITY_TIME_LIMIT, deadline - time.monotonic())
if remaining <= 0:
Expand All @@ -838,7 +847,7 @@ def count_starts() -> list[int]:
active = self.variables['z_c'][i]
for t in self.time_steps:
start = pulp.LpVariable(f'charge_start_{i}_{t}', lowBound=0, upBound=1)
candidate += start >= active[t] - (active[t - 1] if t else 0)
candidate += start >= active[t] - (active[t - 1] if t else charging_now(i))
starts.append(start)
candidate.setObjective(-pulp.lpSum(starts))

Expand Down
48 changes: 46 additions & 2 deletions tests/test_continuity.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,11 @@ def starts(charging: list[float]) -> int:
return int(np.count_nonzero(active & ~np.r_[False, active[:-1]]))


def seed_fragmented(model: Optimizer, monkeypatch: pytest.MonkeyPatch) -> None:
def seed_fragmented(model: Optimizer, monkeypatch: pytest.MonkeyPatch, schedule: tuple[float, ...] = (0, 500, 0, 500, 0, 500)) -> None:
original = model._probe_then_split

def seeded(tmpdir: str, deadline: float | None) -> None:
for t, energy in enumerate([0, 500, 0, 500, 0, 500]):
for t, energy in enumerate(schedule):
model.problem += model.variables['c'][0][t] == energy, f'seed_{t}'
original(tmpdir, deadline)
for t in model.time_steps:
Expand All @@ -55,6 +55,33 @@ def test_equal_prices_prefer_one_session(monkeypatch: pytest.MonkeyPatch, probe_
assert pulp.value(model.cost_objective) == pytest.approx(-0.45, abs=1e-5)


@pytest.mark.parametrize('schedule', [(0, 500, 0, 500, 0, 500), (0, 500, 500, 500, 0, 0)])
def test_running_session_is_not_interrupted(monkeypatch: pytest.MonkeyPatch, schedule: tuple[float, ...]):
model = build()
model.time_series.p_N = [0.0003] * 6
model.batteries[0].c_active = True
seed_fragmented(model, monkeypatch, schedule)

result = model.solve()

charging = result['batteries'][0]['charging_power']
assert charging[0] > 0
assert starts(charging) == 1
assert result['batteries'][0]['state_of_charge'][-1] == pytest.approx(1500, abs=0.1)


def test_running_session_still_yields_to_price(monkeypatch: pytest.MonkeyPatch):
model = build()
model.batteries[0].c_active = True
seed_fragmented(model, monkeypatch)

result = model.solve()

charging = result['batteries'][0]['charging_power']
assert charging[0] == pytest.approx(0, abs=0.01)
assert starts(charging) == 1


def test_price_gaps_keep_interruptions(monkeypatch: pytest.MonkeyPatch):
model = build()
model.time_series.p_N = [0.001, 0.0003, 0.001, 0.0003, 0.001, 0.0003]
Expand Down Expand Up @@ -140,6 +167,23 @@ def unexpected_solver(*args, **kwargs):
model._solve_continuity(tmpdir, None)


def test_running_session_without_gap_skips_the_solver(monkeypatch: pytest.MonkeyPatch):
model = build()
model.time_series.p_N = [0.0003] * 6
model.batteries[0].c_active = True
seed_fragmented(model, monkeypatch, (500, 500, 500, 0, 0, 0))
with monkeypatch.context() as context:
context.setattr(Optimizer, '_solve_continuity', lambda *args: None)
model.solve()

def unexpected_solver(*args, **kwargs):
pytest.fail('continuity should not invoke CBC for an uninterrupted session')

monkeypatch.setattr(model, '_solver', unexpected_solver)
with TemporaryDirectory() as tmpdir:
model._solve_continuity(tmpdir, None)


def fragmented_model(monkeypatch: pytest.MonkeyPatch) -> Optimizer:
model = build()
seed_fragmented(model, monkeypatch)
Expand Down
19 changes: 19 additions & 0 deletions tests/test_continuity_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,25 @@ def test_api_returns_continuous_equal_price_sessions(second_c_min: float | None)
assert battery['state_of_charge'][-1] == pytest.approx(1500, abs=0.1)


def test_api_keeps_a_running_session_charging():
model = build()
model.time_series.p_N = [0.0003] * 6
model.batteries[0].c_active = True
request = {
'batteries': [{key: value for key, value in asdict(model.batteries[0]).items() if value is not None}],
'time_series': asdict(model.time_series),
'eta_c': 1,
'eta_d': 1,
}

response = app.test_client().post('/optimize/charge-schedule', json=request)

assert response.status_code == 200
charging = response.get_json()['batteries'][0]['charging_power']
assert charging[0] > 0
assert starts(charging) == 1


@pytest.mark.parametrize('strategy', ['attenuate_demand_peaks', 'attenuate_feedin_peaks', 'attenuate_grid_peaks'])
def test_each_grid_peak_is_preserved(monkeypatch: pytest.MonkeyPatch, strategy: str):
model = build(strategy)
Expand Down