Skip to content

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

Merged
Flix6x merged 16 commits into
mainfrom
feat/highspy-direct-scheduler
Aug 3, 2026
Merged

Add a Pyomo-free scheduling backend (direct highspy model construction)#2364
Flix6x merged 16 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, and factors out the input handling both backends share.

What's in it

  • 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, EMS-level flow commitments and subcommitments) using vectorized numpy + highspy. The module docstring notes the two-models-in-sync maintenance trade-off and points at device_scheduler as the semantic reference.

  • New module flexmeasures/data/models/planning/scheduling_problem.py — the solver-agnostic half, which both backends now share. The two backends necessarily build the same model in two representations, but everything around that had been written twice: 199 lines were byte-identical between the two files. Argument normalisation, stock groups and their validation, legacy commitment conversion, the sub-commitment split, device_group_lookup, the convex-cost-curve check, the Big-Ms, band validation, the HiGHS option profile, and the cost/schedule assembly have no solver in them. prepare_scheduling_problem() returns a SchedulingProblem that both backends unpack. Duplication between the backends is now 40 lines, and those 40 are the shared signature and the call itself.

  • 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).

  • Arguments are forwarded by name, not by a hand-written keyword list. With a hand-written list, whoever adds the next device_scheduler parameter (e.g. coupling_groups in Combined Heat and Power (CHP) #2218, balance_groups in Balance internal commodity nodes (heat/steam networks) via first-class balance groups #2289) naturally works on the Pyomo model, and a parameter missing from that list would not fail — it would simply never reach the backend, producing a schedule computed as if the constraint had never been requested. Since this PR makes highspy the default, that would be silently wrong. Forwarding by name removes the failure mode; an argument the backend does not model raises NotImplementedError naming it, and a test asserts the backend covers the whole current signature.

  • CI equivalence: the app_with_each_solver fixture now runs ["appsi_highs", "cbc", "highspy"], and test_highspy_equivalence.py asserts near-identical schedules across both HiGHS paths on seven representative scenarios (battery+prices, soc-targets with storage efficiency and stock delta, site capacity with breach/peak prices, two devices with a StockCommitment, an EMS-level flow commitment, an EMS-level commitment scoped to one commodity, and an infeasible case).

  • Default flipped to highspy (config_defaults), with docs updated (configuration, deployment, installation).

Local verification: 318 passed, 3 xfailed across the planning suite under all three solver params (cbc installed, nothing skipped), plus the scheduling-job and API scheduling tests; mypy/black/flake8 clean.

Performance

Two measurements on a 4-device × 192-step problem, beyond the Pyomo-bypass win above:

  • Sub-commitments are usually one row each (every time step tends to form its own commitment group), so device_group_lookup was slicing a fresh two-column DataFrame, running dropna() on it and calling iterrows(), once per time step. Reading the two columns as arrays instead takes that loop from 49 ms to 1.0 ms (device+device_group) and 65 ms to 0.8 ms (device only).
  • Together with the earlier sub-commitment work, prepare_scheduling_problem as a whole went from ~135 ms to ~25 ms, against a model build measured in single-digit milliseconds. Input prep, not model construction, was the remaining bottleneck on the fast path — and it is now in one shared place, so both backends benefit.

The by-name argument forwarding costs ~2 µs per device_scheduler call (the signature comparison is cached on the two function objects; ~70 µs once per process).

Discussion points (deliberate deviations, called out for review)

  1. 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.
  2. Rows whose bounds no finite value can satisfy (upper bound of -inf, or lower bound of +inf, as happens when a commitment quantity is ±inf) are skipped. On the Pyomo path such rows are rejected by HiGHS' addRow when appsi adds them, so the net effect is the same.
  3. Solver results and model objects are lightweight shims rather than Pyomo objects, exposing the attributes callers actually consume.
  4. An empty commitments list is now tolerated. The Pyomo path used to crash on pd.concat([]) where the direct path coped; sharing the input handling fixes it once, for both.
  5. initial_stock_of() casts its device index to int, as the direct backend already did. The Pyomo version raised TypeError on the numpy float device indices a commitment's "device" column can carry.
  6. commodity_devices is computed lazily (a cached_property). It is a per-row scan that only the EMS-level flow commitment constraints need, so computing it eagerly would put a real cost on the fast path.
  7. There is no solver time-limit knob on main; operators can pass time_limit via FLEXMEASURES_LP_SOLVER_OPTIONS (applied last on both paths).

Note on #2355

An earlier revision of this PR did not build ems_flow_commitment_equalities, because on the Pyomo path those rows had no bounds and HiGHS dropped them anyway. #2355 gave them the same one-sided bounds grouped_commitment_equalities uses, so they bind now, and this PR builds them — otherwise an EMS-level commitment would be silently ignored under the new default backend. The row is the grouped one with a different summation set (all devices, or the commitment's commodity's devices), so both constraint families now share one implementation.

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

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>

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 thread flexmeasures/data/models/planning/highspy_optimization.py Outdated
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
Flix6x and others added 4 commits August 3, 2026 15:09
The dispatch to the direct HiGHS backend listed its keyword arguments by
hand. That is a trap for the branches currently adding scheduling
parameters: whoever adds the next one (coupling_groups in #2218,
balance_groups in #2289) works on the Pyomo model further down the file,
and a parameter missing from the dispatch list would not fail. It would
simply never reach the backend, producing a schedule computed as if the
constraint had never been requested -- and since this PR makes "highspy"
the default solver, that would be silently wrong.

Forward by name instead, mapping device_scheduler's signature onto the
backend's. An argument the backend does not model raises NotImplementedError
naming it, but only when the caller actually set it, so leaving a future
parameter at its default stays free. The signature comparison is cached on
the two function objects (~70 us once per process, 2 us per call after).

Also record, in the highspy module docstring, that the deliberate omission
of ems_flow_commitment_equalities stops being harmless once #2355 gives
those rows bounds, and that #2380 routes unscoped flex-context commitments
through grouped_commitment_equalities (which this backend does build).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2jLjsQzwztDc7Nxz1ozmQ
Signed-off-by: F.N. Claessen <claessen@seita.nl>
The two backends build the same model in two representations, so the model
construction is necessarily written twice. Everything around it was too:
199 lines were byte-identical between linear_optimization.py and
highspy_optimization.py -- argument normalisation, stock groups and their
validation, legacy commitment conversion, the sub-commitment split,
device_group_lookup, the convex-curve check, the Big-Ms, band validation,
the HiGHS option profile, and the cost/schedule assembly. None of it has a
solver in it, and keeping it twice meant the two paths could drift apart on
input handling, which the equivalence tests are not aimed at.

Move it to a new scheduling_problem module: prepare_scheduling_problem()
returns a SchedulingProblem that both backends unpack, plus solver_options()
and the result-assembly helpers. Duplication between the backends drops from
199 to 40 lines, and those 40 are the shared signature and the call itself.

Two deliberate changes while moving:

- commodity_devices becomes a cached_property. It is a per-row scan that
  only the Pyomo ems_flow_commitment_equalities needs, so computing it
  eagerly would put a real cost on the fast path. Pyomo pays what it did
  before; the direct backend pays nothing until it needs it (see #2355).
- initial_stock_of() casts its index to int, as the direct backend already
  did. The Pyomo version raised TypeError on the numpy float device indices
  a commitment's "device" column can carry.

The empty-commitments case also stops raising on pd.concat([]), which
previously made the Pyomo path crash where the direct path coped.

Verified: 265 passed, 3 xfailed across the planning suite under all three
solver parameters, plus the scheduling-job and API schedule tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2jLjsQzwztDc7Nxz1ozmQ
Signed-off-by: F.N. Claessen <claessen@seita.nl>
Sub-commitments are usually one row each (every time step tends to form its
own commitment group), so this loop slices a fresh two-column DataFrame,
runs dropna() on it and calls iterrows() once per time step. The pandas
per-call overhead, not the work, dominated: profiling a 4-device x 192-step
problem showed 192 dropna() calls accounting for ~50 ms of a ~135 ms
prepare_scheduling_problem, against a model build measured in single-digit
milliseconds.

Read the two columns as arrays once and loop over them instead, replacing
dropna() with an explicit missing-value check that also handles the
collection-valued "device" entries pd.isna would answer element-wise.

The loop itself goes from 49 ms to 1.0 ms (device+device_group) and 65 ms to
0.8 ms (device only) at 192 sub-commitments; prepare_scheduling_problem as a
whole drops from 135 ms to 22 ms.

Equivalence was checked against the previous implementation over device-only
and grouped frames, NaN/None/pd.NA in either column, list/tuple/ndarray
device entries, mixed group key types, stock-scoped and empty frames: same
groups, same members, same insertion order. Reading the column array yields
numpy scalars where iterrows() yielded Python floats on mixed-dtype frames;
the two are interchangeable as set members and dict keys (equal hash and
equality) and both survive the int() casts applied downstream.

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

# Conflicts:
#	documentation/changelog.rst
Flix6x and others added 2 commits August 3, 2026 16:20
This backend skipped ems_flow_commitment_equalities because the Pyomo path
built those rows without bounds, so they could never bind and HiGHS dropped
them anyway. PR #2355 gives them the same one-sided bounds that
grouped_commitment_equalities uses, so they bind now, and skipping them here
would silently ignore an EMS-level commitment under this backend -- which
this PR makes the default.

The row is the grouped one with a different summation set, so rather than
write it twice, the existing loop's body is extracted into _active_rows (the
commitment's active time steps and bounds) and _add_commitment_rows (bind a
commitment to the summed flow or stock of a set of devices). The EMS-level
loop then sums over every device, or over the commitment's commodity's
devices, and mirrors the Pyomo path in skipping commitments that name a
device group -- those are already bound per group, and binding them twice
over-constrains the problem.

Two equivalence scenarios cover this, so it runs under both backends:
ems_level_flow_commitment (names no device, binds all devices) and
ems_level_commodity_commitment (binds only its commodity's devices, which
also exercises the commodity_devices lookup). Both were checked to fail with
the new rows disabled, so they cannot pass vacuously.

Also drops the now-inaccurate deviation note from the module docstring.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2jLjsQzwztDc7Nxz1ozmQ
Signed-off-by: F.N. Claessen <claessen@seita.nl>
Comment thread flexmeasures/data/models/planning/highspy_optimization.py Outdated
@Flix6x
Flix6x marked this pull request as ready for review August 3, 2026 14:44
@Flix6x
Flix6x requested a review from Copilot August 3, 2026 14:46
"Stanza" was an unhelpful coinage. The attribute this shim stands in for holds
a pyomo.opt.results.solver.SolverInformation, so name it _SolverInformation
and say where the name comes from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2jLjsQzwztDc7Nxz1ozmQ
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 11 out of 11 changed files in this pull request and generated no new comments.

Suppressed comments (9)

flexmeasures/data/models/planning/scheduling_problem.py:40

  • This docstring breaks lines mid-phrase (e.g. after "HiGHS'"). Please reflow so each line ends after punctuation (or keep sentences on one line) to follow the repo's docstring wrapping convention.
    """Raise if HiGHS would refuse any of these options.

    Pyomo's appsi_highs interface applies solver options without checking HiGHS'
    return status, so an unknown name, an invalid value, or a feature missing from
    the installed HiGHS build is otherwise ignored without a word. That silently

flexmeasures/data/models/planning/scheduling_problem.py:111

  • The convert_commitments_to_subcommitments docstring wraps lines mid-phrase (e.g. line breaks inside a sentence without ending punctuation). Please reflow to keep sentence breaks aligned with line breaks.
    """Transform commitments, each specifying a group for each time step, to sub-commitments, one per group.

    'Groups' are a commitment concept (grouping time slots of a commitment),
    making it possible that deviations/breaches can be accounted for properly within this group
    (e.g. highest breach per calendar month defines the penalty).

flexmeasures/data/models/planning/linear_optimization.py:88

  • This docstring wraps mid-phrase in several places (e.g. breaking lines after "next" and before the rest of the sentence). Please reflow so each physical line ends after punctuation, or keep full sentences on one line.
    """Map ``device_scheduler``'s arguments onto the direct HiGHS backend's signature.

    A hand-written keyword list here would be a trap: whoever adds the next
    ``device_scheduler`` parameter naturally works on the Pyomo model further
    down this file, and a parameter missing from that list would not fail — it

flexmeasures/data/models/planning/highspy_optimization.py:22

  • The remainder of the module docstring wraps mid-phrase (e.g. the "Deviations" paragraph and list items). Please reflow so line breaks occur after punctuation, while keeping list indentation intact.
Deviations from the Pyomo implementation (all verified against the behavior of
the ``appsi_highs`` path):

- Rows whose computed bounds are impossible to satisfy for any finite value
  (upper bound of -inf, or lower bound of +inf, as happens when a commitment

flexmeasures/data/models/planning/tests/test_highspy_equivalence.py:11

  • Module docstring wraps lines mid-phrase. In this repo, docstrings/comments should not be broken in the middle of a phrase; reflow so each physical line ends after punctuation (or keep sentences on one line) to keep review comments and text search stable.
"""Equivalence tests for the direct highspy scheduling backend.

Each scenario is run through ``device_scheduler`` twice: once with the Pyomo
path (``appsi_highs``) and once with the direct HiGHS path (``highspy``), and
the resulting schedules, costs and termination handling are compared.

flexmeasures/data/models/planning/scheduling_problem.py:16

  • Module docstring wraps several lines mid-phrase (e.g. breaking right after the function reference and after conjunctions). Please reflow it so each physical line ends after punctuation (or use longer lines) to match the repo's docstring/comment wrapping convention.

This issue also appears in the following locations of the same file:

  • line 36
  • line 107
"""Solver-agnostic preparation of the device scheduler's inputs.

:func:`flexmeasures.data.models.planning.linear_optimization.device_scheduler`
(Pyomo) and
:func:`flexmeasures.data.models.planning.highspy_optimization.device_scheduler_highspy`

flexmeasures/data/models/planning/tests/test_solver.py:3163

  • New inline comments wrap mid-phrase across multiple lines (e.g. splitting a clause without ending punctuation). Please reflow these comments so each physical line ends after punctuation to match the repo's comment-wrapping convention.
    # Expected results with unfair unmet demand and not entirely unfair costs.
    # NB This problem has multiple optima: only the site-level (aggregate)
    # schedule is unique, while the per-device allocation of the charging slots
    # (and thereby the per-device costs and even which device's demand goes
    # unmet) is an arbitrary tie-break that depends on the solver backend.

flexmeasures/data/models/planning/linear_optimization.py:51

  • This docstring wraps mid-phrase (line breaks inside a sentence without punctuation at the line end). Please reflow to keep line breaks aligned with punctuation, as per the repo's docstring/comment wrapping convention.

This issue also appears on line 84 of the same file.

    """Whether an argument was left at its default.

    Best-effort: pandas values compare element-wise, so ``value == default`` may
    return an array (or raise) rather than a bool. Anything we cannot decide is
    reported as "not the default", which errs towards raising in

flexmeasures/data/models/planning/highspy_optimization.py:17

  • The module docstring's warning section wraps lines mid-phrase (several lines end without punctuation). Please reflow to follow the repo's docstring/comment line-break convention, while keeping the RST directive indentation intact.

This issue also appears on line 18 of the same file.

"""Direct HiGHS (highspy) implementation of the device scheduler.

.. warning:: TWO MODELS TO KEEP IN SYNC

    This module deliberately duplicates the mathematical model of

@Flix6x Flix6x added this to the 1.0.0 milestone Aug 3, 2026
CLAUDE.md asks that docstrings and comments break lines only after punctuation,
never mid-phrase, with max-line-length 160 and E501 ignored, so that review
comments and text search stay stable. Copilot flagged nine places in this PR
where I had not followed it, and it was right.

Reflowed the docstrings and comments this PR adds, plus the ones it moved into
the new scheduling_problem module. Doing that here rather than in the refactor
commit keeps that commit verifiable as a verbatim move.

Text only. No behaviour, names or logic changed; 290 passed, 3 xfailed across
the planning suite under all three solvers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2jLjsQzwztDc7Nxz1ozmQ
Signed-off-by: F.N. Claessen <claessen@seita.nl>
@Flix6x
Flix6x merged commit fc0b7bd into main Aug 3, 2026
13 checks passed
@Flix6x
Flix6x deleted the feat/highspy-direct-scheduler branch August 3, 2026 15:08
Flix6x added a commit that referenced this pull request Aug 3, 2026
…upling

main gained a second scheduler backend (#2364) that builds the HiGHS model
directly and is now the default, plus a scheduling_problem module holding the
input handling both backends share. That conflicts with this branch in exactly
one place, and leaves it one thing to implement.

The conflict is mechanical: the stock-group block this branch rewrote for
multi-group membership was moved, unchanged, into prepare_scheduling_problem().
Both of this branch's additions to that block go there now -- the overlapping
group_to_devices membership, and the coupling_device_specs collection, which
becomes a field on SchedulingProblem so both backends read it from one place.

The implementation is coupling itself. device_scheduler forwards its arguments
to the direct backend by name, so coupling_groups would have raised
NotImplementedError there rather than being silently dropped; it now models the
constraint instead. That is one free variable per group per time step (the
group's common normalised flow) and one equality per coupled device,
ems_power[d, j] - coeff * alpha[g, j] == 0.

Adds a chp_coupling_groups equivalence scenario, so a three-port CHP is
compared across both backends. Checked that it fails with the new rows
disabled, so it cannot pass vacuously.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2jLjsQzwztDc7Nxz1ozmQ
Signed-off-by: F.N. Claessen <claessen@seita.nl>
Flix6x added a commit that referenced this pull request Aug 3, 2026
Same shape as the merge one level down: the input handling moved into
prepare_scheduling_problem() on main (#2364), so this branch's
balance_group_specs collection moves there too, as a field on
SchedulingProblem that both backends read.

The direct backend now models the node balance rather than raising
NotImplementedError for balance_groups: one equality per node per time step,
sum_d(ems_power[d, j]) == 0. Groups with no devices are dropped during
preparation, so they add no row, matching the Pyomo path's Constraint.Skip.

Adds an internal_commodity_balance equivalence scenario. The first version of
it passed with the balance rows disabled -- both heat devices had a free flow
band and no cost incentive, so the optimum was zero flow either way and the
constraint never bound. Pinning the consumer's draw with "derivative equals"
makes the balance the only reason the producer runs, and the scenario now
fails as it should when the rows are removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2jLjsQzwztDc7Nxz1ozmQ
Signed-off-by: F.N. Claessen <claessen@seita.nl>
Flix6x added a commit that referenced this pull request Aug 3, 2026
The docstring convention -- break lines only after punctuation -- has been
missed in three PRs in a row (#2364, #2218, #2385), each time caught by a
reviewer rather than by the author. #2384 documented the rule harder, and the
very next PR broke it, so documentation is evidently not the missing piece.

Adds .claude/hooks/reflow_check.py and wires it as a PreToolUse hook on
`git commit`, next to the existing pre-commit and worktree-guard hooks.

Two deliberate choices:

It is an agent hook, not a pre-commit hook. The misses have been an agent's,
and contributors should not pay a false-positive tax for that. It is also the
only form that works: the check is a heuristic, and a heuristic gate that a
human hits on embedded OpenAPI YAML gets disabled, whereas an agent can read
"line 2028 may break mid-phrase", look, and judge.

It is advisory and never blocks. It exits 0 always, and only inspects lines
being added, so a legacy file is not a wall -- the repo has ~4350 candidate
hits, and reflowing unrelated prose is not a thing a commit should do.

Suppressing non-prose (bullets, RST directives, doctests, embedded YAML and
JSON, shell continuations) cuts the noise substantially where docstrings carry
API specs: sensors.py 494 hits to 59, assets.py 385 to 15. Files that are
genuinely prose-heavy stay high (storage.py 199 to 173), which is the honest
answer rather than a tuned one.

Self-tested both directions: a docstring broken mid-phrase is reported, a
correctly reflowed one is silent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2jLjsQzwztDc7Nxz1ozmQ
Signed-off-by: F.N. Claessen <claessen@seita.nl>
Flix6x added a commit that referenced this pull request Aug 3, 2026
* Run the scheduler constraint tests under both backends

Since #2364 the schedulers build the same model twice, once through Pyomo and
once directly in HiGHS, so a test of scheduler behaviour only means something
under one backend if the two agree -- which is the thing that cannot be assumed.
Only test_solver.py was parametrized, so most constraint behaviour was verified
under the configured default and no other. That is how a fix could pass on the
Pyomo path and fail on the direct one, as happened on #2355.

A module now opts in with RUN_UNDER_EACH_SOLVER = True and an autouse fixture in
conftest does the switching, so no test signature changes. Enabled on
test_group_constraints.py and test_operation_modes.py (27 tests, now 54).

test_commitments.py and test_storage.py are NOT enabled, and the reason is worth
recording: they build assets with fixed names, so running each test twice in one
fixture scope violates generic_asset's unique-name constraint. Parametrizing
them means making those fixtures unique per parameter first, which is a larger
change than this one.

Rather than leave that hole silent, test_solver_coverage.py asserts every
planning test module either opts in or appears on an EXEMPT list with a reason,
and that no EXEMPT entry is stale. A new module is then a decision someone makes
on purpose instead of a gap nobody notices.

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

* review: show the exempt modules pass under the other backend too

The exempt modules could not be parametrized in-process, but that left their
behaviour under the non-default backend simply unknown, which is a weaker
position than it needed to be.

Adds a --lp-solver option pinning a whole run to one backend, so those modules
can be run again under the other one. (An environment variable would not do:
TestingConfig does not read FLEXMEASURES_LP_SOLVER.)

Result: test_commitments.py, test_storage.py and test_process.py -- 68 tests --
all pass under appsi_highs, the non-default backend. So the exemption costs
per-test granularity, not coverage.

Checked the flag actually bites rather than silently doing nothing: running with
--lp-solver=definitely_not_a_solver fails with Pyomo's UnknownSolver, so the
option does reach solver selection.

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

* review: reflow this PR's docstrings and comments

Every docstring and comment block added here broke lines mid-phrase, against
the convention this repo documents and that #2384 had just restated -- "run
once per / backend", "under one backend / if the two agree", "set from the /
environment".

Reflowed so each physical line ends after punctuation. Text only.

Checked mechanically rather than by eye this time: inside a multi-line docstring
or comment block, every line but the last must end in punctuation. The only
remaining hits in the added lines are the two continuation lines of an example
shell command, which end in a backslash by nature.

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

* review: drop the comment pointing at the PR description

A code comment telling the reader to consult a pull request description ages
badly and does not belong in the tree. Replaced with the fact it was pointing
at: these modules pass under the other backend when a run is pinned with
--lp-solver.

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

---------

Signed-off-by: F.N. Claessen <claessen@seita.nl>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Flix6x added a commit that referenced this pull request Aug 3, 2026
The docstring convention -- break lines only after punctuation -- has been
missed in three PRs in a row (#2364, #2218, #2385), each time caught by a
reviewer rather than by the author. #2384 documented the rule harder, and the
very next PR broke it, so documentation is evidently not the missing piece.

Adds .claude/hooks/reflow_check.py and wires it as a PreToolUse hook on
`git commit`, next to the existing pre-commit and worktree-guard hooks.

Two deliberate choices:

It is an agent hook, not a pre-commit hook. The misses have been an agent's,
and contributors should not pay a false-positive tax for that. It is also the
only form that works: the check is a heuristic, and a heuristic gate that a
human hits on embedded OpenAPI YAML gets disabled, whereas an agent can read
"line 2028 may break mid-phrase", look, and judge.

It is advisory and never blocks. It exits 0 always, and only inspects lines
being added, so a legacy file is not a wall -- the repo has ~4350 candidate
hits, and reflowing unrelated prose is not a thing a commit should do.

Suppressing non-prose (bullets, RST directives, doctests, embedded YAML and
JSON, shell continuations) cuts the noise substantially where docstrings carry
API specs: sensors.py 494 hits to 59, assets.py 385 to 15. Files that are
genuinely prose-heavy stay high (storage.py 199 to 173), which is the honest
answer rather than a tuned one.

Self-tested both directions: a docstring broken mid-phrase is reported, a
correctly reflowed one is silent.


Claude-Session: https://claude.ai/code/session_01X2jLjsQzwztDc7Nxz1ozmQ

Signed-off-by: F.N. Claessen <claessen@seita.nl>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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