perf: cap the grid limit excess switches at what a step can absorb - #121
Draft
andig wants to merge 3 commits into
Draft
perf: cap the grid limit excess switches at what a step can absorb#121andig wants to merge 3 commits into
andig wants to merge 3 commits into
Conversation
e_imp_lim_exc only opens once the regular import portion sits at p_max_imp, and that switch was written against the global big-M of 1e6 Wh. The most import a step can absorb is its demand plus the grid charge capacity, three orders less on a typical request, so the relaxation was free to put the binary at a fraction and the excess at an amount no schedule could use. The bound that comes back is useless and CBC pays for it in search. Worst case seen is 020-weird-charging-at-night once a demand rate is present: 45.3 s and 10974 nodes, down to 4.8 s and 1266 nodes for the same objective to the last digit. Truncated to 120 steps and swept over the rate, the four points measured go 53.5 s to 4.0 s in total, with the node counts dropping from 5318, 8359, 123 and 171 to 70, 14, 24 and 10. The cap is a valid upper bound, so no integer point is excluded and every stored case returns the objective it returned before. The two identical branches of the import limit and of the energy balance collapse into one, they had drifted into being byte for byte the same. The export side has the same loose big-M. Tightening it the same way makes CBC land 0.03 percent worse on 013-grid-export-limit-hit, so it stays as it is.
The suite had no case setting prc_p_exc_imp, so the whole demand rate path went unexercised. 028 shaves a load spike with the battery down to the discharge limit and reports the remaining 1000 Wh as overshoot. Every number in it is reachable by hand: 3500 Wh at 0.0003 plus 1000 W at 0.01 is the 11.05 it costs.
andig
marked this pull request as draft
July 27, 2026 06:37
The mirror of the previous commit. `e_exp_lim_exc` only opens once the regular export sits at `p_max_exp`, and that switch was written against the global big-M too. The most a step can put on the wire is its solar plus the discharge capacity of the batteries allowed to discharge to the grid, three orders less, so the relaxation was free to put the binary at a fraction and the excess at an amount no schedule could ever reach. - Cap the switch at `ft[t] + cap_d_exp * dt/3600`. No stored case puts an export limit on an instance large enough to time, so put one on `020-weird-charging-at-night` and sweep it, the way the demand rate was swept for the import side. Five points go from 35.1 s to 5.7 s in total and from 370, 329, 12, 101 and 11060 nodes down to 28, 12, 12, 22 and 20, for the same objective to the last digit at every point. - The cap is a valid upper bound, so no integer point is excluded. All 20 stored cases under all 5 charging strategies return the same internal objective to 1e-9 relative. - `013-grid-export-limit-hit` reports 0.03 percent less, and gives up nothing. The internal objective is identical and so is every euro of it: 14.959982712 of grid cost and 15.167416515 of closing battery value, both to nine decimals, off identical closing SOCs. What moves is the reported clean objective, which measures the battery as `s[T-1] - s[0]` while `s[0]` already contains the first step's charging. The new schedule leaves 96 Wh less in battery 0 and 107 Wh more in battery 1 at the end of step 0, and `sum(s[0] * p_a)` rises by 0.005888291, which is the reported drop to the last digit. The stored response is regenerated for that reason. - Add the structural tests, parametrised over `discharge_to_grid` since a battery kept off the grid contributes nothing to the cap, plus the ordering invariant on the export side.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follows the demand rate slowdown found while measuring #104.
Both
e_imp_lim_excande_exp_lim_exconly open once the regular portion of their side sits at the limit, and both switches were written against the global big-M of 1e6 Wh. The most import a single step can absorb is its demand plus the grid charge capacity, and the most export it can put on the wire is its solar plus the discharge to grid capacity — three orders less on a typical request. So the LP relaxation is free to put the binary at a fraction and the excess at an amount no schedule could ever use. The bound that comes back is worthless and CBC pays for it in branch and bound.gt[t] + c_max * dt/3600instead.020-weird-charging-at-nightwith a demand rate goes from 45.3 s and 10974 nodes to 4.8 s and 1266 nodes, for the same objective to the last digit. Truncated to 120 steps and swept over the rate, four measured points go from 53.5 s to 4.0 s in total, node counts from 5318, 8359, 123 and 171 down to 70, 14, 24 and 10.ft[t] + d_max * dt/3600. No stored case puts an export limit on an instance large enough to time — 013 has 31 steps and 022 has 4 — so put ap_max_expon020and sweep that, the mirror of the demand rate sweep. Five points go from 35.1 s to 5.7 s in total, node counts from 370, 329, 12, 101 and 11060 down to 28, 12, 12, 22 and 20, same objective at every point.p_max_exp013-grid-export-limit-hitis the one stored case whose reported objective moves, by 0.03 percent, and it gives up nothing. The internal objective is identical and so is every euro of it: 14.959982712 of grid cost and 15.167416515 of closing battery value, both to nine decimals, off identical closing SOCs. What moves is the reported clean objective, which measures the battery ass[T-1] - s[0]whiles[0]already contains the first step's charging. The new schedule leaves 96 Wh less in battery 0 and 107 Wh more in battery 1 at the end of step 0, andsum(s[0] * p_a)rises by 0.005888291 — the reported drop to the last digit. The stored response is regenerated for that reason. Worth remembering when reading anyobjective_valuedelta: compare the internal objective, not the reported one.028-demand-rate-peak-shaving. No stored case setprc_p_exc_imp, so the demand rate path had no coverage at all. The case shaves a load spike with the battery down to the discharge limit and reports the remaining 1000 Wh as overshoot, and every number in it is reachable by hand: 3500 Wh at 0.0003 plus 1000 W at 0.01 gives the 11.05 it costs.discharge_to_grid, because a battery kept off the grid contributes nothing to the cap.if/elsepairs whose branches had drifted into being byte for byte identical, in the import limit and in the energy balance.Three things I tried and backed out, all valid formulations that CBC simply likes less:
y, which would close a real modelling gap since nothing currently stops the model from importing beyond the limit and exporting in the same step. It fixes three of the four sweep points and blows the fourth up from 2.2 s to 49.4 s, and takes the full case to 101 s.m_expandm_imp, the flow direction big-Ms, which still fall back to the global big-M whenever the opposite side has a limit. That fallback was there because the opposite side's excess used to be unbounded, and after these two caps it no longer is, so it is now over-conservative. The tighter bound is exact: the opposite side's excess is ungated byy, and thegtorftpart of its cap cancels against this side's balance term, leavingm_exp = ft[t] + (cap_d_exp + cap_c_imp) * dt/3600whenp_max_impis set and the mirror form_imp, which dropsself.Mand both conditionals out of the block. Each half can be measured on its own, since a sweptp_max_impon020moves onlym_expand a sweptp_max_expmoves onlym_imp: them_exphalf goes 15.8 s to 19.4 s, worse at four of the five points and slower per node rather than deeper (worst point 9.2 s to 12.9 s on 918 down to 777 nodes), and them_imphalf is a wash at 5.7 s either way. Same objective at every point in both sweeps. Sound, and not worth it.On the one case where the tightening did move the reported objective,
013again, replaying the loose model's solution into the tightened model showed the only violations were the SOC equalities at 5e-4 Wh of dump rounding, and the replayed objective came out above the tightened model's own proven optimum — so the 3e-6 EUR gap was CBC stopping on a plateau, not an excluded point. Worth reaching for whenever a reformulation looks like it lost something.TODO