Skip to content

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

Draft
andig wants to merge 3 commits into
mainfrom
perf/objective-scaling
Draft

perf: scale the objective before handing the model to the solver#104
andig wants to merge 3 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
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