Skip to content

Speed up scheduling problem preparation for flex-models with many devices - #2516

Draft
Ahmad-Wahid wants to merge 5 commits into
mainfrom
perf/scheduler-pandas-scalars
Draft

Speed up scheduling problem preparation for flex-models with many devices#2516
Ahmad-Wahid wants to merge 5 commits into
mainfrom
perf/scheduler-pandas-scalars

Conversation

@Ahmad-Wahid

Copy link
Copy Markdown
Contributor

What this changes

Building the scheduling problem gets slow when a flex-model has many devices — for
example one device per EV charging session. This makes it faster. Schedules are
unchanged.

Why it was slow

A commitment is a pandas DataFrame. Several of its columns hold the same value in
every row
: the name, the class, the commodity, the deviation prices.

When we split a commitment into sub-commitments, we make one per group — and a group
is usually a single time step. So a site with 20 charge points of 2 EVSEs each ends up
with about 7,400 sub-commitments per schedule, each a DataFrame with a single row.

Building the model then read those values back one at a time, with
df[column].iloc[0]. Every such read makes pandas build a Series and index into it:
a few microseconds, where a plain dict lookup would be a tenth of one. Tens of
thousands of times per solve, that became most of the preparation time.

What we do instead

The same idea three times: read each thing once, from the commitment we already
have in hand, instead of reading it back off thousands of small frames.

  1. The constant columns are gathered while the commitment is split, and carried on
    SchedulingProblem as commitment_scalars.
  2. The device grouping uses those. The convexity check now sums over the commitments
    as they were passed in, instead of concatenating every sub-commitment — splitting
    partitions a commitment's rows, so the sums come out the same.
  3. The rows a model builder binds (quantity, time-step index, row bounds) are sliced
    during the split too. The HiGHS backend then never looks at the sub-commitment
    frames at all, so they are only built when something asks for one. In practice that
    means only Pyomo builds them.

Results

Measured on a week of EV charging (20 charge points of 2 EVSEs each, plus a PV, a
building and a battery). Profiled, one simulated day:

before after
prepare_scheduling_problem 11.1 s 3.7 s
_column 2.90 s / 31,451 calls 0.14 s / 1,863 calls
pd.concat for the convexity check 3.78 s 0.10 s
time spent inside pandas 26.7 s 13.6 s

End to end, without the profiler: a simulated day went from 19.7 s to 16.5 s, and
the week from 130 s to 104 s.

Small problems are unaffected — there is nothing to save when there are only a handful
of commitments.

How we know schedules did not change

Each step was checked by computing the result both the old way and the new way and
asserting they matched — across the planning tests and a full week of real EV
charging — before removing the check. For the lazily built frames that comparison was
assert_frame_equal, frame by frame.

Tests on this branch:

  • flexmeasures/data/models/planning — 359 passed, 3 xfailed
  • flexmeasures/data — 1,078 passed (one pre-existing error in
    test_automation_scheduling_fresh_db, which fails the same way on main)

Notes for review

  • The timings above were measured before main was merged into this branch. The test
    results are from after the merge.
  • aggregate_commodity_costs still accepts commitment frames, which is what the Pyomo
    backend passes it.
  • SubCommitmentFrames is a small read-only sequence: it remembers which frame each
    sub-commitment came from and which rows are its own, and builds the frame on first
    access.

🤖 Generated with Claude Code

Ahmad-Wahid and others added 4 commits September 7, 2026 14:26
The scheduling problem carries commitments as DataFrames whose name, class,
commodity and deviation prices are the same value repeated down every row.
Building the model read those back one `df[column].iloc[0]` at a time:
pandas constructs a Series and positionally indexes it for each, costing a
few microseconds where a dict lookup costs a tenth of one.

That is cheap until the flex-model has a device per charging session. A site
with 20 charge points of 2 EVSEs each produces roughly 7,400 sub-commitments
per schedule, because a commitment is split into one sub-commitment per
group and each time step is usually its own group. The reads then run into
the tens of thousands per solve.

The constants are now gathered in convert_commitments_to_subcommitments,
which already has the parent frame in hand, and carried on SchedulingProblem
as `commitment_scalars`. Gathering them there rather than from the split
result is the point: the parent is read once, not once per time step, and
the group prices come from one vectorized `groupby.first()` instead of a
lookup per group.

