02 - Flexibility use cases: what the house can do for the grid, and what it costs¶
Learning objectives
- Quantify implicit flexibility: what the price-aware controller saves against a price-agnostic one under day-ahead prices, how much energy it moves in time, and what planning on the 15-minute market time unit changes.
- Shave the peak under a capacity tariff on the monthly peak (the Flemish capaciteitstarief): how the controller trades energy cost against capacity cost, and why the floor of the tariff matters for a small heat pump.
- Deliver explicit flexibility: respond to an up or down activation request against a baseline, measure the delivered energy and the rebound.
- Map the flexibility envelope (power versus duration, at what cost) and the response to a price step (the flexibility function).
- See what negative prices do to a house with PV.
The definitions follow IEA EBC Annex 67 and the characterisation of Reynders, Diriken and Saelens (2017), the
flexibility function of Junker et al. (2018) and the BOPTEST KPIs of Blum et al. (2021); every source is listed
in tutorials/references.md. The closed loops (against the true building of tutorial 01, with moving horizon
estimation) come from tutorials/cache/flexibility/ (make cache); the plans of section 1 are solved live.
Runtimes (this container): a two-day hourly closed loop about ten minutes; a 24-hour plan 5 to 20 s at hourly steps, one to two minutes at 15-minute steps; the envelope about twenty plans.
import sys
if "google.colab" in sys.modules: # Colab: fetch the tutorials, install mpctrano and rumoca (a no-op elsewhere)
import subprocess
subprocess.run( # a fixed command; the clone is skipped when it exists
["git", "clone", "-q", "--depth", "1", "https://github.com/andoludo/mpctrano", "/content/mpctrano"], check=False
)
sys.path.insert(0, "/content/mpctrano")
from tutorials import _colab
_colab.setup()
import pandas as pd
from IPython.display import display
from mpctrano import ComfortSpec, ModelPredictiveController, MPCSettings, price_response
from mpctrano.flexibility import compare, shifted_energy
from mpctrano.notebook import closed_loop_explorer, kpi_table, quick_mode, timed
from mpctrano.plot import plot_activation, plot_closed_loop, plot_envelope, plot_plan
from tutorials import _flexibility as study
from tutorials._house import START, building
pd.set_option("display.width", 200)
print(f"quick mode: {quick_mode()}; cached runs: {study.DAYS:g} days from {START.date()}")
tariffs = study.tariffs()
quick mode: False; cached runs: 2 days from 2023-01-16
1. Implicit flexibility under day-ahead prices¶
The price-agnostic controller has the same model, comfort band and constraints, but plans at the mean price of
the period; costing its run with the day-ahead tariff gives what the house would pay without reacting to the
price. The difference is what the flexibility is worth under that tariff (value_of_flexibility).
rows = {}
for variant in ("base", "old"):
aware = study.cached(study="implicit", variant=variant, pricing="aware", days=study.DAYS)
agnostic = study.cached(study="implicit", variant=variant, pricing="agnostic", days=study.DAYS)
costed = agnostic.metrics_under(tariffs["day-ahead"])
rows[variant] = {
"aware cost": aware.metrics.total_cost,
"agnostic cost": costed.total_cost,
"saving %": 100 * (costed.total_cost - aware.metrics.total_cost) / costed.total_cost,
"aware kWh": aware.metrics.energy_kwh,
"agnostic kWh": costed.energy_kwh,
"shifted kWh": shifted_energy(aware, agnostic),
"low-price share aware": aware.metrics.low_price_share,
"low-price share agnostic": costed.low_price_share,
"comfort aware K.h": aware.metrics.comfort_violation_kh,
"comfort agnostic K.h": costed.comfort_violation_kh,
}
display(pd.DataFrame(rows).T.round(2))
aware = study.cached(study="implicit", variant="base", pricing="aware", days=study.DAYS)
display(plot_closed_loop(aware, "base house, price aware, day-ahead prices"))
| aware cost | agnostic cost | saving % | aware kWh | agnostic kWh | shifted kWh | low-price share aware | low-price share agnostic | comfort aware K.h | comfort agnostic K.h | |
|---|---|---|---|---|---|---|---|---|---|---|
| base | 3.21 | 3.23 | 0.45 | 12.60 | 12.49 | 0.76 | 0.58 | 0.57 | 1.40 | 1.54 |
| old | 6.46 | 6.49 | 0.45 | 25.64 | 25.35 | 3.19 | 0.59 | 0.59 | 2.53 | 2.59 |
The share of energy bought below the median price is the simplest signature of load shifting; price_response
gives the whole distribution: how many kWh were bought at each price level.
display(price_response(aware).sort_index().round(3).tail(8))
| sum | count | share | |
|---|---|---|---|
| price | |||
| 0.301051 | 0.329 | 1 | 0.026 |
| 0.302367 | 0.338 | 1 | 0.027 |
| 0.304052 | 0.319 | 1 | 0.025 |
| 0.305502 | 0.340 | 1 | 0.027 |
| 0.310095 | 0.247 | 1 | 0.020 |
| 0.312109 | 0.266 | 1 | 0.021 |
| 0.314026 | 0.243 | 1 | 0.019 |
| 0.318597 | 0.330 | 1 | 0.026 |
Hourly or 15-minute planning¶
Since 1 October 2025 the single day-ahead coupling trades 15-minute products. Below, the same plan at hourly and at 15-minute steps (the same 24-hour horizon: 24 or 96 steps, four times the variables). A heat pump on a buffer can follow a quarter-hour price only through its compressor: compare the energy bought and the cost.
house = building("base")
horizon_hours = 6 if quick_mode() else 24
plans = {}
for label, time_step in (("1 h", 3600.0), ("15 min", 900.0)):
steps = round(horizon_hours * 3600 / time_step)
controller = ModelPredictiveController(
house.model, MPCSettings(horizon=steps, time_step=time_step), ComfortSpec(), tariffs["day-ahead"]
)
with timed(f"plan at {label}"):
plans[label] = controller.solve(house.initial_state(), house.forecaster.forecast(START, steps, time_step))
summary = {}
for label, plan in plans.items():
step = (plan.index[1] - plan.index[0]).total_seconds()
summary[label] = {
"status": plan.status,
"cost EUR": plan.economic_cost,
"electricity kWh": float(plan.meter["P_meter"].sum()) * step / 3.6e6,
"peak kW": float(plan.meter["P_meter"].max()) / 1000,
"solve s": plan.solve_time,
}
display(pd.DataFrame(summary).round(3))
display(plot_plan(plans["15 min"], house.model, "base house, 15-minute plan"))
plan at 1 h: 17.7 s
plan at 15 min: 301.0 s
| 1 h | 15 min | |
|---|---|---|
| status | Solve_Succeeded | Solve_Succeeded |
| cost EUR | 1.39092 | 1.437567 |
| electricity kWh | 5.430996 | 5.847839 |
| peak kW | 0.85689 | 0.946388 |
| solve s | 8.618793 | 300.949893 |
2. Peak shaving under the capacity tariff¶
The Flemish distribution tariff bills the average of the twelve monthly peaks (highest quarter-hour average power of the month, floored at 2.5 kW) at about 50 EUR/kW/year (2025 levels: 48.9 to 56.9 depending on the area). One extra kilowatt of monthly peak costs about 4 EUR; the controller carries the peak of the month so far and weighs the energy saving of a cheap-hour boost against the capacity cost of a higher peak. The runs use a time-of-use tariff and a deep night setback (5 K), so that the morning recovery makes the peak. With hourly steps the peak is the hourly average, which underestimates the quarter-hour peak.
capacity = {label: study.cached(**parts) for label, parts in study.capacity_runs().items()}
rows = []
for variant in ("base", "old"):
subset = {label: result for label, result in capacity.items() if label.startswith(variant)}
comparison = compare(subset, baseline=f"{variant} | none")
rows.append(
comparison[["total_cost", "capacity_cost", "peak_power_kw", "peak_reduction_pct", "comfort_violation_kh"]]
)
display(pd.concat(rows).round(2))
| total_cost | capacity_cost | peak_power_kw | peak_reduction_pct | comfort_violation_kh | |
|---|---|---|---|---|---|
| base | none | 3.42 | 0.00 | 1.77 | 0.00 | 0.96 |
| base | 50 EUR/kW/year, 2.5 kW floor | 13.84 | 10.42 | 1.77 | 0.00 | 0.96 |
| base | 150 EUR/kW/year, no floor | 8.36 | 4.29 | 0.34 | 80.59 | 1.64 |
| old | none | 6.35 | 0.00 | 3.66 | 0.00 | 1.75 |
| old | 50 EUR/kW/year, 2.5 kW floor | 17.09 | 10.49 | 2.52 | 31.20 | 1.82 |
| old | 150 EUR/kW/year, no floor | 17.66 | 9.84 | 0.79 | 78.47 | 2.18 |
dimensions = {"variant": ["base", "old"], "capacity": list(study.CAPACITY)}
display(closed_loop_explorer(study.CACHE, study.capacity_runs(), study.compute, dimensions))
display(plot_closed_loop(capacity["old | 50 EUR/kW/year, 2.5 kW floor"], "old house under the Flemish capacity tariff"))
3. Explicit flexibility: activation requests and the rebound¶
An aggregator or the DSO asks for a change of the electricity bought during a window against a baseline (here
the run of the same controller without the request). The controller receives the request as a soft cap (down)
or floor (up) one hour before the window (ActivationRequest.notice_hours), delivers what the comfort band
allows and rebounds afterwards: the heat not delivered during a shed is delivered later, the heat stored
during an upward activation is not bought later. The metrics give the delivered energy, the rebound energy and
peak and their ratio, in the spirit of the available storage capacity and storage efficiency of Reynders et al.
results = {label: study.activation(label) for label in study.ACTIVATIONS}
display(pd.DataFrame({label: result.metrics for label, result in results.items()}).T.round(2))
display(plot_activation(results["shed 0.3 kW, 2 h at 17h"], "base house: shed 0.3 kW for 2 h at 17h"))
display(plot_activation(results["absorb 0.5 kW, 2 h at 13h"], "base house: absorb 0.5 kW for 2 h at 13h"))
| delivered_energy_kwh | delivered_power_kw | delivery_ratio | rebound_energy_kwh | rebound_peak_kw | rebound_duration_h | rebound_ratio | net_energy_change_kwh | cost_change | comfort_change_kh | |
|---|---|---|---|---|---|---|---|---|---|---|
| shed 0.3 kW, 2 h at 17h | 0.50 | 0.25 | 0.83 | 0.06 | 0.06 | 1.0 | 0.12 | 0.20 | 0.03 | 0.02 |
| shed 0.3 kW, 4 h at 17h | 0.69 | 0.17 | 0.57 | 0.01 | 0.01 | 0.0 | 0.02 | 0.38 | 0.06 | 0.01 |
| absorb 0.5 kW, 2 h at 13h | 1.00 | 0.50 | 1.00 | 0.62 | 0.23 | 3.0 | 0.62 | 0.38 | 0.08 | 0.01 |
| absorb 1 kW, 1 h at 13h | 1.00 | 1.00 | 1.00 | 0.63 | 0.23 | 3.0 | 0.63 | 0.37 | 0.07 | -0.00 |
4. Flexibility envelope and flexibility function¶
The envelope answers the aggregator's question before any request: from the current state, how much power can the house shed or absorb for one, two or four hours without leaving the comfort band, and what does that plan cost against the unconstrained one? It is a sweep of constrained MPC plans (bisection on the cap or floor of the electricity bought; absorbing is bounded by the full-load power of the heat pump).
for at in study.ENVELOPE_TIMES:
envelope = study.envelope(at)
display(envelope.round(3))
display(plot_envelope(envelope, f"base house at {at} on the first day, day-ahead prices"))
| down_kw | down_energy_kwh | down_cost | up_kw | up_energy_kwh | up_cost | |
|---|---|---|---|---|---|---|
| duration_h | ||||||
| 1.0 | 0.018 | 0.018 | -0.001 | 1.340 | 1.340 | 0.094 |
| 2.0 | 0.000 | 0.000 | 0.000 | 1.313 | 2.626 | 0.214 |
| 4.0 | 0.000 | 0.000 | 0.000 | 1.313 | 5.253 | 0.685 |
| down_kw | down_energy_kwh | down_cost | up_kw | up_energy_kwh | up_cost | |
|---|---|---|---|---|---|---|
| duration_h | ||||||
| 1.0 | 0.094 | 0.094 | 0.000 | 1.470 | 1.470 | 0.257 |
| 2.0 | 0.006 | 0.011 | -0.002 | 1.434 | 2.869 | 0.525 |
| 4.0 | 0.000 | 0.000 | 0.000 | 1.434 | 5.737 | 0.892 |
The flexibility function of Junker et al. characterises the response to a penalty (price) step rather than to a power request: here the price is multiplied by three for three hours from 17h, and the change of the electricity bought reads like an activation.
response = study.activation("price step")
display(pd.Series(response.metrics).round(2).to_frame("price x3 for 3 h at 17h"))
display(plot_activation(response, "flexibility function: price x3 for 3 h at 17h"))
| price x3 for 3 h at 17h | |
|---|---|
| delivered_energy_kwh | 0.74 |
| delivered_power_kw | 0.25 |
| delivery_ratio | 1.00 |
| rebound_energy_kwh | 0.04 |
| rebound_peak_kw | 0.04 |
| rebound_duration_h | 1.00 |
| rebound_ratio | 0.06 |
| net_energy_change_kwh | 0.41 |
| cost_change | 0.06 |
| comfort_change_kh | -0.02 |
5. Negative prices and PV¶
With 20 m2 of PV the house exports at midday; a negative wholesale price (a renewable surplus) turns the midday import into something the controller wants: it charges the buffer and the walls, and the export is paid at the (negative) export price. Self-consumption is the share of the generation used on site.
pv = {label: study.cached(**parts) for label, parts in study.pv_runs().items()}
display(
kpi_table(
pv,
("total_cost", "energy_kwh", "sold_kwh", "generation_kwh", "self_consumption", "comfort_violation_kh"),
)
)
display(plot_closed_loop(pv["pv | day-ahead + negative midday"], "PV house: negative midday prices"))
| total_cost | energy_kwh | sold_kwh | generation_kwh | self_consumption | comfort_violation_kh | |
|---|---|---|---|---|---|---|
| pv | day-ahead | 1.53 | 6.3 | 0.01 | 9.28 | 1.0 | 1.59 |
| pv | day-ahead + negative midday | 1.53 | 6.3 | 0.01 | 9.28 | 1.0 | 1.59 |
Key takeaways¶
- Implicit flexibility is worth little with a flat winter price: the price-aware controller shifts 0.8 kWh
(base house) to 3.2 kWh (old house) into the cheap hours over two days and saves 0.45 % of the bill. The
synthetic day-ahead signal has a small spread in January; the saving scales with the spread, not with the
building (
tutorials/scripts/fetch_prices.pybrings a real year in). The 15-minute plan costs twenty times the solve for a 3 % different answer: the hour is the right step for a heavy house. - A capacity tariff is the strong signal: at 150 EUR/kW/year without a floor the base house cuts its monthly peak by 81 % (1.77 to 0.34 kW) and the old house by 78 %, trading a little comfort (1.6 against 1.0 K.h); with a 2.5 kW floor the base house has nothing to gain (its peak is below the floor) and pays the floor.
- Explicit activations are delivered when the plan has room: a 1 kW absorption at 13h is delivered in full with a 0.6 kWh rebound over the next three hours; a 0.3 kW shed at 17h is delivered at 83 % over two hours and 57 % over four, because the evening plan is already lean. The envelope says the same: at 13h and 19h the house can absorb 1.3 to 1.5 kW for hours, it can shed almost nothing.
- PV without a battery does not react to negative prices: with 9.3 kWh of January PV consumed on site and nothing to charge, the controller's day costs the same (1.53 EUR) whether midday prices go negative or not; tutorial 05 adds the storages that make them count.
Further reading¶
tutorials/references.md: Annex 67 and Annex 82 for the definitions, Reynders, Diriken and Saelens (2017) and
Stinner et al. (2016) for the characterisation, Junker et al. (2018) for the flexibility function, the Vlaamse
Nutsregulator sheets for the capacity tariff.