flexmeasures.data.models.planning.scheduling_problem
Solver-agnostic preparation of the device scheduler’s inputs.
flexmeasures.data.models.planning.linear_optimization.device_scheduler() (Pyomo) and
flexmeasures.data.models.planning.highspy_optimization.device_scheduler_highspy() (direct HiGHS)
build the same mathematical model in two very different representations,
so the model construction itself is necessarily written twice.
Everything around it is not:
normalising arguments, resolving stock groups, converting legacy commitments, deriving Big-Ms,
and turning solver output back into schedules and costs is plain pandas/numpy work with no solver in it.
Keeping that work here means the two backends cannot drift apart on input handling —
only on the model, which is what the equivalence tests in tests/test_highspy_equivalence.py compare.
It also gives both backends a single place to grow support for a new scheduling feature’s inputs.
Functions
- flexmeasures.data.models.planning.scheduling_problem.aggregate_commodity_costs(commitments: list[DataFrame], subcommitment_costs: dict) dict
Sum sub-commitment costs per commodity, skipping commitments without one.
- flexmeasures.data.models.planning.scheduling_problem.aggregate_subcommitment_costs(subcommitment_costs: dict, commitment_mapping: dict, merged_constituents: dict | None = None, deviations: dict | None = None) dict
Sum sub-commitment costs back onto the commitments they were split from.
A merged sub-commitment stands for several commitments at once, so its realised cost is shared out by their own prices against the deviation they share: a commitment priced
up_ianddown_icarriesu * up_i + d * down_iof it. That is exact rather than apportioned, because the deviation is one and the same for every commitment merged into it, which is why merging them costs nothing in what can be reported afterwards.Costs come back ordered by commitment, since callers read them off alongside the commitments themselves.
- Parameters:
deviations – per sub-commitment index, its realised
(upwards, downwards)deviation, signed as the solver holds them.
- flexmeasures.data.models.planning.scheduling_problem.convert_commitments_to_subcommitments(dfs: list[DataFrame]) tuple[list[DataFrame], dict[int, int]]
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). Here, we define sub-commitments, by separating commitments by group and by direction of deviation (up, down).
We also enumerate the time steps in a new column “j”.
For example, given contracts A and B (represented by 2 DataFrames), each with 3 groups, we return (sub)commitments A1, A2, A3, B1, B2 and B3, where A,B,C is the enumerated contract and 1,2,3 is the enumerated group.
- flexmeasures.data.models.planning.scheduling_problem.deviation_price(commitment: DataFrame, column: str) float
The single deviation price that the optimizers apply to a commitment, for the given price column.
A commitment carries one pair of deviation variables, priced by one pair of prices, so the price in its first row stands for the whole commitment.
convert_commitments_to_subcommitmentsguarantees that is well defined, by rejecting a group whose prices differ per row. A missing column, or a missing price, means no price at all.
- flexmeasures.data.models.planning.scheduling_problem.interchangeable_subcommitments(commitments: list[DataFrame], device_group_lookup: dict[int, dict]) list[list[int]]
Sub-commitments that impose the identical constraint, grouped, singletons dropped.
Such sub-commitments pin the same deviation in any bounded solution, because their constraints say the same thing, so one of them can carry the group: the duplicates add a variable pair and a constraint row each without adding any information. Replacing a group by one sub-commitment carrying the summed prices is what lets a commitment that is not convex on its own be priced against a partner that more than compensates, since there is then one deviation to inflate rather than one each (GH#2534).
- flexmeasures.data.models.planning.scheduling_problem.loss_coefficients(efficiency: float) tuple[float, float]
Coefficients (a, b) of one step of the stock recursion, for how=”linear”.
stock[j] = a * stock[j-1] + b * change[j]
Mirrors
apply_stock_changes_and_losses(), which we cannot call here because it expects numbers, while change[j] may be a Pyomo expression.
- flexmeasures.data.models.planning.scheduling_problem.merge_interchangeable_subcommitments(commitments: list[DataFrame], commitment_mapping: dict[int, int], device_group_lookup: dict[int, dict], worth_merging) tuple[list[DataFrame], dict[int, int], dict[int, dict], dict[int, list[tuple]]]
Replace each group of interchangeable sub-commitments that
worth_mergingselects by one carrying their summed prices.The duplicates say nothing the first one does not, so the merged sub-commitment constrains the solver exactly as the group did, with one pair of deviation variables and one constraint row in place of one each. What the group’s members do not share is the level of their prices, so the merged sub-commitment is priced on their sum, and each member’s own prices are recorded against it so that its share of the cost can be worked out again after the solve.
- Parameters:
worth_merging – called with a group’s member indices; a group it rejects is left alone, so a problem that gains nothing keeps its model unchanged.
- Returns:
the sub-commitments, their mapping back to the original commitments, their device-group lookup, and, per merged index, the
(original index, upwards price, downwards price)of each member.
- flexmeasures.data.models.planning.scheduling_problem.planned_power_per_device(power_per_device, start, end, resolution) list[Series]
Turn each device’s planned power values into a time series.
- flexmeasures.data.models.planning.scheduling_problem.prepare_scheduling_problem(device_constraints: list[DataFrame], ems_constraints: DataFrame | list[DataFrame], commitment_quantities: list[Series] | None = None, commitment_downwards_deviation_price: list[Series] | list[float] | None = None, commitment_upwards_deviation_price: list[Series] | list[float] | None = None, commitments: list[DataFrame] | list[Commitment] | None = None, initial_stock: float | list[float] = 0, stock_groups: dict[int, list[int]] | None = None, ems_constraint_groups: list[list[int]] | None = None, device_power_bands: list[list[tuple[float, float]] | None] | None = None, coupling_groups: dict[str, list[tuple[int, float]]] | None = None, balance_groups: dict[str, list[int]] | None = None) SchedulingProblem
Normalise and validate
device_scheduler’s arguments into a SchedulingProblem.Note
This adds a “stock delta” column to the passed
device_constraintsDataFrames in place, as the schedulers have always done.
- flexmeasures.data.models.planning.scheduling_problem.solver_options(solver_name: str) dict
The solver options to apply, for the given solver.
HiGHS (whether reached through Pyomo as
appsi_highsor directly ashighspy– both match on “highs”) gets a tight-tolerance profile, so the two backends cannot disagree on tolerances and silently produce different schedules. Operator-configured options are applied last, so they win.
- flexmeasures.data.models.planning.scheduling_problem.validate_highs_options(options: dict) None
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 turns a mis-typed option into a no-op, and a benchmark of it into a false negative. Probing a throwaway Highs instance surfaces the rejection instead.
Classes
- class flexmeasures.data.models.planning.scheduling_problem.SchedulingProblem(start: object, end: object, resolution: object, device_constraints: list[~pandas.core.frame.DataFrame], ems_constraints_list: list[~pandas.core.frame.DataFrame], ems_constraint_device_groups: list[list[int]], device_to_group: dict[int, str], group_to_devices: dict[str, list[int]], commitments: list[~pandas.core.frame.DataFrame], commitment_mapping: dict[int, int], device_group_lookup: dict[int, dict], merged_constituents: dict[int, list[tuple]], convex_cost_curve: bool, Md: float, Mc: float, band_lookup: dict[int, list[tuple[float, float]]], coupling_device_specs: list[tuple[int, int, float]], balance_group_specs: list[list[int]], initial_stock: float | list[float], original_commitments: list[~pandas.core.frame.DataFrame] = <factory>)
Everything both scheduler backends need before building their model.
Produced by
prepare_scheduling_problem(); seedevice_scheduler’s docstring for what the underlying arguments mean.- __init__(start: object, end: object, resolution: object, device_constraints: list[~pandas.core.frame.DataFrame], ems_constraints_list: list[~pandas.core.frame.DataFrame], ems_constraint_device_groups: list[list[int]], device_to_group: dict[int, str], group_to_devices: dict[str, list[int]], commitments: list[~pandas.core.frame.DataFrame], commitment_mapping: dict[int, int], device_group_lookup: dict[int, dict], merged_constituents: dict[int, list[tuple]], convex_cost_curve: bool, Md: float, Mc: float, band_lookup: dict[int, list[tuple[float, float]]], coupling_device_specs: list[tuple[int, int, float]], balance_group_specs: list[list[int]], initial_stock: float | list[float], original_commitments: list[~pandas.core.frame.DataFrame] = <factory>) None
- balance_group_specs: list[list[int]]
device lists of the balance groups (internal commodity nodes), empty groups dropped
- band_lookup: dict[int, list[tuple[float, float]]]
device index -> its signed power bands (S2 operation modes)
- commitments: list[DataFrame]
Sub-commitments (one per commitment group and deviation direction), and the mapping from each sub-commitment index back to its original commitment index
- property commodity_devices: dict
commodity -> set(device indices).
Computed on demand: only the EMS-level flow commitment constraints need it, and the per-row scan is not cheap enough to pay for unconditionally.
- convex_cost_curve: bool
Whether every commitment’s deviation prices describe a convex cost curve (a non-convex curve needs binary commitment-sign variables). A merged sub-commitment is judged on the summed prices it carries, which is the point of merging it.
- coupling_device_specs: list[tuple[int, int, float]]
(group index, device index, coefficient) triples for hard flow-coupling constraints
- device_constraints: list[DataFrame]
Device constraints, with a “stock delta” column guaranteed to be present
- device_group_lookup: dict[int, dict]
sub-commitment index -> {device group label -> member device indices}
- device_to_group: dict[int, str]
device -> its primary stock group key, and stock group key -> member devices
- ems_constraints_list: list[DataFrame]
EMS constraints, normalised to a list, plus the device indices each applies to
- initial_stock_of(d) float
The initial stock of device
d, defaulting to 0.Device indices reaching this from a commitment’s “device” column may be numpy floats, hence the cast.
- merged_constituents: dict[int, list[tuple]]
Per merged sub-commitment index, the
(original index, upwards price, downwards price)of each commitment merged into it, so that its share of the realised cost can be worked out again.
- original_commitments: list[DataFrame]
The commitments as passed in, before the sub-commitment split. Only kept to derive
commodity_deviceslazily.