Skip to content

Add a Pyomo-free scheduling backend (direct highspy model construction) - #2364

Draft
Flix6x wants to merge 8 commits into
mainfrom
feat/highspy-direct-scheduler
Draft

Add a Pyomo-free scheduling backend (direct highspy model construction)#2364
Flix6x wants to merge 8 commits into
mainfrom
feat/highspy-direct-scheduler

Conversation

@Flix6x

@Flix6x Flix6x commented Jul 28, 2026

Copy link
Copy Markdown
Member

Description

Building the scheduling model through Pyomo dominates scheduling-job time: profiled on a large co-simulation database, the Pyomo layer (expression-tree construction + appsi ingestion) accounted for ~1 s of a battery-only job and ~7 s of a two-device job with operation-mode power bands, while the solve itself finishes in seconds and belief reads are negligible (~0.3 s). A prototype building the identical battery model via highspy's array API lands at ~5 ms.

This PR adds a Pyomo-free backend that constructs the HiGHS model directly:

  • New module flexmeasures/data/models/planning/highspy_optimization.py — a faithful reimplementation of device_scheduler's model semantics (device power up/down with efficiencies, stock dynamics incl. storage efficiency / stock delta / gain / usage, stock bounds and targets, device and EMS power capacities, the commitments framework incl. grouped equalities and subcommitments) using vectorized numpy + highspy. The module docstring prominently notes the two-models-in-sync maintenance trade-off and points at device_scheduler as the semantic reference.
  • Exposed as a solver choice: FLEXMEASURES_LP_SOLVER = "highspy". device_scheduler dispatches to the new module for that value; every other value keeps the Pyomo path untouched. Same inputs, same 4-tuple return contract (results/model shims cover every consumer usage: termination_condition, commitment_costs, indexed power views).
  • CI equivalence: the app_with_each_solver fixture now runs ["appsi_highs", "cbc", "highspy"], and a new test_highspy_equivalence.py asserts near-identical schedules across both HiGHS paths on five representative scenarios (battery+prices, soc-targets with storage efficiency and stock delta, site capacity with breach/peak prices, two devices with a StockCommitment, and an infeasible case).
  • Default flipped to highspy (config_defaults), with docs updated (configuration, deployment, installation).

Local verification: planning suite passes under all three solver params (263 passed × both HiGHS backends; cbc installed, nothing skipped), scheduling job tests and API scheduling tests pass under the new default, mypy/black/flake8 clean.

Discussion points (deliberate deviations, called out for review)

  1. ems_flow_commitment_equalities is not built on the highspy path: on the Pyomo path it produces a bound-less (vacuous) constraint, and rows whose bounds involve ±inf commitment quantities are rejected by HiGHS when appsi adds them — so the net effect on main is identical. Documented in the module docstring.
  2. Degenerate optima may tie-break differently between backends. test_multiple_devices_simultaneous_scheduler case 2 asserted one specific optimum of a problem whose per-device allocation is non-unique (same total cost either way); it now asserts solver-independent properties (aggregate schedule, total cost, total unmet demand). Case 1 (unique optimum) keeps exact assertions.
  3. The highspy path tolerates an empty commitments list (the Pyomo path would crash on pd.concat([])).
  4. The subcommitment conversion (previously O(J²) pandas filtering) is now hoisted to module level, vectorized via a single groupby pass, and shared by both backends — benchmarked on a 2-device × 192-step problem: appsi_highs 0.47 s → 0.28 s and highspy 0.37 s → 0.12 s per device_scheduler call, with identical costs. The largest wins appear on models with many constraint rows (e.g. the operation-mode power-band branch, where the Pyomo layer costs ~7 s/job).
  5. There is no solver time-limit knob on main; operators can pass time_limit via FLEXMEASURES_LP_SOLVER_OPTIONS (applied last on both paths).

How to test

pytest flexmeasures/data/models/planning/tests (runs all three solver params), or set FLEXMEASURES_LP_SOLVER="appsi_highs" to keep the previous default behavior.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WtuVTVfL4fQ9QSqbLXmAGD

Flix6x and others added 4 commits July 28, 2026 16:38
Add flexmeasures/data/models/planning/highspy_optimization.py, which builds
the device scheduler's LP/MILP directly with the HiGHS Python API (highspy),
bypassing Pyomo's model construction and solution-ingestion overhead. The
Pyomo implementation (linear_optimization.device_scheduler) remains the
semantic reference; the module docstring prominently documents that the two
models must be kept in sync, as well as the (verified) deviations:

- ems_flow_commitment_equalities are not built: on the Pyomo path they are
  bound-less, i.e. free rows without any effect on the solution
- rows no finite assignment can satisfy (bounds involving +/-inf quantities)
  are skipped, mirroring HiGHS rejecting such rows when appsi adds them
- solver results and model are lightweight shims exposing the attributes
  callers consume (termination_condition/status strings, commitment_costs,
  commodity_costs, costs, and indexed variable views)

The model is built with vectorized numpy arrays (addVars/addRows/
changeColsCost/changeColsIntegrality), constructing and solving typical
battery problems in milliseconds, where the Pyomo layer needs seconds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtuVTVfL4fQ9QSqbLXmAGD
Signed-off-by: F.N. Claessen <claessen@seita.nl>
When FLEXMEASURES_LP_SOLVER is set to "highspy", device_scheduler delegates
to device_scheduler_highspy with the same inputs and the same return
contract. All other solver names keep using the Pyomo path unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtuVTVfL4fQ9QSqbLXmAGD
Signed-off-by: F.N. Claessen <claessen@seita.nl>
- Add "highspy" to the app_with_each_solver fixture params, so tests using
  it also run against the direct backend.
