Skip to content

Model predictive control, tariffs and flexibility

Optimisation problem

At every step, from the estimated state x0, the time t0 and the forecasts over N steps of dt seconds, ModelPredictiveController solves with IPOPT:

min   Σ_k (π_k b_k − π^in_k (b_k − e_k) + π^fuel_k f_k)    energy cost [EUR], prices in EUR/kWh
    + c_cap · P_peak / 1000                                 capacity charge [EUR/kW] on the peak electric power
    + Σ_k (w₁ (s⁻ + s⁺) + w₂ (s⁻² + s⁺²)) · dt / 3600       comfort violations [K·h]
    + w_lim · Σ_k s_lim,k · dt / 3.6e6                      electricity above a demand response power limit
    + w_move · Σ_k |v_k − v_{k−1}|² + w_effort · Σ_k |v_k|²   moves and effort of the controls (scaled to [0, 1])
s.t.  the FastModel collocated on Radau IIA points in each step
      the bounds of each control (0-1 for the valves, the heat pump speed and the boiler load)
      T_lower,k − s⁻_k ≤ T_zone,k+1 ≤ T_upper,k + s⁺_k        soft comfort band on every zone
      P_k ≤ limit_k + s_lim,k,     P_k ≤ P_peak                electric power bought from the grid

e_k is the net electricity of step k [kWh], read from the meters of the model (the electricity of the heat pumps and fans, the heat of the ideal emitters, minus the PV production), b_k its smooth positive part (bought, the rest is sold at the feed-in price), f_k the fuel the boilers drew and P_k = b_k / dt the electric power. Since the energy comes from the meters, the efficiency of the systems is part of the plan: the COP of a heat pump is better in the mild afternoon than in the cold night, the efficiency of a boiler depends on its load and its water temperature.

MPCSettings holds the horizon, the time step, the penalties, fuel_price and feed_in_price (the tariff price when not given) and the IPOPT options. The problem is built once (casadi.Opti with the time, the forecasts, the prices and the bounds as parameters) and every solve() updates them and warm starts IPOPT from a CVODES simulation of the previous plan shifted by one step, with a small initial barrier parameter (warm_mu_init), which halves the iterations of the receding-horizon solves. If IPOPT does not converge the last iterate is returned with success=False (the closed loop keeps running and counts the failures), and the next solve starts cold.

Comfort bounds apply at the end of each step (the occupied band starting at 7h constrains the temperature reached at 7h); prices apply to the energy of the step. controller.energy gives the electricity bought and net, the fuel and the electric power of each step of the last plan.

On the heat pump house of the tests (27 states), a 24-hour plan with hourly steps solves in 5 to 20 s; the six-zone heat pump house of the Trano test suite (111 states) takes over a minute.

Tariffs

Tariff Description
FlatTariff one price
TimeOfUseTariff peak / off-peak hours, weekends off-peak unless weekend_peak
DynamicTariff a price series (from_csv, synthetic_day_ahead), held between samples
DemandResponseTariff any tariff + DemandResponseEvents: price multiplier and/or power cap

The tariffs price the electricity; the fuel has its own price (MPCSettings.fuel_price). Every tariff can carry a capacity_charge [EUR/kW] on the peak electric power of the horizon. A DemandResponseEvent power cap applies to the electric power bought from the grid; it is a soft constraint with a high penalty (power_limit_penalty): it is respected unless comfort would collapse.

Comfort

ComfortSchedule defines occupied/unoccupied bands in °C with the occupied hours; ComfortSpec assigns a schedule per zone (default otherwise) and map("widen", delta) or map("constant") transforms all of them.

Flexibility

The flexibility of a building is what the thermal mass and the buffers allow between the bounds: with a cheap night the controller pre-heats and lets the temperature float during the expensive hours; with a capacity charge it spreads the morning boost; during a demand response event it sheds load. The comfort band is the lever: wide_comfort_band in the scenario catalogue raises its upper bound by 2 K.

Costs are only comparable under the same tariff. value_of_flexibility(model, scenario) runs the scenario twice, with the price-aware MPC and with a price-agnostic one (flat price, same comfort band and model), costs both runs with the tariff of the scenario and reports the saving, the energy shifted and the share of energy bought at low prices.

The reference of a building is its own control: run_rule_based (or Project.run_rule_based) simulates the controllers of the YAML on the same building. On the boiler house of the tests, over a January day with the band of its thermostat, the MPC costs 12 % less than the controllers of the YAML under a flat and under a time-of-use tariff, with almost no discomfort (0.05 K·h against 3.7 K·h, the warm-up of the thermostat), and buys 30 % of its energy off-peak against 10 %.

The controller can only exploit the storage it believes in: with the uncalibrated Trano parameters against a different real building, the pre-heating is mis-sized and the comfort band is missed (tutorial 5 shows it). Calibrate before trusting the price response.

compare(runs, baseline="flat") puts several runs side by side (same tariff for the costs to be comparable):

  • total_cost, cost_saving_pct - energy plus capacity cost and the saving against the baseline,
  • energy_kwh, energy_change_pct - pre-heating costs some extra energy,
  • shifted_energy_kwh - energy moved in time against the baseline (half of the L1 distance of the hourly profiles),
  • low_price_share, mean_price - share of the energy bought below the median price, average price paid,
  • comfort_violation_kh, max_violation, peak_power_kw.

price_response(run) gives the energy bought at each price level.

Cooling with ideal emitters

The ideal emitter of a zone (radiator of variant ideal in the YAML) is a heat flow input of the FastModelMPC model, which Trano bounds to heating (min=0). FastModel.with_control_bounds gives it a negative lower bound: the same equations cool the zone. The controller costs its heat at MPCSettings.heat_cop and its cooling at MPCSettings.cooling_eer (electricity per unit of heat or cooling); the closed loop meters it alike.

office = FastModel.from_yaml("office.yaml").with_control_bounds({"emitter_001_Q.Q_flow": (-12000.0, None)})
settings = MPCSettings(horizon=24, heat_cop=3.5, cooling_eer=3.0)

Electrical storages at the meter

A stationary battery and an electric vehicle are optimised in the balance of the meter next to the thermal storage of the building (mpctrano.storage): linear state-of-charge models with a round-trip efficiency and power bounds; the vehicle charges while plugged in (arrival_hour to departure_hour), leaves with at least departure_energy_kwh (soft, at missing_energy_price) and comes back driven_kwh lower. The controller carries their state from one solve to the next (ModelPredictiveController.advance, called by the closed loop) and reports the plan in MPCSolution.storage.

settings = MPCSettings(
    horizon=24,
    batteries=(Battery(capacity_kwh=8, max_charge_w=3000, max_discharge_w=3000),),
    vehicles=(ElectricVehicle(capacity_kwh=50, max_charge_w=7400, arrival_hour=18, departure_hour=7, driven_kwh=12),),
)

The energy stored in a battery at the end of the horizon is valued at the mean price of the horizon, so that the controller does not empty it before the end.