05 - PV, a battery and an electric vehicle next to the thermal storage¶
Learning objectives
- Put electrical storages next to the thermal one: a stationary battery and a vehicle charged at home are optimised in the balance of the meter, with the heat pump, the buffer and the walls of the house.
- Read the dispatch order under day-ahead prices and under negative midday prices: which storage takes the cheap or free energy first, what is exported, how much of the PV is consumed on site.
- See the vehicle's deadline: it leaves at 7h with 30 kWh and comes back at 18h 12 kWh lower; the controller charges it when the night is cheap, not when it arrives.
The storages are linear state-of-charge models in the controller (Battery, ElectricVehicle,
MPCSettings.batteries and .vehicles): Trano's FastModel describes the building and its thermal systems,
mpctrano the electrical assets at the meter. The closed loops come from tutorials/cache/pv/
(uv run python tutorials/scripts/build_cache.py pv); the plans are solved live.
Runtimes (hourly steps, this container): one 24-hour plan of the PV house 10 to 50 s; one closed-loop day with the storages 10 to 30 minutes.
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
from mpctrano.notebook import kpi_table, quick_mode, timed
from mpctrano.plot import plot_closed_loop, plot_plan, plot_storage
from tutorials import _pv
pd.set_option("display.width", 200)
print(f"quick mode: {quick_mode()}; cached runs: {_pv.DAYS:g} day from {_pv.START.date()}")
quick mode: False; cached runs: 1 day from 2023-01-16
1. The house with PV, and the storages¶
tutorials/buildings/house/pv.yaml is the house of tutorial 01 with 20 m2 of PV on the south roof (about 4 kWp):
Trano meters its production (pv_001.EPro), which the controller nets against the heat pump at the meter. The
battery (8 kWh, 3 kW, 92 % round trip) and the vehicle (50 kWh, 7.4 kW charger, home from 18h to 7h, 12 kWh a
day, 30 kWh required at departure) are declared in the controller settings.
house = _pv.building("pv")
model = house.model
print("meters", model.meters)
print("battery", _pv.BATTERY.model_dump())
print("vehicle", _pv.VEHICLE.model_dump())
x0 = house.initial_state()
tariffs = _pv.tariffs()
horizon = 6 if quick_mode() else 24
meters {'boiler_001.EEle': 'electricity', 'pv_001.EPro': 'electricity_production'}
battery {'name': 'battery', 'capacity_kwh': 8.0, 'max_charge_w': 3000.0, 'max_discharge_w': 3000.0, 'efficiency': 0.92, 'soc_min': 0.1, 'soc_max': 1.0, 'initial_soc': 0.5, 'driven_kwh': 0.0, 'departure_energy_kwh': 0.0}
vehicle {'name': 'vehicle', 'capacity_kwh': 50.0, 'max_charge_w': 7400.0, 'max_discharge_w': 1.0, 'efficiency': 0.92, 'soc_min': 0.1, 'soc_max': 1.0, 'initial_soc': 0.5, 'driven_kwh': 12.0, 'departure_energy_kwh': 30.0, 'arrival_hour': 18.0, 'departure_hour': 7.0, 'missing_energy_price': 2.0, 'weekdays_only': False}
2. One plan: the dispatch under day-ahead prices¶
The plan below runs the house, the battery and the vehicle together over a January day. Look at where the battery charges (the cheap night, the midday PV) and discharges (the evening peak), and at the vehicle: charged in the cheap hours before 7h, idle while away, charged again after 18h only if the night is not cheaper.
plans = {}
for price in _pv.PRICES:
settings = MPCSettings(horizon=horizon, **_pv.STORAGES["battery + vehicle"])
controller = ModelPredictiveController(model, settings, ComfortSpec(), tariffs[price])
with timed(f"plan under {price}"):
plans[price] = controller.solve(x0, house.forecaster.forecast(_pv.START, horizon, 3600.0))
display(plot_plan(plans["day-ahead"], model, "PV house with a battery and a vehicle, day-ahead prices"))
display(plot_storage(plans["day-ahead"], "storages, day-ahead prices"))
plan under day-ahead: 59.7 s
plan under day-ahead + negative midday: 58.7 s
3. Negative prices at midday¶
A negative wholesale price (a renewable surplus) makes importing worth money: the controller charges everything that can take it, the battery, the vehicle if it is home (it is not, at midday on a weekday), the buffer and the walls, and stops exporting the PV. Compare the two plans on the storages:
display(plot_storage(plans["day-ahead + negative midday"], "storages, negative midday prices"))
summary = {
price: {
"cost EUR": plan.economic_cost,
"bought kWh": float(plan.meter["P_meter"].sum()) / 1000,
"sold kWh": float(plan.meter["P_export"].sum()) / 1000,
"battery charged kWh": float(plan.storage["battery_charge_w"].sum(skipna=True)) / 1000,
"vehicle charged kWh": float(plan.storage["vehicle_charge_w"].sum(skipna=True)) / 1000,
}
for price, plan in plans.items()
}
display(pd.DataFrame(summary).round(2))
| day-ahead | day-ahead + negative midday | |
|---|---|---|
| cost EUR | 1.65 | 1.44 |
| bought kWh | 8.93 | 9.58 |
| sold kWh | 0.01 | 0.01 |
| battery charged kWh | 6.65 | 7.51 |
| vehicle charged kWh | 5.21 | 5.21 |
4. The closed loop: with and without the storages¶
One day against the true building (perturbed envelope, noisy sensors) with moving horizon estimation, for the house alone, with the battery, and with the battery and the vehicle, under the two price signals; the controllers of the YAML (no storage, the PV exports what the house does not use) as the reference. The vehicle's driving energy is bought in every run that has it: compare the cost per kWh rather than the total.
loops = {label: _pv.cached(**parts) for label, parts in _pv.runs().items()}
for price in _pv.PRICES:
loops[f"pv | {price} | rule based"] = _pv.reference(price)
display(
kpi_table(
loops,
(
"total_cost",
"energy_kwh",
"sold_kwh",
"generation_kwh",
"self_consumption",
"comfort_violation_kh",
"peak_power_kw",
),
)
)
display(
plot_closed_loop(
loops["pv | day-ahead + negative midday | battery + vehicle"], "negative midday prices, battery and vehicle"
)
)
| total_cost | energy_kwh | sold_kwh | generation_kwh | self_consumption | comfort_violation_kh | peak_power_kw | |
|---|---|---|---|---|---|---|---|
| pv | day-ahead | none | 0.70 | 2.90 | 0.01 | 5.07 | 1.00 | 0.54 | 0.94 |
| pv | day-ahead | battery | 0.66 | 3.55 | 0.00 | 5.07 | 1.00 | 0.50 | 3.16 |
| pv | day-ahead | battery + vehicle | 1.62 | 8.76 | 0.00 | 5.07 | 1.00 | 0.50 | 8.38 |
| pv | day-ahead + negative midday | none | 0.70 | 2.90 | 0.00 | 5.07 | 1.00 | 0.54 | 0.94 |
| pv | day-ahead + negative midday | battery | 0.50 | 4.28 | 0.00 | 5.07 | 1.00 | 0.46 | 1.21 |
| pv | day-ahead + negative midday | battery + vehicle | 1.45 | 9.49 | 0.00 | 5.07 | 1.00 | 0.46 | 4.94 |
| pv | day-ahead | rule based | 0.88 | 3.68 | 2.72 | 5.07 | 0.46 | 24.44 | 0.37 |
| pv | day-ahead + negative midday | rule based | 1.00 | 3.68 | 2.72 | 5.07 | 0.46 | 24.44 | 0.37 |
Key takeaways¶
- The storages sit in one balance: the heat pump, the buffer and the walls (through the FastModel), the battery and the vehicle (exact linear models in the controller) share the meter; the controller dispatches them together against the same price. In January the 20 m2 of PV give 5 kWh a day, and the MPC consumes all of it on site (self-consumption 1.00) where the controllers of the YAML export more than half (2.7 kWh sold, 0.46).
- The battery earns little under day-ahead prices and more under negative ones: 0.70 to 0.66 EUR a day with the winter spread of the day-ahead signal; 0.70 to 0.50 EUR when the midday prices go negative, because the battery is paid to charge (7.5 kWh charged, 4.3 kWh bought for a house that needs 2.9).
- The vehicle is a deadline, not a storage: the 12 kWh it needs by 7h are charged in the cheapest night hours (5.2 kWh over the day from a half-full battery); its 7.4 kW charger sets the peak of the house (8.4 kW under day-ahead prices, 4.9 kW when the negative midday hours spread the charging) - the capacity tariff of tutorial 02 is what would tame it.
Further reading¶
tutorials/references.md: Annex 67 for the storage order and the self-consumption indicators.- The storages are exact linear models here; a battery and a charger as FastModel components in Trano would
put them in the Modelica model with their losses (see
tutorials/PLAN.md).