diff --git a/AGENTS.md b/AGENTS.md index 97c9cab80..a1b20c81b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/src/optimizer/optimizer.py b/src/optimizer/optimizer.py index c9d34bf1d..f9e661770 100644 --- a/src/optimizer/optimizer.py +++ b/src/optimizer/optimizer.py @@ -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'), @@ -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 @@ -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): """ @@ -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 diff --git a/tests/test_objective_scaling.py b/tests/test_objective_scaling.py new file mode 100644 index 000000000..0e65d75f0 --- /dev/null +++ b/tests/test_objective_scaling.py @@ -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}"