Skip to content
Draft
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ The model is a maximization problem. Read these before changing it:
- Charging and discharging strategies are cost-neutral tie-breakers. They add tiny soft terms (coefficient around `min_import_price * 1e-6`) that only decide between economically equal solutions. They are intentionally excluded from `get_clean_objective_value()`, which recomputes the real economic value without strategy incentives or penalties.
- `get_clean_objective_value()` measures battery value as `(s[T-1] - s[0]) * p_a`, but `s[0]` already includes the first time step's charging, so energy charged in the first step is not counted as a gain. The optimization objective itself uses the absolute final state of charge, `s[-1] * p_a`. Two solutions that are equal in the real objective can therefore report different clean values. Keep optional charging off the first time step when designing cost-neutrality scenarios.
- Grid limits are soft: exceeding `p_max_imp` or `p_max_exp` is penalized rather than forbidden, so an over constrained request reports the violation instead of returning infeasible.
- The assembled objective is multiplied by `OBJECTIVE_SCALE` before it goes to the solver. Prices per Wh put the raw coefficients close to CBC's absolute tolerances, where real improvements are discarded as numerical noise. Scaling does not change the argmax. Keep new objective terms in the same unit and let the scaling do its work, do not compensate for it in individual coefficients.

## Python Coding Standards

Expand Down
21 changes: 15 additions & 6 deletions src/optimizer/optimizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ class OptimizationStrategy:
'attenuate_grid_peaks': ('imp', 'exp'),
}

# factor the assembled objective is multiplied by before the model goes to the solver. CBC judges
# improvements against absolute tolerances (~1e-7), and with prices given per Wh the raw objective
# coefficients land close to that bound, so real improvements get pruned as numerical noise.
# Scaling does not change the argmax, it only lifts the coefficients into a range the solver can
# resolve. The reported objective value is recalculated from the solution and stays in its
# original unit.
OBJECTIVE_SCALE = 1e6

# grid energy variable and limit exceedance variable per leveled side
PEAK_SIDE_VARIABLES = {
'imp': ('n', 'e_imp_lim_exc'),
Expand Down Expand Up @@ -220,12 +228,13 @@ def _setup_variables(self):
self.variables['p_max_imp_exc'] = pulp.LpVariable("p_max_imp_exc", lowBound=0)

# highest grid power over the whole horizon (W) and step to step ramp of the grid power (W)
# per side, used by the peak attenuation strategies
# per side, used by the peak attenuation strategies. there is no ramp into the first time
# step, so the ramp of step t is held at index t - 1
for side in self.peak_sides:
self.variables[f'p_{side}_peak'] = pulp.LpVariable(f"p_{side}_peak", lowBound=0)
self.variables[f'p_{side}_ramp'] = [
pulp.LpVariable(f"p_{side}_ramp_{t}", lowBound=0)
for t in self.time_steps
for t in range(1, self.T)
]

# Binary variable: power flow direction to / from grid variables
Expand Down Expand Up @@ -364,7 +373,7 @@ def _setup_target_function(self):
objective += self.variables['c'][i][t] * self.min_import_price * 5e-5 * (self.T - t) * bat.c_priority
objective += self.variables['d'][i][t] * self.min_import_price * 5e-5 * (self.T - t) * bat.c_priority

self.problem += objective
self.problem += objective * OBJECTIVE_SCALE

def _add_energy_balance_constraints(self):
"""
Expand Down Expand Up @@ -471,10 +480,10 @@ def _add_energy_balance_constraints(self):

for t in self.time_steps:
self.problem += p_grid[t] <= self.variables[f'p_{side}_peak']
# ramp magnitude: p_ramp[t] >= |p_grid[t] - p_grid[t-1]|
# ramp magnitude: p_ramp[t - 1] >= |p_grid[t] - p_grid[t-1]|
for t in range(1, self.T):
self.problem += self.variables[f'p_{side}_ramp'][t] >= p_grid[t] - p_grid[t - 1]
self.problem += self.variables[f'p_{side}_ramp'][t] >= p_grid[t - 1] - p_grid[t]
self.problem += self.variables[f'p_{side}_ramp'][t - 1] >= p_grid[t] - p_grid[t - 1]
self.problem += self.variables[f'p_{side}_ramp'][t - 1] >= p_grid[t - 1] - p_grid[t]

# if demand rate is applied, the maximum grid import power value
# of all time steps drives the demand rate charge
Expand Down
23 changes: 23 additions & 0 deletions tests/test_objective_scaling.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@

import json
import pathlib

from optimizer.app import app

# 021 solved without a charging strategy. Before the objective was scaled, the solver stopped
# here on a solution about 0.59% below the optimum, so this pins the value it has to reach.
CASE = pathlib.Path('test_cases/021-min-pv-use-case-with-weird-behavior.json')
EXPECTED_OBJECTIVE = 1.4327495658487992


def test_optimum_is_not_cut_off_by_solver_tolerances():
request = json.loads(CASE.read_text())["request"]
request["strategy"] = {"charging_strategy": "none"}

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

assert response.status_code == 200, f"request returned with status {response.status_code}"
assert response.json["status"] == "Optimal"
# not an equality check: a better solution stays acceptable, a worse one does not
assert response.json["objective_value"] >= EXPECTED_OBJECTIVE * (1 - 1e-6), \
f"objective value: {response.json['objective_value']}, expected at least: {EXPECTED_OBJECTIVE}"
Loading