04 - An office in summer: cooling, occupancy and the price of comfort¶
Learning objectives
- Model cooling with Trano's FastModel: the ideal emitter of a zone is a heat flow the controller decides; letting it go negative turns it into a chiller, costed at an EER.
- Read a cooling plan: the sun and the occupants drive the load, the mass of the building and the price signal decide when the chiller runs (pre-cooling at night under day-ahead prices).
- Weigh comfort against cost: the office without cooling (the controllers of the YAML in summer) against the MPC, and the peak of the chiller under a capacity charge.
The closed loops come from tutorials/cache/office/ (uv run python tutorials/scripts/build_cache.py office);
the plans are solved live.
Runtimes (hourly steps, this container): Trano and rumoca about 15 s; one 24-hour plan of this two-zone office one to two minutes (52 states, two heat flows); one closed-loop day about an hour.
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 ModelPredictiveController, MPCSettings
from mpctrano.notebook import kpi_table, quick_mode, timed
from mpctrano.plot import plot_closed_loop, plot_plan
from tutorials import _office
pd.set_option("display.width", 200)
print(f"quick mode: {quick_mode()}; cached runs: {_office.DAYS:g} day from {_office.START.date()}")
quick mode: False; cached runs: 1 day from 2023-08-02
1. The office and its emitters¶
tutorials/buildings/office/base.yaml: an open-plan office of 200 m2 with 36 m2 of south glazing and a meeting
area of 80 m2, externally insulated walls, a flat roof, occupied from 8h to 18h (12 W/m2 of occupants and
equipment in the open plan). Each zone has an ideal emitter (radiator of variant ideal in the YAML): in the
FastModelMPC model it is an input Q_flow [W], which Trano bounds to heating (min=0). The study lets it go
negative (FastModel.with_control_bounds): the same equations cool the zone. The electricity follows from a
COP of 3.5 for heating and an EER of 3 for cooling (MPCSettings.heat_cop, cooling_eer).
office = _office.building()
model = office.model
print("controls", model.control_names, "bounds", model.control_bounds)
print("zones", model.zone_names, "| states", model.n_states, "| meters", model.meters or "none: the emitters are ideal")
x0 = office.initial_state()
print("spun-up air [degC]", {zone: round(x0[model.state_names.index(state)] - 273.15, 1)
for zone, state in zip(model.zone_names, model.comfort_states, strict=True)}) # fmt: skip
controls ['emitter_001_Q.Q_flow', 'emitter_002_Q.Q_flow'] bounds (array([-12000., -5000.]), array([12000., 5000.])) zones ['openplan_001', 'meeting_001'] | states 52 | meters none: the emitters are ideal
spun-up air [degC] {'openplan_001': np.float64(25.8), 'meeting_001': np.float64(24.0)}
2. A summer day: flat price against day-ahead prices¶
The comfort band is 21 to 25 degC when occupied, free floating between 15 and 30 degC otherwise. With a flat price the controller cools as late as possible; with day-ahead prices it may pre-cool the mass at night, when electricity is cheap, if the saving beats the extra losses.
tariffs = _office.tariffs()
horizon = 6 if quick_mode() else 24
plans = {}
for price in _office.PRICES:
controller = ModelPredictiveController(
model, MPCSettings(horizon=horizon, **_office.EFFICIENCIES), _office.BAND, tariffs[price]
)
with timed(f"plan under {price} prices"):
plans[price] = controller.solve(x0, office.forecaster.forecast(_office.START, horizon, 3600.0))
display(plot_plan(plans[price], model, f"office, {price} prices"))
summary = {
price: {
"cost EUR": plan.economic_cost,
"cooling kWh": -float(plan.controls.clip(upper=0).sum().sum()) / 1000,
"heating kWh": float(plan.controls.clip(lower=0).sum().sum()) / 1000,
"electricity kWh": float(plan.meter["P_meter"].sum()) / 1000,
"peak kW": float(plan.meter["P_meter"].max()) / 1000,
}
for price, plan in plans.items()
}
display(pd.DataFrame(summary).round(2))
plan under flat prices: 137.5 s
plan under day-ahead prices: 277.9 s
| flat | day-ahead | |
|---|---|---|
| cost EUR | 3.15 | 3.17 |
| cooling kWh | 37.22 | 37.23 |
| heating kWh | 0.00 | 0.00 |
| electricity kWh | 12.42 | 12.42 |
| peak kW | 1.80 | 1.80 |
3. The closed loop: the MPC against no cooling¶
The true office (perturbed envelope, noisy sensors) over one working day with moving horizon estimation, against the office without cooling: the closed-loop model of the YAML has the thermostats of the heating only, so in summer it free floats. Its "cost" is the comfort it loses.
loops = {}
for price in _office.PRICES:
loops[f"MPC | {price}"] = _office.cached(price=price, days=_office.DAYS)
loops["no cooling"] = _office.reference("flat")
display(kpi_table(loops, ("total_cost", "energy_kwh", "comfort_violation_kh", "max_violation", "peak_power_kw")))
display(plot_closed_loop(loops["MPC | day-ahead"], "office, day-ahead prices, true building"))
| total_cost | energy_kwh | comfort_violation_kh | max_violation | peak_power_kw | |
|---|---|---|---|---|---|
| MPC | flat | 3.17 | 12.47 | 0.74 | 0.16 | 1.82 |
| MPC | day-ahead | 3.18 | 12.47 | 0.74 | 0.16 | 1.82 |
| no cooling | 0.00 | 0.00 | 51.76 | 5.37 | 0.00 |
4. The peak of the chiller under a capacity charge¶
A capacity charge on the peak of the day (here 8 EUR/kW, the monthly charge of the Flemish capacity tariff at about 100 EUR/kW/year) makes the controller spread the cooling: lower peak, more kWh.
capped = {}
for charge in (0.0, 8.0):
run = _office.study_scenario("flat", capacity_charge=charge)
controller = ModelPredictiveController(
model, MPCSettings(horizon=horizon, **_office.EFFICIENCIES), run.comfort, run.tariff
)
with timed(f"plan with a capacity charge of {charge:g} EUR/kW"):
capped[f"{charge:g} EUR/kW"] = controller.solve(x0, office.forecaster.forecast(_office.START, horizon, 3600.0))
display(
pd.DataFrame(
{
label: {
"energy cost EUR": plan.costs["electricity"],
"capacity cost EUR": plan.costs["capacity"],
"electricity kWh": float(plan.meter["P_meter"].sum()) / 1000,
"peak kW": float(plan.meter["P_meter"].max()) / 1000,
}
for label, plan in capped.items()
}
).round(2)
)
plan with a capacity charge of 0 EUR/kW: 82.3 s
plan with a capacity charge of 8 EUR/kW: 314.2 s
| 0 EUR/kW | 8 EUR/kW | |
|---|---|---|
| energy cost EUR | 3.15 | 5.51 |
| capacity cost EUR | 0.00 | 11.18 |
| electricity kWh | 12.42 | 21.69 |
| peak kW | 1.80 | 1.40 |
Key takeaways¶
- Cooling is the same optimisation with the sign flipped: the ideal emitters of the YAML, their lower bound made negative, cool the two zones at a fixed EER; on the hottest day of the year the office needs 37 kWh of cooling, 12.4 kWh of electricity and a 1.8 kW peak, and keeps the band to 0.7 K.h where the office without cooling exceeds it by 52 K.h and 5.4 K at worst.
- Day-ahead prices move nothing here: the plan under day-ahead prices is the plan under a flat price (3.17 against 3.15 EUR). Summer prices are flat where the cooling load is, the night is already cool and cooling the mass in advance costs the same electricity: there is no price spread to arbitrage with a constant EER.
- A capacity charge is what moves the chiller: at 8 EUR/kW of monthly peak the controller pre-cools the slab
and the walls through the night, shaves the peak from 1.80 to 1.40 kW and pays for it with 75 % more
electricity (21.7 against 12.4 kWh); the bill still falls, by less than a euro over the day, because the
capacity charge is the larger term. Whether the pre-cooling is worth it depends on that charge: see
tutorials/references.mdon capacity tariffs.
Further reading¶
tutorials/references.md: the characterisation of flexibility (Reynders et al.) applies to cooling as to heating; Blum et al. (2021) for the KPIs.- The chiller is a constant EER here; a chiller and a cooling coil in the FastModel library of Trano would give
the part-load behaviour (see
tutorials/PLAN.md).