- Add test_highspy_equivalence.py, running representative scenarios (battery
  with prices; soc targets incl. storage efficiency and stock delta; site
  capacity with breach and peak prices; two devices with a StockCommitment;
  an infeasible case) through both appsi_highs and highspy, asserting
  near-identical schedules and costs, and matching termination handling.
- Make test case 2 of test_multiple_devices_simultaneous_scheduler assert
  solver-independent properties (aggregate schedule, total costs, total unmet
  demand): the problem has multiple optima, and only the site-level schedule
  is unique, while the per-device slot allocation is an arbitrary tie-break
  that depends on the solver backend.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtuVTVfL4fQ9QSqbLXmAGD
Signed-off-by: F.N. Claessen <claessen@seita.nl>
Flip the FLEXMEASURES_LP_SOLVER default from "appsi_highs" to "highspy"
and update the configuration, installation and deployment docs accordingly.
Any Pyomo-based solver remains available as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtuVTVfL4fQ9QSqbLXmAGD
Signed-off-by: F.N. Claessen <claessen@seita.nl>
@Flix6x
Flix6x requested a review from Copilot July 28, 2026 14:45
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtuVTVfL4fQ9QSqbLXmAGD
Signed-off-by: F.N. Claessen <claessen@seita.nl>
@read-the-docs-community

read-the-docs-community Bot commented Jul 28, 2026

Copy link
Copy Markdown

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new scheduling backend that bypasses Pyomo and constructs the equivalent optimization model directly with highspy (HiGHS), then makes it selectable via FLEXMEASURES_LP_SOLVER="highspy" and flips that to the default. This aims to substantially reduce scheduling-job overhead dominated by Pyomo model construction / ingestion.

Changes:

  • Introduce flexmeasures/data/models/planning/highspy_optimization.py implementing the device scheduler model directly in HiGHS (with lightweight result/model shims).
  • Add solver dispatch in device_scheduler, expand the solver-parametrized planning test fixture, and add equivalence tests comparing appsi_highs vs highspy.
  • Update docs + changelog to reflect the new default solver backend and installation/deployment guidance.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
flexmeasures/utils/config_defaults.py Switch default LP solver config to highspy
flexmeasures/data/models/planning/linear_optimization.py Dispatch to the new highspy backend when configured
flexmeasures/data/models/planning/highspy_optimization.py New direct-HiGHS scheduler model implementation + result/model shims
flexmeasures/data/models/planning/tests/conftest.py Run planning tests under appsi_highs, cbc, and highspy
flexmeasures/data/models/planning/tests/test_solver.py Make a previously solver-tie-broken test assert solver-independent properties
flexmeasures/data/models/planning/tests/test_highspy_equivalence.py New scenario-based equivalence tests for appsi_highs vs highspy
documentation/configuration.rst Document the backend split (highspy direct vs Pyomo solver interfaces) and new default
documentation/host/installation.rst Update installation guidance to reflect highspy as the default backend
documentation/host/deployment.rst Update deployment guidance for solver installation under the new default
documentation/changelog.rst Add changelog entry announcing the new backend and default change

Comment thread flexmeasures/data/models/planning/highspy_optimization.py Outdated
Comment thread documentation/configuration.rst Outdated
Comment thread documentation/host/deployment.rst Outdated
Flix6x and others added 2 commits July 28, 2026 16:53
Hoist convert_commitments_to_subcommitments to module level (it is
solver-agnostic and closure-free) and share it between both scheduler
backends, removing the duplicated copy from the highspy module.

Splitting a commitment into per-group subcommitments now uses a single
groupby pass (in order of first appearance, like pd.unique) instead of
filtering the DataFrame once per group, and the price non-uniqueness
checks are vectorized across all groups. This removes a cost that scaled
quadratically with the number of time steps (each time step often forms
its own group), benefiting both backends: a 2-device, 192-step benchmark
drops from 0.47s to 0.28s via appsi_highs and from 0.37s to 0.12s via
highspy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtuVTVfL4fQ9QSqbLXmAGD
Signed-off-by: F.N. Claessen <claessen@seita.nl>
- Do not disable HiGHS output unconditionally in the direct backend;
  only force output_flag false when LOGGING_LEVEL is "INFO", mirroring
  exactly how the Pyomo path builds its solver options profile, so
  verbose logging modes can still see solver output. Operator-configured
  FLEXMEASURES_LP_SOLVER_OPTIONS are still applied last and can override.
- Clarify in the configuration docs that a separate solver installation
  is only needed for external solvers such as cbc; both HiGHS-based
  choices (highspy and appsi_highs) rely on the bundled highspy package.
- Remove the now-inconsistent "pip install highspy" instruction from the
  deployment docs, which already state that highspy ships with
  FlexMeasures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtuVTVfL4fQ9QSqbLXmAGD
Signed-off-by: F.N. Claessen <claessen@seita.nl>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.

Comment on lines +352 to +362
# commodity -> set(device indices)
commodity_devices: dict = {}
for df in commitments:
if "commodity" not in df.columns or "device" not in df.columns:
continue
for _, row in df[["commodity", "device"]].dropna().iterrows():
devices = row["device"]
if not isinstance(devices, (list, tuple, set)):
devices = [devices]
commodity_devices.setdefault(row["commodity"], set()).update(devices)

The commodity -> device indices lookup was only consumed by the Pyomo
path's ems_flow_commitment_equalities, which the direct backend
deliberately does not build (they are free rows). A breadcrumb comment
keeps pointing readers at that documented skip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtuVTVfL4fQ9QSqbLXmAGD
Signed-off-by: F.N. Claessen <claessen@seita.nl>
@Flix6x Flix6x self-assigned this Jul 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants