Control and Optimisation¶
Controllers are simulators that read measurements, optionally look ahead with a Forecast and write set-points (MPCController solves an economic LP/MILP over library Blocks, OPFController wraps pandapower OPF, PyPSARollingHorizonController wraps pypsa.Network.optimize). Solver-based controllers need scipy (pip install energysim[control]). The status of each feature is tracked in features.yaml; see Validation for the cross-checks against independent LPs.
Controllers¶
Controller¶
Module: energysim.control.base
Controller(
name: 'str' = 'controller',
measurement_inputs: 'Sequence[str]' = (),
setpoint_outputs: 'Sequence[str]' = (),
forecasts: 'Optional[Mapping[str, Any]]' = None,
initial_setpoints: 'Optional[Mapping[str, float]]' = None,
initial_measurements: 'Optional[Mapping[str, float]]' = None,
units: 'Optional[Mapping[str, str]]' = None,
decide_at_init: 'Optional[bool]' = None,
actuation_delay: 'bool' = True,
step_size: 'float' = 900.0,
)
Base class of controllers (optimisers, MPC, OPF, rule-based ...).
Parameters¶
| Parameter | Type | Description |
|---|---|---|
name |
str | Controller name (used in messages). |
measurement_inputs |
list[str] | Names of the inputs (measurements / external signals). |
setpoint_outputs |
list[str] | Names of the set-point outputs. The diagnostic outputs solver_status (0 = optimal, 1 = limit, 2 = infeasible, 3 = unbounded, 4 = error, -1 = not solved yet), solve_time_s, objective and fallback (0 none, 1 previous plan, 2 rule) are always added. |
forecasts |
dict[str, Forecast], optional | Forecast providers evaluated at every decision over horizon_times and handed to decide. |
initial_setpoints |
dict, optional | Output values before the first decision (default 0.0). |
initial_measurements |
dict, optional | Measurement values assumed before the first exchange (default 0.0). |
units |
dict, optional | {variable: unit} for inputs / outputs (used by world unit conversion). |
decide_at_init |
bool, optional | Take a decision in init (default: True when initial_measurements were given). |
actuation_delay |
bool, default True | See the module docstring. |
step_size |
float, default 900 | Decision interval used by step (advance decides once per call). |
Methods¶
| Method | Summary |
|---|---|
advance(start_time, stop_time) |
One decision per world macro step (delay = macro step). |
advance_with_recording(start_time, stop_time, outputs) |
Advance while recording output values at every micro-step. |
configure(start_time, stop_time, macro_step) |
Receive the run's time frame before init is called. |
decide(t, measurements, forecasts) |
Return {setpoint: value} for the interval starting at t. |
evaluate_forecasts(t_act, measurements) |
|
get_available_variables() |
Return {'inputs': [...], 'outputs': [...]} of known variable names. |
get_value(parameters, time=None) |
Return a list of values corresponding to parameters. |
horizon_times(t_act) |
Start times of the forecast horizon intervals (default: just t_act). |
init() |
Start a fresh run: outputs, measurements, history and forecasts are reset. |
reset_controller() |
Hook for subclasses: clear internal state (plans, estimates) at init(). |
set_value(parameters, values) |
Set a list of parameters to the given values. |
step(time) |
Decide once at time (stand-alone use; delay = step_size). |
RuleController¶
Module: energysim.control.base
Controller defined by a plain function rule(t, measurements, forecasts) -> dict.
Handy as a baseline or as the fallback of an optimising controller.
Methods¶
| Method | Summary |
|---|---|
decide(t, measurements, forecasts) |
Return {setpoint: value} for the interval starting at t. |
MPCController¶
Module: energysim.control.mpc
MPCController(
blocks: 'Sequence[Block]',
forecasts: 'Optional[Mapping[str, Any]]' = None,
horizon: 'int' = 24,
dt: 'float' = 900.0,
co2_price: 'float' = 0.0,
unserved_penalty: 'Optional[float]' = 10000.0,
spill_penalty: 'Optional[float]' = None,
extra_inputs: 'Sequence[str]' = (),
fallback: 'Union[str, Callable]' = 'hold',
horizon_end: 'Optional[float]' = None,
delay_compensation: 'bool' = True,
time_limit: 'Optional[float]' = None,
mip_rel_gap: 'Optional[float]' = 1e-06,
name: 'str' = 'mpc',
decide_at_init: 'bool' = True,
**kwargs,
)
Economic MPC over a set of Block objects.
Parameters¶
| Parameter | Type | Description |
|---|---|---|
blocks |
list[Block] | Assets of the problem. Outputs are named "<block>.<var>" (e.g. bess.P_cmd), measured states likewise (bess.SoC). |
forecasts |
dict[str, Forecast] | Providers referenced by name in block parameters ('price', 'load'...). |
horizon |
int, default 24 | Number of intervals. |
dt |
float, default 900 | Interval length in seconds. |
co2_price |
float, default 0 | EUR per tonne CO2. |
unserved_penalty |
float or None, default 1e4 | EUR per MWh of unserved demand per carrier; None makes the balances hard (the problem can then be infeasible). |
spill_penalty |
float or None | If set, surplus may be spilled at this cost (EUR/MWh). |
extra_inputs |
list[str] | Additional inputs (e.g. a connected price signal usable as a persistence forecast by name). |
fallback |
'hold' | 'zero' | callable | Rule used when the solve fails and no previous plan covers the current time: keep the previous set-points, use zeros, or call fallback(t, measurements, forecasts) -> dict. |
horizon_end |
float, optional | Absolute time beyond which the horizon is not extended (shrinking horizon). |
delay_compensation |
bool, default True | Predict the state at the actuation time (see module docstring). |
time_limit |
float, optional | HiGHS options. |
mip_rel_gap |
float, optional | HiGHS options. |
Methods¶
| Method | Summary |
|---|---|
block(name) |
|
decide(t, measurements, forecasts) |
Return {setpoint: value} for the interval starting at t. |
horizon_times(t_act) |
Start times of the forecast horizon intervals (default: just t_act). |
plan_open_loop(t0, n_steps=None, measurements=None) |
Solve one problem from t0 over n_steps (default horizon) from the initial |
reset_controller() |
Hook for subclasses: clear internal state (plans, estimates) at init(). |
solve_horizon(times, states, forecasts=None, measurements=None) |
Build and solve the problem over times from the given block states. |
status_text() |
OPFController¶
Module: energysim.control.opf
OPFController(
network: 'Any',
inputs: 'Sequence[str]' = (),
outputs: 'Sequence[str]' = (),
controllable: 'Optional[Iterable[str]]' = None,
costs: 'Optional[Mapping[str, Union[float, Mapping[str, float]]]]' = None,
mode: 'str' = 'ac',
copy: 'bool' = True,
opf_kwargs: 'Optional[Mapping[str, Any]]' = None,
name: 'str' = 'opf',
**kwargs,
)
pandapower OPF wrapped as an energysim controller.
Parameters¶
| Parameter | Type | Description |
|---|---|---|
network |
pandapowerNet, pp_adapter, or path to a pickle / json network | The network model (deep-copied unless copy=False). |
inputs |
list[str] | "element.column" names set on the network before every OPF. |
outputs |
list[str] | "element.column" result names (and the special outputs cost, max_line_loading_percent, max_trafo_loading_percent). |
controllable |
iterable of element names, optional | Elements whose controllable flag is set to True. |
costs |
dict, optional | {element: cp1_eur_per_mw} or {element: {cp0_eur:..., cp1_eur_per_mw:..., ...}}; missing poly_cost rows are created with pp.create_poly_cost. |
mode |
'ac' | 'dc' | runopp or rundcopp. |
opf_kwargs |
dict, optional | Extra keyword arguments for the pandapower OPF call. |
Validity and notes¶
On non-convergence the previous set-points are kept (fallback = 1) and
solver_status is 2 (infeasible / not converged) or 4 (other error).
Methods¶
| Method | Summary |
|---|---|
decide(t, measurements, forecasts) |
Return {setpoint: value} for the interval starting at t. |
reset_controller() |
Hook for subclasses: clear internal state (plans, estimates) at init(). |
run_opf() |
Run the pandapower OPF on the controller's network (raises on failure). |
PyPSARollingHorizonController¶
Module: energysim.control.pypsa_rolling
PyPSARollingHorizonController(
network: 'Any',
window: 'int',
setpoints: 'Mapping[str, Sequence]',
measurements: 'Optional[Mapping[str, Sequence]]' = None,
snapshot_seconds: 'Optional[float]' = None,
start_snapshot: 'int' = 0,
solver_name: 'str' = 'highs',
solver_options: 'Optional[Mapping]' = None,
copy: 'bool' = True,
name: 'str' = 'pypsa_mpc',
**kwargs,
)
Receding-horizon controller around pypsa.Network.optimize.
Parameters¶
| Parameter | Type | Description |
|---|---|---|
network |
pypsa.Network | Multi-period network with time series over (at least) the simulated period. |
window |
int | Number of snapshots optimised at each decision. |
setpoints |
dict[str, tuple] | {output_name: (component, attribute, element[, scale])} read from the first window snapshot (e.g. {'bess.P_cmd': ('StorageUnit', 'p', 'bess', -1.0)}). |
measurements |
dict[str, tuple], optional | {input_name: (component, attribute, element[, scale])} written before each solve (e.g. {'bess.SoC': ('StorageUnit', 'state_of_charge_initial', 'bess', 4.0)}). |
snapshot_seconds |
float, optional | Snapshot spacing in seconds (default: inferred from a DatetimeIndex, else 3600). |
Methods¶
| Method | Summary |
|---|---|
decide(t, measurements, forecasts) |
Return {setpoint: value} for the interval starting at t. |
reset_controller() |
Hook for subclasses: clear internal state (plans, estimates) at init(). |
snapshot_index(t) |
Forecasts¶
Forecast¶
Module: energysim.control.forecast
Base class of forecast providers.
PerfectForecast¶
Module: energysim.control.forecast
PerfectForecast(
data,
column: 'Optional[str]' = None,
time_column: 'Optional[str]' = None,
periodic: 'Optional[float]' = None,
scale: 'float' = 1.0,
)
Read ahead in a known time series (an oracle forecast).
Parameters¶
| Parameter | Type | Description |
|---|---|---|
data |
DataFrame, Series, str (CSV path), dict or (times, values) tuple |
The time series. For a DataFrame / CSV the time axis is the time_column (default 'time' if present, else the index); for a Series its index. Times are seconds. |
column |
str, optional | Value column (required for DataFrames with more than one candidate). |
periodic |
float, optional | Period in seconds; times are wrapped modulo the period (e.g. 86400 to repeat a daily profile). Without it values beyond the series hold the first / last sample. |
scale |
float, default 1.0 | Multiplier applied to the values (unit conversion, e.g. 1e-3 kW->MW). |
PersistenceForecast¶
Module: energysim.control.forecast
Repeat the latest measured value over the whole horizon.
Parameters¶
| Parameter | Type | Description |
|---|---|---|
input |
str, optional | Name of the controller input holding the measured value. |
default |
float, default 0.0 | Value used when no measurement is available. |
CallableForecast¶
Module: energysim.control.forecast
Wrap fn(t, horizon_times) -> array | scalar.
NoisyForecast¶
Module: energysim.control.forecast
NoisyForecast(
base: 'Forecast',
sigma: 'float',
seed: 'Optional[int]' = None,
relative: 'bool' = False,
growth: 'float' = 0.0,
exact_first: 'bool' = False,
)
Add Gaussian noise to another forecast (for robustness / ensemble studies).
sigma is absolute (same unit as the base forecast) unless
relative=True (fraction of the base value). The noise standard
deviation grows linearly with lead time: sigma_k = sigma * (1 + growth * k).
The first horizon value is left noise-free when exact_first=True.
The generator is re-seeded at every reset() (i.e. every run), so equal
seeds give identical runs.
MPC building blocks¶
Block¶
Module: energysim.control.blocks
Base class: one asset in the optimisation problem.
Subclasses implement build and extract; stateful blocks
also initial_state, state_from_measurements and
predict. Variable names exposed by the controller are prefixed
with "<block name>.".
StorageBlock¶
Module: energysim.control.blocks
StorageBlock(
name: 'str',
capacity: 'float',
p_charge_max: 'float',
p_discharge_max: 'Optional[float]' = None,
eta_c: 'float' = 1.0,
eta_d: 'float' = 1.0,
soc_min: 'float' = 0.0,
soc_max: 'float' = 1.0,
soc0: 'float' = 0.5,
self_discharge: 'float' = 0.0,
carrier: 'str' = 'electricity',
setpoint: 'str' = 'P_cmd',
charge_positive: 'bool' = True,
soc_measurement: 'Optional[str]' = 'SoC',
soc_terminal: 'Optional[float]' = None,
terminal_value: 'Optional[float]' = None,
cycle_cost: 'float' = 0.0,
use_binary: 'bool' = False,
)
Generic storage (battery, thermal tank, hydrogen tank ...).
Dynamics (e stored energy, dt_h hours):
Parameters¶
| Parameter | Type | Description |
|---|---|---|
capacity |
float | Usable capacity in MWh (kg for hydrogen). |
p_charge_max |
float | Power limits (MW; kg/h for hydrogen). |
p_discharge_max |
float | Power limits (MW; kg/h for hydrogen). |
eta_c |
float | Charge / discharge efficiencies. |
eta_d |
float | Charge / discharge efficiencies. |
soc_min |
float | SoC window and initial SoC (fractions). |
soc_max |
float | SoC window and initial SoC (fractions). |
soc0 |
float | SoC window and initial SoC (fractions). |
self_discharge |
float | Fraction of the stored energy lost per hour. |
setpoint |
str, default 'P_cmd' | Name of the set-point output; charge_positive gives its sign. |
soc_measurement |
str or None, default 'SoC' | Name of the measured SoC input (fraction); None = open loop. |
soc_terminal |
float, optional | Minimum SoC at the end of the horizon. |
terminal_value |
float, optional | Value (EUR/MWh) of energy left at the end of the horizon. |
cycle_cost |
float, default 0 | Throughput cost (EUR/MWh charged + discharged). |
use_binary |
bool, default False | Forbid simultaneous charge and discharge with a binary (MILP). |
ElectrolyzerBlock¶
Module: energysim.control.blocks
ElectrolyzerBlock(
name: 'str',
p_max: 'float',
p_min: 'float' = 0.0,
curve: 'Optional[Sequence[Tuple[float, float]]]' = None,
spec_consumption: 'float' = 55.0,
use_binary: 'bool' = False,
setpoint: 'str' = 'P_cmd',
carrier_in: 'str' = 'electricity',
carrier_out: 'str' = 'hydrogen',
standby_cost: 'float' = 0.0,
)
Electrolyser: electricity -> hydrogen with a piecewise-linear production curve.
P = p_min * on + sum(seg_j), H2 = h(p_min) * on + sum(slope_j * seg_j)
with 0 <= seg_j <= width_j * on. The curve is replaced by its concave
envelope (exact for concave curves, slightly optimistic otherwise). With
use_binary=True on is binary and the minimum turndown p_min is
enforced exactly (MILP); otherwise on is relaxed to [0, 1] (LP).
Parameters¶
| Parameter | Type | Description |
|---|---|---|
p_max |
float | Rated electrical power (MW). |
p_min |
float | Minimum turndown (MW). |
curve |
sequence of (P_MW, H2_kg_per_h), optional | Production curve between p_min and p_max. Default: constant spec_consumption (kWh/kg). |
HeatPumpBlock¶
Module: energysim.control.blocks
HeatPumpBlock(
name: 'str',
q_max: 'float',
cop: 'Any' = 3.0,
q_min: 'float' = 0.0,
setpoint: 'str' = 'Q_heat_cmd_MW',
carrier_in: 'str' = 'electricity',
carrier_out: 'str' = 'heat',
)
Heat pump: P_elec = Q_heat / COP; COP fixed or time-varying (forecast).
BoilerBlock¶
Module: energysim.control.blocks
BoilerBlock(
name: 'str',
p_max: 'float',
efficiency: 'float' = 1.0,
fuel: 'Optional[str]' = 'electricity',
fuel_price: 'Any' = 0.0,
co2_intensity: 'float' = 0.0,
ramp: 'Optional[float]' = None,
setpoint: 'str' = 'P_cmd',
carrier_out: 'str' = 'heat',
)
Boiler: heat = efficiency * fuel.
fuel is a carrier name whose balance supplies the input (e.g.
'electricity' for an electric boiler) or None for fuel bought at
fuel_price (EUR/MWh fuel). co2_intensity is t CO2 per MWh fuel.
The set-point is the fuel / electric input power.
GeneratorBlock¶
Module: energysim.control.blocks
GeneratorBlock(
name: 'str',
p_max: 'float',
p_min: 'float' = 0.0,
cost: 'Any' = 0.0,
no_load_cost: 'float' = 0.0,
ramp: 'Optional[float]' = None,
co2_intensity: 'float' = 0.0,
no_load_co2: 'float' = 0.0,
heat_ratio: 'float' = 0.0,
carrier: 'str' = 'electricity',
heat_carrier: 'str' = 'heat',
use_binary: 'bool' = False,
setpoint: 'str' = 'P_cmd',
)
Dispatchable generator (optionally CHP) with unit-commitment relaxation.
p_min * on <= p <= p_max * on; cost cost * p + no_load_cost * on
(EUR/MWh, EUR/h); CO2 co2_intensity * p + no_load_co2 * on (t/MWh,
t/h); ramp limit in MW/h; recoverable heat <= heat_ratio * p. on
is binary with use_binary=True (MILP) else relaxed to [0, 1].
GridBlock¶
Module: energysim.control.blocks
GridBlock(
name: 'str' = 'grid',
import_limit: 'Any' = inf,
export_limit: 'Any' = inf,
price_import: 'Any' = 0.0,
price_export: 'Any' = 0.0,
peak_cost: 'float' = 0.0,
co2_intensity: 'Any' = 0.0,
carrier: 'str' = 'electricity',
)
Market / grid connection: import and export at (time-varying) prices.
peak_cost (EUR/MW) prices the maximum import over the horizon (demand
charge). co2_intensity (t/MWh) applies to imports. Make sure
price_export <= price_import or limit the exchange, otherwise the
problem is unbounded. Works for any carrier (e.g. a hydrogen offtaker
with carrier='hydrogen', import_limit=0, price_export=4.0).
LoadBlock¶
Module: energysim.control.blocks
LoadBlock(
name: 'str',
demand: 'Any',
carrier: 'str' = 'electricity',
shiftable_fraction: 'float' = 0.0,
shift_max: 'Optional[float]' = None,
)
Demand (forecast) with an optional shiftable share.
The fixed part (1 - f) * d must be served when it occurs; the
shiftable part f * d may be served at any interval of the horizon
(0 <= s <= shift_max, total energy conserved within the horizon).
The output P_set is the planned load (a set-point for flexible loads).
RenewableBlock¶
Module: energysim.control.blocks
RenewableBlock(
name: 'str',
available: 'Any',
curtailable: 'bool' = True,
carrier: 'str' = 'electricity',
cost: 'Any' = 0.0,
)
Variable renewable source: 0 <= p <= available (== available if not curtailable).
PlanContext¶
Module: energysim.control.blocks
PlanContext(
lp: 'LinearProblem',
times: 'np.ndarray',
dt: 'float',
forecasts: 'Optional[Mapping[str, np.ndarray]]' = None,
measurements: 'Optional[Mapping[str, float]]' = None,
co2_price: 'float' = 0.0,
)
Shared state while the blocks build one horizon problem.
Linear programs¶
LinearProblem¶
Module: energysim.control.lp
Incrementally built min c'x s.t. lo <= A x <= hi, lb <= x <= ub.
LPResult¶
Module: energysim.control.lp
LPResult(
status: 'int',
x: 'Optional[np.ndarray]',
objective: 'float',
message: 'str' = '',
solve_time_s: 'float' = 0.0,
)
Outcome of LinearProblem.solve.