diff --git a/AGENTS.md b/AGENTS.md index b098356ed..e9f6199b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +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. - The objective is assembled in two parts, `cost_objective` (real money: grid cost, export revenue, battery value, demand rate, penalties) and `preference_objective` (the tie breakers, peak and ramp weights, priorities), and `solve()` optimizes them in that order. A new term goes into the part it belongs to, `tests/test_objective_split.py` asserts the split stays exhaustive. - `solve()` runs two stages. The first maximizes `cost_objective` and may stop `OPTIMIZER_GAP_ABS` short of the optimum. The second maximizes `preference_objective` under a constraint that keeps the money the first found, so the strategies decide the tie instead of being swallowed by a gap that is orders larger than they are. A second stage that times out or comes back infeasible falls back to the first stage schedule whole, `preference_stage` records how it ended. The tiny slack the bound needs, `COST_BOUND_SLACK`, is spent on every request: the second stage is indifferent to money and drops straight to the bound. -- `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. +- `get_clean_objective_value()` measures battery value as `(s[T-1] - s_initial) * p_a`. The reference is `s_initial`, not `s[0]`: `s[0]` is the state after the first time step and already carries that step's charging, so subtracting it would drop the first step's charge from the reported value. The optimization objective itself uses the absolute final state of charge, `s[-1] * p_a`, so the two agree up to the constant `s_initial * p_a`. - 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. - `problem.solve()` returns a `pulp.LpSolveStats`; the problem itself carries no status. `stats.status in PROVEN` (`Optimal`, or `GapLimit` for CBC's "Optimal (within gap tolerance)") means proved, a run stopped by the clock reports `TimeLimit` and carries what it found in `stats.has_solution`: on one captured request a 2 s and a 30 s run both came back with a schedule, with objective values of -682466848 and 59714881. `Optimizer.stats` holds the stats of the schedule currently in the variables, `solve()` reports proved as `Optimal` and everything else with a schedule as `Feasible`. - pulp 4 variables belong to the problem (`self.problem.add_variable`), constraints cannot change once added, and `problem.copy()` copies the variables too, so a copy is addressed through its own `variablesDict()`.