Load shedding¶
Load shedding finds the minimum demand that must be curtailed to keep a network feasible under given operational limits. monee ships a ready-made load-shedding formulation for multi-energy networks: a one-call interface for the common case, and a builder function when you need to customise it.
How it works¶
The optimiser makes each demand, generator, and coupling unit controllable
via a regulation multiplier in [0, 1]. It then minimises the total
deviation from full service, subject to operational bounds on voltage
(electrical), pressure (gas), and temperature (heat):
Bound argument |
Controls |
|---|---|
|
Voltage magnitude at buses (per unit, e.g. |
|
Normalised temperature at heating junctions (per unit) |
|
Normalised pressure at gas junctions (per unit) |
|
Active power / mass-flow range at external grid connections |
One-call interface¶
Tip
Use monee.solve_load_shedding_problem() for the simplest path: it
picks sensible defaults and requires minimal configuration.
from monee import solve_load_shedding_problem
result = solve_load_shedding_problem(
network,
bounds_vm=(0.9, 1.1),
bounds_t=(0.8, 1.2),
bounds_pressure=(0.8, 1.2),
bounds_ext_el=(-0.5, 0.5),
bounds_ext_gas=(-2.0, 2.0),
)
print(f"Objective (shed load cost): {result.objective:.4f}")
The result is a SolverResult with the solved
network and DataFrames for each model type. The bound and check arguments are
keyword-only and mirror create_min_load_shedding_problem()
one-to-one.
Custom problem¶
For finer control (different weights per carrier, extra constraints, or
different bounds) use
create_min_load_shedding_problem() directly:
import monee.express as mx
from monee import run_energy_flow_optimization
from monee.problem import create_min_load_shedding_problem
# Build a small test network
net = mx.create_multi_energy_network()
bus_0 = mx.create_bus(net)
bus_1 = mx.create_bus(net)
mx.create_line(net, bus_0, bus_1, 100, r_ohm_per_m=7e-5, x_ohm_per_m=7e-5)
mx.create_ext_power_grid(net, bus_0)
mx.create_power_load(net, bus_1, p_mw=0.4, q_mvar=0.0)
# Create and customise the load-shedding problem
problem = create_min_load_shedding_problem(
demand_weight=20, # penalty per MW of shed demand
bounds_vm=(0.92, 1.08), # tighter voltage bounds
check_pressure=False, # no gas grid present
check_t=False, # no heat grid present
debug=False,
)
result = run_energy_flow_optimization(
net, problem, exclude_unconnected_nodes=True
)
Note
Pass exclude_unconnected_nodes=True when the network may contain buses
or junctions that are topologically disconnected (e.g. because a line is
deactivated). Without this flag such nodes enter the solve and can cause
infeasibility. With it, disconnected components are excluded and their
regulation is reported as 0.0 (fully shed) in the result.
Key parameters of create_min_load_shedding_problem:
Parameter |
Effect |
|---|---|
|
Penalty factors applied to each unit of shed demand / curtailed
generation. Higher demand weight pushes the solver to avoid load
curtailment. Defaults: |
|
Callback |
|
|
|
|
|
|
|
Maximum electrical line loading. Default: |
|
Disable individual bound checks for carriers not present in the network. |
|
Exchange range at external grids: active power in MW for electricity,
mass flow in kg/s for gas and heat. Defaults: |
|
Make external grids controllable, bound their exchange, and add a
quadratic slack that nudges exchange toward zero, weighted at
|
|
Make storage units controllable (dispatchable) during shedding.
Default: |
|
Penalise curtailment of coupling points like ordinary loads on their
input carrier. Default: |
|
Temporal constraint: limits how much each |
|
Two-phase lexicographic solve (Pyomo only) that removes weight tuning
entirely. Default: |
|
Lift the demand weights so shed cost always dominates
formulation-tightening terms regardless of network size.
Defaults: |
|
Enable verbose solver output. Default: |
Prioritising individual loads¶
By default every demand is shed at the same cost per MW. To establish a
priority ordering (keep the hospital, shed the warehouse first), pass
weight_for_load, a callback that receives a load model and returns its
shed weight, or None to fall back to demand_weight. A convenient
pattern is to tag the models with a custom attribute when building the
network:
import monee.express as mx
from monee.problem import create_min_load_shedding_problem
hospital_id = mx.create_power_load(net, bus_1, p_mw=0.4, q_mvar=0.0)
net.child_by_id(hospital_id).model._priority_weight = 1e5 # shed last
problem = create_min_load_shedding_problem(
# None -> fall back to demand_weight for untagged loads
weight_for_load=lambda model: getattr(model, "_priority_weight", None),
)
The callback applies to all demand-type models (PowerLoad, HeatLoad,
gas Sink) and to every heat-exchanger variant, load and generator side
alike. All per-load weights are multiplied by the shared auto-priority-floor
scale (see below), so the ratios you choose are preserved even when monee
lifts the weights.
Why the default weights just work¶
Some network formulations (e.g. MISOCP electricity, the smooth nonlinear gas
and heat models) add small auxiliary objective terms that tighten their
relaxations. On a large network these terms accumulate, and a fixed
demand_weight could be undercut: the solver would rather shed load than
pay the tightening cost. With auto_priority_floor=True (the default),
monee computes an upper bound of the total auxiliary objective at apply time
and lifts the demand weights to at least priority_safety_factor times
that bound, so shedding always dominates the auxiliary terms regardless of
network size. The generator weight and any weight_for_load weights are
scaled by the same factor, preserving the relative priorities you set.
Lexicographic objectives¶
Instead of weighting shed cost against the auxiliary terms, you can separate
them entirely: with lex_objectives=True the problem is solved in two
phases. Phase one minimises shed load alone, phase two minimises the
auxiliary tightening terms with the phase-one optimum pinned. This removes
weight tuning altogether, at the cost of a second solve. It requires the
Pyomo backend (PyomoSolver); GEKKO falls back to a
single summed objective. See Solvers & Backends for choosing a
backend.
Shedding coupling points¶
By default, coupling units between carriers are controllable but their
curtailment is free: only the downstream service loss is penalised. With
include_coupling_points=True, coupling points (the CHP, CHPHG, G2H, and
P2H control nodes as well as the P2G, G2P, P2H-HG, and G2H-HG branches) are
penalised at demand_weight * rated input MW * (1 - regulation): each
coupling point is treated as a load on its input carrier (power or gas),
with gas input converted to MW via 3.6 * higher_heating_value_kwh_per_kg. Use this
when curtailing a conversion unit should count as lost demand in its own
right.
What counts as shed energy¶
The objective sums weighted unserved energy in MW-equivalent across all carriers. The conversion conventions are:
Electrical and heat loads/generators contribute
setpoint * (1 - regulation)directly in MW.Gas sinks and sources are mass flows in kg/s; their shed is converted to MW via
3.6 * higher_heating_value_kwh_per_kgof the enclosing gas grid (fallback: the lgas heating value of about 11.79 kWh/kg if the grid defines none).Water-grid sinks and sources are deliberately excluded: mass flow in a heating loop is a transport quantity, not demand, and applying a heating value to it would produce a phantom MW-scale penalty.
Heat exchangers are penalised by the under-delivery gap
|q_mw_set - q_mw_delivered|, which captures both regulation cuts and physical shortfall from a narrow temperature spread that a pure(1 - regulation)proxy would miss.
Interpreting the result¶
After solving, the regulation attribute of each controllable component
shows how much of its nominal setpoint was served. A value of 1.0 means
fully served; 0.0 means completely shed.
for child in result.network.childs:
reg = getattr(child.model, "regulation", None)
if reg is not None:
print(f"{child.name or child.id}: regulation = {reg:.2f}")
Note
A regulation value between 0 and 1 indicates partial load curtailment.
Inspect the dataframes dict for per-component voltage, pressure, and
temperature to understand why curtailment was needed.
Measuring curtailment¶
To quantify how much demand was actually curtailed, use
GeneralResiliencePerformanceMetric on the solved
network: it returns curtailed MW per carrier as a
(power, heat, gas) tuple:
from monee.problem import GeneralResiliencePerformanceMetric
power_mw, heat_mw, gas_mw = GeneralResiliencePerformanceMetric().calc(
result.network,
include_ext_grid=True,
include_coupling_points=False,
)
or, equivalently, via the convenience wrapper
calc_general_resilience_performance():
from monee.problem import calc_general_resilience_performance
power_mw, heat_mw, gas_mw = calc_general_resilience_performance(result.network)
Ignored or inactive loads (e.g. excluded by exclude_unconnected_nodes)
count at their full rating; regulated loads count at
upper - value * regulation. Gas curtailment is converted to MW with the
same 3.6 * higher_heating_value_kwh_per_kg convention as the objective. Pass
include_coupling_points=True to also account coupling-point curtailment
on the input carrier, mirroring the corresponding problem option.