Measured on a week of EV charging (20 charge points of 2 EVSEs each, PV,
building and battery), profiled, one simulated day:

  _validate_commitments_are_enforceable   1.79s -> 0.02s
  _price_of (59,176 calls)                2.64s -> 0.01s
  aggregate_commodity_costs               0.98s -> 0.00s
  convert_commitments_to_subcommitments   2.10s -> 2.39s  (now builds them)
  prepare_scheduling_problem             11.11s -> 9.77s
  pandas self-time                       26.73s -> 20.25s

Unprofiled, that is 24.6s -> 20.8s for a simulated day and 172s -> 138s for
the week, with the resulting schedules unchanged.

_identify_commitment and aggregate_commodity_costs now take the gathered
constants. aggregate_commodity_costs still accepts frames, which is what the
Pyomo backend passes it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rames

Two more places read the sub-commitment frames back a lookup at a time.
Splitting a commitment produces one sub-commitment per group, and a group is
usually a single time step, so both ran thousands of times per schedule on
frames holding a single row.

The device grouping (stock key, device column, device_group column) is now
sliced from the parent while splitting, using the group positions the
groupby already knows, and carried alongside the other constants. The loop
that builds device_group_lookup reads those instead of the frames.

The convexity check summed the deviation prices over the sub-commitments,
which meant concatenating thousands of one-row frames. It sums over the
commitments as passed in instead. Splitting partitions a commitment's rows
between groups, and a group that yields both a downwards and an upwards
sub-commitment contributes each price exactly once, just as the row it came
from does, so the sums are identical -- verified by computing both and
asserting equality across the planning tests and a full week of EV charging
before removing the check.

Profiled, one simulated day of the same benchmark:

  pd.concat (convexity check)      3.78s ->  0.10s
  DataFrame.__getitem__      1.41s/61,443 ->  0.16s/811
  convert_commitments_to_subcommitments
                                   2.39s ->  2.79s  (now gathers both)
  pandas self-time                20.25s -> 17.80s
  profiled total                  39.0s  -> 36.8s

Unprofiled: 20.8s -> 19.7s for a simulated day, 138s -> 130s for the week,
schedules unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…emand

Splitting a commitment produces one sub-commitment per group, and a group is
usually a single time step, so a schedule with a device per charging session
holds thousands of one-row frames. The HiGHS backend then read each one back
for its quantity and time-step columns.

Those are now sliced from the commitment while it is split, alongside the
constants gathered there already, including the row bounds -- which follow
from whether the sub-commitment kept the upwards or the downwards price
column, and so are known without inspecting the result. With the commodity
also coming from the gathered constants, the HiGHS backend no longer looks
at the frames at all.

That leaves them needed only by the Pyomo backend, so they are no longer
built up front: SubCommitmentFrames records the frame each came from, its
group's row positions and the price column its half drops, and materialises
one the first time it is asked for. Verified by building both ways and
comparing frame by frame with assert_frame_equal, across the planning tests
and a full week of EV charging, before removing the check.

Profiled, one simulated day of the same benchmark:

  _column                    2.90s/31,451 -> 0.14s/1,863
  groupby get_iterator             1.57s  -> not reached
  convert_commitments_to_subcommitments
                                   3.29s  -> 1.87s
  prepare_scheduling_problem      11.11s  -> 3.73s
  pandas self-time                26.73s  -> 13.56s
  profiled total                  46.9s   -> 31.7s

Unprofiled: 19.7s -> 16.5s for a simulated day and 130s -> 104s for the
week, schedules unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Ahmad-Wahid Ahmad-Wahid self-assigned this Sep 10, 2026
@read-the-docs-community

read-the-docs-community Bot commented Sep 10, 2026

Copy link
Copy Markdown

Documentation build overview

📚 flexmeasures | 🛠️ Build #34497648 | 📁 Comparing e7f4394 against latest (be84ec5)

  🔍 Preview build  

3 files changed
± genindex.html
± _autosummary/flexmeasures.data.models.planning.scheduling_problem.html
± api/v3_0.html

4.3.0 stops building ORM objects that the bulk-save path never reads, and
writes large belief batches with COPY rather than a multi-row INSERT, so
storing beliefs gets substantially cheaper.

The Docker image installs from uv.lock with "uv sync --frozen", so the lock
is what decides the version; the floor in pyproject.toml is raised to match,
so a later re-resolve cannot quietly drop back to 4.2.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updatedtimely-beliefs@​4.2.0 ⏵ 4.3.0100 +1100100100100

View full report

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