Skip to content

perf: scale the objective before handing the model to the solver - #104

Merged
andig merged 11 commits into
mainfrom
perf/objective-scaling
Jul 26, 2026
Merged

perf: scale the objective before handing the model to the solver#104
andig merged 11 commits into
mainfrom
perf/objective-scaling

Conversation

@andig

@andig andig commented Jul 24, 2026

Copy link
Copy Markdown
Member

The grid peak shaping work (#93, #96, #102) made the peak attenuation strategies noticeably slower: measured against the commit before them, attenuate_grid_peaks costs about 1.3x median and up to 3x on single cases, while the default strategies are unaffected. Looking for the cause turned up something more general than the peak code.

The problem

CBC judges improvements against absolute tolerances of about 1e-7. Prices enter the model in currency per Wh, so the raw objective coefficients land close to that bound. The peak attenuation tie-breakers are the extreme case at roughly 1e-9 per W, but the cost terms are not far above it either. Improvements that small are indistinguishable from numerical noise, so the solver explores a degenerate search tree and stops on solutions that are not actually optimal.

The change

Multiply the assembled objective by a constant before handing it to the solver. This does not change the argmax, it only moves the coefficients into a range the solver can resolve. get_clean_objective_value() recomputes the reported value from the solution, so the API response stays in the original unit and nothing in the contract changes.

The second commit removes a ramp variable that the peak strategies create for the first time step but never constrain. It carries the ramp penalty, is driven to zero, and contributes nothing.

Measurements

All test cases, each run with its stored strategy and with every charging strategy (14 cases x 5 settings), old and new interleaved in one process, min of 7 runs:

before after
total solve time 12.78 s 10.07 s 0.79x
worst case, none 1.31 s 0.79 s 0.60x
worst case, attenuate_grid_peaks 2.17 s 1.03 s 0.47x

No solution came out economically worse. Several came out better, the largest being 021-min-pv-use-case, where the previous model returned a solution 0.59% below the optimum. 018-high-soc-initial got slower (0.13 s to 0.23 s with attenuate_grid_peaks); that is branching luck on one instance, not a trend.

The gain is not limited to the peak strategies, the default path benefits as much. It does not remove the extra work the peak strategies add to the model (one peak variable, T - 1 ramp variables and 3T - 2 constraints per leveled side), it makes the solver handle the whole model better.

Test

tests/test_objective_scaling.py pins the recovered optimum. It reads the request from the existing 021 case, solves it without a charging strategy, and asserts a lower bound on the objective, so a future model that finds an even better solution there does not fail it. On main the same request returns 1.4243782673 against a threshold of 1.4327495658.

andig added 3 commits July 24, 2026 16:11
CBC judges improvements against absolute tolerances of about 1e-7. Prices
are given in currency per Wh, so the raw objective coefficients land close
to that bound: the peak attenuation tie-breakers sit around 1e-9 per W, and
the cost terms are not far above. Improvements that small are indis-
tinguishable from numerical noise, so the solver both explores a degenerate
search tree and stops on solutions that are not optimal.

Multiplying the assembled objective by a constant does not change its
argmax, but it lifts the coefficients into a range the solver can resolve.
The reported objective value is recalculated from the solution by
get_clean_objective_value(), so the API response stays in the original unit
and no expected test result changes because of the scaling itself.

Measured over all test cases, each run with the stored strategy and with
every charging strategy (14 cases x 5 settings, interleaved A/B, min of 7):

  total solve time         12.78 s -> 10.07 s   (0.79x)
  worst case, none          1.31 s ->  0.79 s   (0.60x)
  worst case, grid peaks    2.17 s ->  1.03 s   (0.47x)

No solution got economically worse, several got better. The largest gain is
021-min-pv-use-case, where the previous model returned a solution 0.59%
below the optimum. Case 018-high-soc-initial got slower (0.13 s -> 0.23 s
with attenuate_grid_peaks), which is branching luck rather than a trend.

The new test case pins the recovered optimum: it fails without the scaling.
The peak attenuation strategies create one ramp variable per time step, but
the ramp constraints only cover the steps that have a predecessor. The
variable of the first step is therefore never constrained, it only carries
the ramp penalty and is driven to zero. Index the ramps by the step they
lead into instead, which removes one variable per leveled grid side without
changing the model.
…ed case

The scenario that proves the objective scaling is 021 solved without a
charging strategy. Adding it as a data driven case duplicated 2839 lines of
fixture that differ from the existing file in one field. Read the request
from that file instead and assert the objective in a dedicated test module,
as AGENTS.md suggests for behaviour the data driven harness cannot express.

The assertion is a lower bound rather than an equality: a future model that
finds an even better solution here should not fail this test.

Also drops the duplicated rationale at the call site, the comment on
OBJECTIVE_SCALE covers it.
@andig

andig commented Jul 24, 2026

Copy link
Copy Markdown
Member Author

@ekkea should probably revisit this after #88 has been evaluated

@andig
andig marked this pull request as draft July 24, 2026 15:52
andig and others added 4 commits July 25, 2026 13:45
…t moves

Both tests on this branch fail against current main.

`test_objective_scaling` asserted 021 reaches 1.4327495658 with the charging strategy forced
to `none`. That number is not reachable and never was: with `none` the case returns
1.4243782720 at every scale from 1e0 to 1e8, and 1.4327495637 is what 021 returns under its
stored `attenuate_demand_peaks` strategy *unscaled*. The threshold was read off a different
strategy and the wrong side of the change, so the test could only ever fail.

The 0.59 percent recovery it was written to pin does not reproduce either. Main has moved
since the measurement, and #88 tightening the indicator big-Ms already removed that
suboptimal stop. Measured across all 19 cases x 5 charging strategies, scaled against
unscaled, the true objective now differs by at most 6e-7 anywhere. The speed claim does
hold: 14.34 s against 10.78 s in total, 0.75x.

So the test now pins the guarantee the change can actually make, on three cases x three
strategies: a uniform positive factor cannot move the argmax, so the solver must not stop
anywhere worse than it does unscaled. It compares the model objective rather than the
reported one, because `get_clean_objective_value` credits battery gain as `s[T-1] - s[0]`
and every one of these cases charges more in the first step once scaled. That reads as a
loss of about half a percent where the true objective is in fact a shade better, which is
the same `s[0]` quirk documented in AGENTS.md and the reason #88 had to refresh 013.

`019` is refreshed for exactly that reason. Its true objective moves by +2.2e-07, from
2.7861544729 to 2.7861546924, while first step charging goes from 666.67 Wh to 724.44 Wh and
the reported value therefore drops 0.71 percent. No money is given up. `012` and `021` show
the same effect but only under the peak strategies, which the golden suite does not exercise
for those cases, so their stored responses are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI runs on linux, where the scaled and unscaled solves of 019 without a charging strategy land
4e-9 apart. Asserting at 1e-9 measured that noise rather than the change: CBC converges to about
1e-7 in objective units, so nothing tighter than that says anything about the model.

A part per million is still three orders below the smallest move worth catching, the 0.4 percent
that the strategy weight table records at x10.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@andig
andig marked this pull request as ready for review July 26, 2026 12:36
@ekkea

ekkea commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

The risk of undercutting the solver's precision floor is a good finding ...

... but hang on, we shall have a look at the max. spread of double precision as well. Let me check with Claude ....

... in order to make sure that the scaling of penalties is consistent and considers all real price inputs to the model.
@ekkea

ekkea commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Objective term hierarchy — updated after the penalty_base / prc_p_goal_pen fixes

Source: src/optimizer/optimizer.py. Scaled ranges use the penalty_base range actually observed
across test_cases/*.json (1.6e-4 to 0.3) times OBJECTIVE_SCALE = 1e6, unless noted otherwise.

What changed

  1. penalty_base (optimizer.py:104-125) now derives
    from the largest real currency/Wh rate across all economic terms — import price, export price,
    every battery's p_a, and the demand-rate tariff prc_p_exc_imp (converted from currency/W to
    currency/Wh via horizon_hours = sum(dt)/3600, the unit conversion implied by the
    e_imp_lim_exc[t] <= p_max_imp_exc * dt[t]/3600 constraint). Previously it only looked at
    max_import_price, so p_a, p_E, and prc_p_exc_imp could each independently overrun the
    tier-1 penalties without penalty_base ever noticing.
  2. prc_p_goal_pen (optimizer.py:129) dropped the stray
    * max(dt)/3600 factor and now reads penalty_base * 10e1, identical in form to
    prc_e_goal_pen. Both penalize Wh-denominated shortfalls (p_demand_pen, s_goal_pen) and
    are meant to carry the same tier-1 weight; the old factor silently cut it to a quarter of that
    for 15-minute time steps, with no unit or domain justification.

Tier table

Tier Term Formula Multiple of penalty_base Scaled coefficient range Intent
1 — hard-avoidance SOC limit exceedance prc_soc_exc_pen = penalty_base·1000 ×1000 1.6e5 – 3.0e8 must basically never happen
Unmet charge goal prc_e_goal_pen = penalty_base·100 ×100 1.6e4 – 3.0e7 strong avoid
Unmet charge demand prc_p_goal_pen = penalty_base·100 (fixed — was ·(max dt/3600)·100) ×100 1.6e4 – 3.0e7 strong avoid, now equal weight to the goal penalty above, as intended
Import limit exceedance prc_e_grid_imp_pen = penalty_base·100 ×100 1.6e4 – 3.0e7 strong avoid
Export limit exceedance prc_e_grid_exp_pen·(1−t·1e-5) = penalty_base·100·(≈1) ×100 1.6e4 – 3.0e7 strong avoid
2 — real economics Grid import cost p_N[t] (raw, and now also feeds penalty_base via max_import_price) ~-1 – 3e5 actual currency
Grid export revenue p_E[t] (raw, now also feeds penalty_base via max_export_price) ~0 – 3e5 actual currency
Final SOC value bat.p_a (raw, now also feeds penalty_base directly) ~1.7e2 (up to whatever p_a is configured) actual currency
Demand-rate charge grid.prc_p_exc_imp (raw; prc_p_exc_imp / horizon_hours now also feeds penalty_base) unbounded, but tier 1 now tracks it actual currency
3 — tie-break nudges Peak leveling prc_p_peak = penalty_base·1e-3 ×1e-3 0.16 – 300 shape, not cost
Charge-before-export min_import_price·2e-5·(T−t) ×2e-5·(T−t) ~1e-3 – tens shape, not cost
Discharge-before-import min_import_price·5e-6·(T−t) ×5e-6·(T−t) ~1e-4 – tens shape, not cost
Charging priority min_import_price·5e-5·(T−t)·c_priority ×5e-5·(T−t)·c_priority ~1e-3 – tens shape, not cost
Ramp leveling prc_p_ramp = penalty_base·1e-5 ×1e-5 1.6e-3 – 3.0 shape, not cost

Net effect

Tier 1 now scales with the largest of any real economic term the model can see — not just the
energy price series — so a large demand-rate tariff or an unusually high p_a inflates
penalty_base (and therefore every tier-1 penalty) right along with it, keeping tier 1 ≫ tier 2
regardless of which real-cost term happens to dominate for a given request. Within tier 1 itself,
prc_p_goal_pen and prc_e_goal_pen are now always equal, so the choice between sacrificing an
unmet charge demand vs. an unmet charge goal is decided by the model's deliberate tie-breakers
(the (1 + (T−t)/T) front-loading factor), not by an accidental side effect of the input time
series' step length.

@ekkea

ekkea commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

I hope I didn't break anything in regards to parallel PRs, I didn't check against that one yet. My fix above was about making sure precision losses coming from poorly scaled penalties do not affect the optimization results, not really about performance.

ekkea and others added 3 commits July 26, 2026 20:22
…time step

The demand charge is set by the single worst time step, so 1 Wh kept out of the
binding step is worth 3600/dt of it. Converting the rate over the horizon instead
made penalty_base up to T times too small. The SOC floor penalty is charged per
time step, which hides that for a violation lasting the whole horizon, but one
confined to a single short step is charged once and wins: 1000 Wh of load in a
36 s step at the end of a 23 h horizon drained the battery to 48952.6 against a
floor of 50000.

Also gate the term on is_grid_demand_rate_active. p_max_imp and prc_p_exc_imp are
independent optional fields, so a rate without an import threshold used to inflate
every penalty for a term that is not in the model at all.
…s out

The repository already keeps documentation in docs/. Add the two properties the
tier table does not show: the SOC penalty is charged per time step, and the
min_import_price tie breakers invert under negative prices.
@andig

andig commented Jul 26, 2026

Copy link
Copy Markdown
Member Author

Pushed two commits fixing what a review of the last two commits turned up.

The demand rate conversion was the wrong quantity. prc_p_exc_imp / horizon_hours is the value of 1 W spread over every time step. What competes with the penalties is the marginal value at the binding step: e_imp_lim_exc[t] <= p_max_imp_exc * dt[t]/3600, so 1 Wh kept out of that step buys back 3600/dt[t] W of demand charge. Converting over the shortest step is the version that keeps tier 1 above tier 2 everywhere.

The horizon version looked right because s_min_pen is charged per time step: a violation lasting the whole horizon is paid for T times over, which cancels a penalty_base that is T times too small. A violation confined to one short late step is paid for once and wins. Reproduced against the branch as it stood, 1000 Wh of load in a 36 s step at the end of a 23 h horizon drained the battery to 48952.6 against a floor of 50000, while the same case with a 1 h final step held. Real inputs reach those ratios, 023-c_min-limit-kept-with-p_demand-set runs 31 s steps over 34 h.

penalty_base counted a demand rate that is not in the model. The gate was prc_p_exc_imp is not None, but the objective term only exists when is_grid_demand_rate_active, which also needs p_max_imp. Both are independent optional fields in the API, so a rate without a threshold inflated every penalty for a term that is not there. Now gated on the flag, computed above the scaling block.

Table moved into docs/ next to the rest of the documentation, with the two properties it does not show: the SOC penalty is charged per time step, and the min_import_price tie breakers invert sign under negative prices.

Three new tests, all red before the fix, suite is 39 green.

Open point: the new conversion raises penalty_base by sum(dt)/min(dt) whenever a demand rate is active, which widens the coefficient range this PR set out to tighten. No test case carries prc_p_exc_imp, so the suite says nothing about it. A data driven case with a demand rate would be worth adding before merge.

@andig

andig commented Jul 26, 2026

Copy link
Copy Markdown
Member Author

Measured the open point. The larger penalty_base does not cost solver time.

No stored case carries prc_p_exc_imp, so this injects one (p_max_imp 1000 W, prc_p_exc_imp 0.05 currency/W) into five cases and solves each with none and attenuate_grid_peaks, min of 3 runs, both divisors in the same process.

case strategy horizon shortest step
012-early-charging-not-perfect none 0.063 s 0.068 s
012-early-charging-not-perfect attenuate_grid_peaks 0.156 s 0.152 s
019-unexpected-charge-spikes none 0.043 s 0.045 s
019-unexpected-charge-spikes attenuate_grid_peaks 0.056 s 0.062 s
020-weird-charging-at-night none 123.440 s 63.969 s
020-weird-charging-at-night attenuate_grid_peaks 4.988 s 5.241 s
021-min-pv-use-case none 0.694 s 0.514 s
021-min-pv-use-case attenuate_grid_peaks 1.029 s 2.793 s
023-c_min-limit-kept-with-p_demand-set none 0.347 s 0.261 s
023-c_min-limit-kept-with-p_demand-set attenuate_grid_peaks 0.503 s 0.898 s
total 131.3 s 74.0 s

penalty_base goes up by 200x on 012 and by 4000x on 023, which has 31 s steps over 34 h, and the coefficients stay well inside what CBC handles. The total is dominated by one case, so read it as "no regression" rather than as a speedup: 021 and 023 got slower under the peak strategy, the rest is flat.

Worth noting that 020 with no strategy takes over a minute either way once a demand rate is present, well past the 10 s time limit from #116. Separate issue, but the demand rate path looks like the expensive one.

@ekkea

ekkea commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Pushed two commits fixing what a review of the last two commits turned up.

Interesting ... did you verify or is this what Claude said?

@andig

andig commented Jul 26, 2026

Copy link
Copy Markdown
Member Author

Re-measured the gain over the stored cases against current main, since the numbers in the description predate #113 through #119 and the case set has grown to 19. All 19 cases under all 5 charging strategies, min of 3 per run, best of two full runs, main at 59ca6dc versus the branch at cffe7ed.

main branch ratio
total, 95 solves 14.15 s 10.48 s 0.74x
run to run 14.25 / 14.40 s 10.55 / 10.77 s stable
strategy main branch ratio worst case
none 1.832 s 1.870 s 1.02x 0.611 s to 0.593 s
charge_before_export 1.893 s 1.765 s 0.93x 0.645 s to 0.655 s
attenuate_demand_peaks 3.861 s 2.241 s 0.58x 1.878 s to 0.862 s
attenuate_feedin_peaks 2.309 s 2.107 s 0.91x 0.752 s to 0.775 s
attenuate_grid_peaks 4.255 s 2.497 s 0.59x 1.819 s to 0.826 s

Total is better than the 0.79x in the description, but the shape is different: the gain is entirely in the peak strategies and none comes out flat, where the description has worst case none going 1.31 s to 0.79 s. Nearly all of it is one case, 021-min-pv-use-case under attenuate_demand_peaks at 1.878 s to 0.255 s and under attenuate_grid_peaks at 1.819 s to 0.345 s. Largest regression is 023-c_min-limit-kept-with-p_demand-set under attenuate_grid_peaks, plus 0.106 s.

All 95 solves come back Optimal. Three report a lower objective, up to 0.71 percent, and four report a higher one, up to 8.1 percent, but that is the get_clean_objective_value artifact the scaling test already documents. Comparing model objectives directly, the two sides agree to 1e-7 relative:

019-unexpected-charge-spikes|charge_before_export    2.7861544729 -> 2.7861546924
021-min-pv-use-case|attenuate_demand_peaks           8.5517901929 -> 8.5517905005
012-early-charging-not-perfect|attenuate_grid_peaks  5.1739101477 -> 5.1739107189
019-unexpected-charge-spikes|none                    2.7887157462 -> 2.7887157437
021-min-pv-use-case|none                             8.5519775421 -> 8.5519775412

So nothing regressed economically, and the reported 5 to 8 percent improvements on 019 are not real gains either. Worth keeping in mind for the description.

None of this covers the penalty_base correction, no stored case sets prc_p_exc_imp so both changed branches are unreachable there. The 0.74x is the objective scaling plus the dropped ramp variable.

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.

2 participants