Skip to content
Open
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,21 @@ The maximum is a single value out of the horizon, which leaves one gap: a load s
<img alt="Grid exchange over 24 hours without a strategy and with attenuate_grid_peaks, showing the import peak dropping from 11.5 kW to 3.6 kW" src="docs/img/example-peak-light.svg">
</picture>

## How a request is solved

One request is up to five solver runs under one wall clock, `OPTIMIZER_TIME_LIMIT` (10 s in production). Each stage keeps what the previous one found unless it can improve on it without spending money. The request log carries where the clock went (`stages`), which path was taken (`path`), and what the tie break (`preferences`) and the continuity pass (`continuity`) did.

| Stage | Task | Runs when | Parameters |
|---|---|---|---|
| `build` | Build the MILP and scale its objective so the largest coefficient sits at `OBJECTIVE_TARGET`. | Always. | `OBJECTIVE_TARGET` 1e6 |
| `probe` | Solve cost and preferences together. Proven optimal means the tie is decided in one solve: path `joint`, nothing else runs on the money. | Unless `OPTIMIZER_PROBE_SECONDS` is 0. | `OPTIMIZER_PROBE_SECONDS`, default `PROBE_SHARE` 0.2 of the limit |
| `cost` | Money only, stopped on an absolute gap. Holds back a slice of the clock for the tie break instead of taking whatever is left. | The probe did not prove its answer: path `split`. | `OPTIMIZER_GAP_ABS` 0.01 currency, `PREFERENCE_TIME_SHARE` 0.25 of the limit reserved |
| `tie_break`, LP | Pin the binaries the cost stage chose and move only the continuous variables, under a bound that keeps the cost found. Milliseconds, so it runs whatever the clock says. If CBC calls the bound infeasible the slack is widened tenfold per retry. | Path `split` and a strategy is configured. | `OPTIMIZER_PREFERENCE_BUDGET` 0, `COST_BOUND_SLACK` 1e-5 up to `COST_BOUND_SLACK_CEILING` 1e-2, `COST_BOUND_TOLERANCE` 1e-4, `LP_PREFERENCE_TIME_LIMIT` 1 s |
| `tie_break`, MILP | Search the whole model under the same bound to beat the LP. Whichever is ahead is returned. | After the LP, clock permitting. | The reserved slice, capped at `MILP_PREFERENCE_TIME_LIMIT` 2.5 s; uncapped without a time limit |
| `continuity` | Fewest charge starts for batteries with `c_min > 0`, bounded by the cost, the preference value and each levelled grid peak already reached. A preference, not a guarantee: prices, charge demands and grid shaping still win, and power may vary within a session. | A battery has more than one charging session, and the solve so far took less than the stage may spend. | `CONTINUITY_TIME_LIMIT` 1 s, `CONTINUITY_TOLERANCE` 1e-5 |

A schedule the solver stopped on at the limit is reported as `Feasible` rather than `Optimal`. A solve that comes back off the integers is refused and reported as `Not Solved`.

## API

`POST /optimize/charge-schedule` takes the whole problem as one JSON document and returns the schedule. `GET /optimize/health` is the liveness probe. Every field is documented in [`openapi.yaml`](openapi.yaml).
Expand Down
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.

7 changes: 6 additions & 1 deletion docs/comparison_objective_terms.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,9 @@
then maximizes tier 3 over the schedules that keep that value, so the distance between the tiers
no longer decides whether a preference is respected. The ranges listed above are what the second
stage works on, and it normalizes them off its own largest coefficient before solving.

- After those objectives are decided, a final pass minimizes charging starts for batteries with
`c_min > 0`. It bounds both achieved objectives and each leveled grid peak separately, so fewer
interruptions cannot compensate for worse economics, preferences, or peaks beyond numerical
tolerances. This pass adds no term to either earlier objective. It runs only for fragmented
schedules, uses at most one second of solver time within the remaining request budget, and
retains the incumbent unless a valid schedule has fewer actual starts.
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
89 changes: 89 additions & 0 deletions src/optimizer/optimizer.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import shutil
import time
from dataclasses import dataclass
from math import isfinite
from tempfile import TemporaryDirectory
from typing import Dict, List, Optional

Expand Down Expand Up @@ -67,6 +68,10 @@ def objective_scale(objective) -> float:
return OBJECTIVE_TARGET / max(coefficients)


def _complete_solution(problem: pulp.LpProblem) -> bool:
return all(var.name == '__dummy' or (var.varValue is not None and isfinite(var.varValue)) for var in problem.variables())


# name of the constraint the second stage adds to keep the money the first stage found
COST_BOUND = 'cost_bound'

Expand Down Expand Up @@ -102,6 +107,9 @@ def objective_scale(objective) -> float:
# request that is hard on money still gets the money right and only loses part of the tie break
PREFERENCE_TIME_SHARE = 0.25

CONTINUITY_TIME_LIMIT = 1.0
CONTINUITY_TOLERANCE = 1e-5

# a cbc on PATH is preferred over the one pulp bundles, which is 2.10.3 built Dec 2019 and gets a
# MIP start wrong on this model, see _solve_preferences. None falls back to the bundled binary, so
# a checkout without cbc installed still runs. The Dockerfile installs one.
Expand Down Expand Up @@ -136,6 +144,7 @@ class BatteryConfig:
p_demand: Optional[List[float]] = None # Minimum charge demand (Wh)
s_goal: Optional[List[float]] = None # Goal state of charge (Wh)
c_priority: int = 0
c_active: bool = False # Whether the device is charging at the start of the horizon


@dataclass
Expand Down Expand Up @@ -788,6 +797,85 @@ def _solve_preferences(self, tmpdir, deadline) -> None:
var.varValue = value
self.problem.status = pulp.LpStatusOptimal

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

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

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

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

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

before = count_starts()
# a device that is charging already can reach zero starts, one that is idle needs one
if all(count <= 1 - charging_now(i) for i, count in zip(eligible, before)):
return
remaining = CONTINUITY_TIME_LIMIT if deadline is None else min(CONTINUITY_TIME_LIMIT, deadline - time.monotonic())
if remaining <= 0:
return

solution = {var: var.varValue for var in self.problem.variables()}
cost = pulp.value(self.cost_objective)
preference = pulp.LpAffineExpression(self.preference_objective)
# Normalize tiny preference coefficients so CBC's row tolerance cannot erase their bound.
scale = 1 / max((abs(value) for value in preference.values() if value), default=1)
preference *= scale
preferred = pulp.value(preference)

# A shallow copy shares solution variables but leaves the reusable model's constraints intact.
candidate = self.problem.copy()
candidate += self.cost_objective >= cost - CONTINUITY_TOLERANCE
candidate += preference >= preferred - CONTINUITY_TOLERANCE
peak_values = {side: pulp.value(self.variables[f'p_{side}_peak']) for side in self.peak_sides}
for side in self.peak_sides:
candidate += self.variables[f'p_{side}_peak'] <= peak_values[side] + CONTINUITY_TOLERANCE
starts = []
for i in eligible:
active = self.variables['z_c'][i]
for t in self.time_steps:
start = pulp.LpVariable(f'charge_start_{i}_{t}', lowBound=0, upBound=1)
candidate += start >= active[t] - (active[t - 1] if t else charging_now(i))
starts.append(start)
candidate.setObjective(-pulp.lpSum(starts))

improved = False
try:
if deadline is not None:
remaining = min(remaining, deadline - time.monotonic())
if remaining <= 0:
return
candidate.solve(self._solver(tmpdir, timeLimit=remaining))
if (candidate.sol_status not in (pulp.LpSolutionOptimal, pulp.LpSolutionIntegerFeasible)
or not _complete_solution(candidate) or not self._is_integral()):
return
# CBC writes its solution with 8 significant digits, so a balance row over a 40 kWh
# SOC comes back off by up to 1e-3 and candidate.valid(1e-5) rejects every candidate
# (#146). CBC already held the model rows; only the bounds added here need checking.
improved = (pulp.value(self.cost_objective) >= cost - 2 * CONTINUITY_TOLERANCE
and pulp.value(preference) >= preferred - 2 * CONTINUITY_TOLERANCE
and all(pulp.value(self.variables[f'p_{side}_peak']) <= peak_values[side] + 2 * CONTINUITY_TOLERANCE
for side in self.peak_sides)
and sum(count_starts()) < sum(before))
except pulp.PulpSolverError:
return
finally:
if not improved:
for var, value in solution.items():
var.varValue = value

def _probe_then_split(self, tmpdir, deadline) -> None:
"""
Solve the whole objective if it can be proved quickly, otherwise fall back to the split.
Expand Down Expand Up @@ -868,6 +956,7 @@ def solve(self) -> Dict:

with TemporaryDirectory() as tmpdir:
self._probe_then_split(tmpdir, deadline)
self._solve_continuity(tmpdir, deadline)

# back to the total worth of the solution, neither stage objective on its own
self.problem.setObjective((self.cost_objective + self.preference_objective) * self.objective_scale)
Expand Down
Loading
Loading