Skip to content

perf: cap the grid limit excess switches at what a step can absorb - #121

Draft
andig wants to merge 3 commits into
mainfrom
perf/tighten-grid-limit-bigm
Draft

perf: cap the grid limit excess switches at what a step can absorb#121
andig wants to merge 3 commits into
mainfrom
perf/tighten-grid-limit-bigm

Conversation

@andig

@andig andig commented Jul 26, 2026

Copy link
Copy Markdown
Member

Follows the demand rate slowdown found while measuring #104.

Both e_imp_lim_exc and e_exp_lim_exc only 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.

  • Cap the import switch at gt[t] + c_max * dt/3600 instead. 020-weird-charging-at-night with 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.
  • Cap the export switch at 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 a p_max_exp on 020 and 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_exp before after nodes before nodes after
2000 2.28 s 1.41 s 370 28
3000 2.11 s 0.96 s 329 12
5000 1.12 s 1.06 s 12 12
8000 2.00 s 1.14 s 101 22
12000 27.55 s 1.10 s 11060 20
total 35.06 s 5.67 s
  • Both caps are valid upper bounds, so no integer point is excluded. All 20 stored cases under all 5 charging strategies return the same internal objective as before, to 1e-9 relative.
  • 013-grid-export-limit-hit is 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 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 — the reported drop to the last digit. The stored response is regenerated for that reason. Worth remembering when reading any objective_value delta: compare the internal objective, not the reported one.
  • Add 028-demand-rate-peak-shaving. No stored case set prc_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.
  • Add structural tests pinning both switch coefficients, since a timing assertion would only measure the machine. The export one is parametrised over discharge_to_grid, because a battery kept off the grid contributes nothing to the cap.
  • Collapse two if/else pairs 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:

  • Gating the excess variables with the flow direction binary 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.
  • Dropping the ordering binary in demand rate mode. It collapses the search uniformly, but it lets the model exploit exactly that gap, so the objective moves.
  • Tightening m_exp and m_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 by y, and the gt or ft part of its cap cancels against this side's balance term, leaving m_exp = ft[t] + (cap_d_exp + cap_c_imp) * dt/3600 when p_max_imp is set and the mirror for m_imp, which drops self.M and both conditionals out of the block. Each half can be measured on its own, since a swept p_max_imp on 020 moves only m_exp and a swept p_max_exp moves only m_imp: the m_exp half 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 the m_imp half 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, 013 again, 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

  • The ungated excess variables are a real modelling gap independent of performance. Worth closing once the branching behaviour is understood.

andig added 2 commits July 26, 2026 22:50
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
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.
@andig andig changed the title perf: cap the grid import excess switch at what a step can absorb perf: cap the grid limit excess switches at what a step can absorb Jul 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant