Function add_tank_optimization() optimizes a
heat pump that charges one thermal tank (e.g. a
domestic hot water or a buffer tank), which the heat demand of the
building discharges. It solves one independent optimization problem per
optimization window (typically one day), with the same
objectives, optimization windows and solvers as
add_battery_optimization():
- Minimize the power exchanged with the grid (net power), with
opt_objective = "grid"(QP, OSQP) - Minimize the energy cost, with
opt_objective = "cost"(LP, HiGHS, or QP whenlambda > 0) - Minimize a weighted combination of net power and cost, with a
numeric
opt_objectivebetween0and1(QP)
The tank plays the role of the battery: it stores heat, so the compressor can run when electricity is cheap or locally produced, and the tank delivers the heat later. On top of the compressor, the heat pump can have a resistive backup heater, and the comfort floor of the tank is soft: when neither the compressor nor the backup heater can keep the tank above it, the tank keeps giving heat below its comfort temperature, down to the temperature of the inlet water, and the optimization reports that heat as unmet heat instead of failing. In this article, we’ll cover the tank model, the backup heater and the soft comfort floor, followed by a worked example.
Tank model
The decision variables of every optimization window, with time slots of hours, are:
- : Electric power of the compressor (kW)
-
: Power of the backup heater (kW). It is resistive, so its electric
power is also its heat
().
Only a variable when the heat pump has a backup heater
(
Pb > 0) - : Level of the tank at the end of slot (kWh thermal): the heat it holds above the inlet temperature, the temperature of the cold water that refills it. At the tank is at the inlet temperature and gives no more heat
And the parameters:
-
: Heat demand (kW thermal), the column
heat_demandofopt_data -
: Coefficient of performance of the compressor, the column
copofopt_data. means the compressor cannot serve the tank in that slot (e.g. while it defrosts or serves another circuit) -
: Tank capacity (kWh thermal),
Tcap: the heat between the inlet temperature and the top of the tank -
: Maximum electric power of the compressor,
Pc -
: Rating of the backup heater,
Pb(NULLor0without one) - : Comfort floor of the tank (%), the lowest level that still delivers heat at the comfort temperature
- : Top of the tank (%)
-
: Standing loss of the tank (% per hour),
loss - : Level before the first slot of the window
The level of the tank follows the heat balance:
with the following constraints:
- The level stays between the comfort floor and the top of the tank. The floor is soft (see Soft comfort floor): the level can go below it, down to the inlet temperature () at most:
- The compressor runs between 0 and its maximum power, and not at all where the COP is 0:
- The backup heater runs between 0 and its rating, also where the COP is 0:
- Every window ends at least as full as it started, so that a window does not empty the tank at the expense of the next one:
- The heat pump’s electric load is part of the net power of the site, , which must stay within the grid import and export capacities, as in the net power article, unless they cannot be met (see Soft grid capacities).
Litres and temperatures
The model counts the heat in the tank in kWh, but a tank is usually described by its volume and its temperatures. Assuming a mixed tank (one temperature for all its water), the heat it holds above the inlet temperature is:
So a tank of volume
(L) that can be heated up to
,
with a comfort floor at
,
translates into the arguments of add_tank_optimization()
as:
and the level back into the temperature . For example, 300 L between an inlet temperature of 20 °C and a maximum of 55 °C hold kWh, and a comfort floor at 30 °C is .
Optimization windows
The level carries from one window to the next, so the windows are
solved in order. They are the windows of the other optimization
functions, one every window_days days from
window_start_hour, plus the partial
windows that the run cuts at both ends:
- When the first time slot of the run is not a window start (e.g. a
run from 00:00 with
window_start_hour = 6), the slots up to the first window start form a leading window. It starts fromSOCini, and it must also end at least as full as it started. - The slots after the last full window form a trailing window.
Both are optimized like any other window. Only the first
flex_window_hours of every window are optimized, and the
slots after them run the static schedule of the heat pump, the column
flexible of opt_data. So with
flex_window_hours = 24 (and window_days = 1)
every time slot is optimized.
The slots that run flexible behave like the tank too: at
the top, the compressor stops as a thermostat would (its power is the
static schedule reduced to what keeps the tank at
),
and below the comfort floor the tank cools down to the inlet temperature
at most, with unmet heat as in the optimized slots.
Objective
The objective is the one of the battery, with the heat pump’s electric load in place of the battery’s power, plus a linear term on the backup heater. For the net power objective:
and for the energy cost objective, with the imported power and exported power of the cost article and :
The weighted objective combines both as described in the combined optimization article, with the same linear term. When the comfort floor or the grid capacities cannot be held, the window gets the further terms described below.
Compressor first, backup heater second, comfort last
The linear terms order the ways to cover the heat demand. They are built from the heat price of the window: the highest value that one kW of electric load can take in the window’s objective, divided by the lowest positive COP when it is below 1, so that no kWh of heat from the compressor is worth more than :
| Objective | Heat price |
|---|---|
"cost" |
, the highest electricity price of the window |
"grid" |
, the largest derivative of the squared net power |
weight w
|
w times the grid value (scaled as the net power term)
plus 1 - w times the cost value |
and with lambda > 0 the largest derivative of the
ramping penalty,
,
is added to it. Then:
- The backup heater pays per kW of heat on top of its electricity, which the objective already sees like the compressor’s. Its heat is therefore always more expensive than the compressor’s, so it only runs for the heat the compressor cannot deliver, either because the compressor is at , or because its COP is 0. It never runs to absorb a PV surplus or a cheap hour that the compressor could absorb.
- Missing a grid capacity costs per kW (see Soft grid capacities).
- Going below the comfort floor costs per kWh below it and time slot, more than any kWh of heat the compressor, the backup heater or a missed grid capacity can deliver. So the tank only cools below its floor when they cannot hold it, and is brought back above it as soon as they can.
Soft comfort floor
Without a soft floor, a window where the compressor and the backup heater cannot keep the tank above (a cold spell, a compressor too small for the peak, a COP of 0 during a long defrost) would have no solution at all. A real tank, in that case, keeps giving heat: its water gets colder than the comfort temperature, down to the temperature of the inlet water, and from there it gives no more. The optimization does the same. When a window has no solution that holds the floor, it is solved again with:
- the level between the inlet temperature and the top, ;
- the depth below the floor, , priced at per kWh and slot;
- the end condition missed by at the same price, so that a tank that has been drained does not have to be refilled by the end of the window at any cost.
Every window has a solution then. The unmet heat is the heat that would have been required to keep the tank at least at its comfort floor: the extra heat that, added in slot , would have stopped the temperature from falling below the comfort temperature (or further below it). It is a missing energy, not a measure of discomfort: it is positive while the temperature is falling below the floor, and 0 again as soon as it stops falling, even if the tank is still below the floor while it warms back up. Its total is the extra heat a larger heat pump or backup heater would have had to deliver. With the level the balance asks for (and the level the tank reaches):
The first term covers the heat the tank gives from below its floor, and the part of below 0 the demand it cannot serve at all once it is at the inlet temperature. Standing losses below the floor are not counted.
So in terms of temperature: the backup heater runs when, with the compressor at full power, the temperature of the tank would drop below the comfort floor; and heat is unmet when, even with the backup heater, the temperature drops below the comfort floor, by as much heat as it would have taken to keep it there. The temperature keeps falling with the demand, to the inlet temperature at most. The unmet heat is a report: it is not part of the heat balance of the tank.
The soft floor only enters the problem of a window when the window has no solution that holds the floor. So, without a backup heater and with a band that the compressor can hold, the problem is exactly the one without it.
Soft grid capacities
As for the battery, the grid import and export capacities are soft. When a window has no solution that holds the band within them (e.g. a PV surplus above the export capacity that a full tank cannot absorb, or an import capacity too low to cover the heat demand), it is solved with the soft comfort floor and with a penalised slack on each capacity, and :
The slack is bounded by how far the site already misses the capacity without the optimization: for import, with the heat pump running its static schedule ; for export, with the heat pump off, since a full tank cannot take any more heat:
It costs per kW in the objective, between the backup heater and going below the comfort floor. So the solver uses the compressor and the backup heater to their limits before missing a capacity, and misses an import capacity before letting the tank cool below its floor. The heat pump switched off, with the tank cooling, always fits these bounds, so every window has a solution. When a capacity is still missed, a message says so, and the net power shows where.
Results
add_tank_optimization() returns the compressor’s
electric power
(kW), one value per row of opt_data, rounded to 2 decimals.
Its attributes hold the rest of the solution, one value per row as
well:
| Attribute | Content |
|---|---|
backup_kw |
Power of the backup heater (kW electric, which is also kW thermal). 0 without a backup heater |
unmet_heat_kw |
Unmet heat (kW thermal): the heat that would have been required in each slot to keep the tank at least at its comfort floor. Positive while the temperature falls below the floor, 0 while it warms back up |
The electric load of the heat pump is the returned power plus
attr(P, "backup_kw"). The level of the tank is not
returned, but get_tank_level() computes it from the power
and the backup heater, stopping at the inlet temperature.
get_tank_unmet_heat() computes the unmet heat of any
schedule, with the same definition: for example, of the static schedule
flexible, to compare it with the optimized one.
A window the solver fails on (a numerical failure, since every window
has a solution) runs the static schedule, with a message. With
on_failure = "error", it stops with an error naming the
window and the solver status instead.
Example
Package flextools provides an example data set of
time-series energy and prices profiles, energy_profiles. We
will use three days of January 2023 with 15-minute resolution, the
building consumption as static load and the solar PV production. The
import prices of this data set are sometimes negative, so we cap the
export price at the import price (a feed-in tariff that never pays more
than importing costs).
The data set has no heat demand, so we build one with a morning and an evening peak, around a base of 2 kW. The second day is a cold morning: its morning peak reaches 12 kW, far above what the compressor can deliver. The COP of the compressor is 3.2, and drops to 2.7 on the cold day:
opt_data <- energy_profiles %>%
filter(
datetime >= dmy("09/01/2023", tz = "CET"),
datetime < dmy("12/01/2023", tz = "CET")
) %>%
mutate(
day = as.integer(date(datetime) - dmy("09/01/2023")) + 1,
hour = hour(datetime) + minute(datetime) / 60
) %>%
transmute(
datetime,
production = solar,
static = building,
price_imported,
price_exported = pmin(price_exported, price_imported),
heat_demand = 2 +
ifelse(
day == 2,
10 * exp(-((hour - 8) / 2)^2), # cold morning
2.5 * exp(-((hour - 7.5) / 1.2)^2)
) +
3 * exp(-((hour - 19) / 1.5)^2),
cop = ifelse(day == 2, 2.7, 3.2),
flexible = heat_demand / cop
)
head(opt_data)## # A tibble: 6 × 8
## datetime production static price_imported price_exported
## <dttm> <dbl> <dbl> <dbl> <dbl>
## 1 2023-01-09 00:00:00 0 1.65 0.0644 0.025
## 2 2023-01-09 00:15:00 0 1.64 0.0644 0.025
## 3 2023-01-09 00:30:00 0 1.63 0.0644 0.025
## 4 2023-01-09 00:45:00 0 1.62 0.0644 0.025
## 5 2023-01-09 01:00:00 0 1.61 0.0603 0.025
## 6 2023-01-09 01:15:00 0 1.57 0.0603 0.025
## # ℹ 3 more variables: heat_demand <dbl>, cop <dbl>, flexible <dbl>
The static schedule flexible is the heat pump following
the heat demand without any storage:
The heat pump has a 2 kW compressor and a 300 L tank that can be heated up to 55 °C, with a comfort floor at 30 °C, refilled with inlet water at 20 °C. We convert them into the tank’s capacity and comfort floor as described above:
volume <- 300 # L
T_in <- 20 # °C, inlet water
T_floor <- 30 # °C, comfort floor
T_max <- 55 # °C
kwh_per_degree <- volume * 4.186 / 3600 # kWh per °C
Tcap <- kwh_per_degree * (T_max - T_in)
SOCmin <- 100 * (T_floor - T_in) / (T_max - T_in)
c(Tcap = Tcap, SOCmin = SOCmin)## Tcap SOCmin
## 12.20917 28.57143
The tank always holds its 300 L, since the inlet water refills whatever is drawn: it is its temperature that tells how much heat it holds. From 55 °C down to the 30 °C floor it can give 8.7 kWh at a comfortable temperature, and 3.5 kWh more, colder, down to the 20 °C of the inlet water. On the cold morning the compressor delivers at most kW of heat: not enough for the peak.
To show the results, a small helper gathers the compressor power, the
backup heater, the unmet heat and the temperature of the tank (from the
level that get_tank_level() computes) for each
scenario:
tank_results <- function(opt_data, P) {
opt_data %>%
mutate(
P = P,
B = attr(P, "backup_kw"),
U = attr(P, "unmet_heat_kw"),
level = get_tank_level(
P, heat_demand, cop,
init = Tcap, time_resolution = 15,
backup = B
),
temperature = T_in + level / kwh_per_degree
)
}
daily_totals <- function(results) {
results %>%
group_by(date = date(datetime)) %>%
summarise(
demand_kWh = sum(heat_demand) / 4,
compressor_kWh = sum(P) / 4,
backup_kWh = sum(B) / 4,
unmet_kWh = sum(U) / 4,
min_temp_C = min(temperature)
)
}Without backup heater
First, the heat pump has no backup heater and minimizes the energy cost:
P <- add_tank_optimization(
opt_data,
opt_objective = "cost",
Tcap = Tcap,
Pc = 2,
SOCmin = SOCmin
)## ⚠️ Optimization warning: the tank goes below its comfort floor in some slots. That heat is unmet (attribute `unmet_heat_kw`).
results <- tank_results(opt_data, P)
daily_totals(results)## # A tibble: 3 × 6
## date demand_kWh compressor_kWh backup_kWh unmet_kWh min_temp_C
## <date> <dbl> <dbl> <dbl> <dbl> <dbl>
## 1 2023-01-09 61.3 19.2 0 0 30.0
## 2 2023-01-10 91.4 32.3 0 7.59 20
## 3 2023-01-11 61.3 19.2 0 0 30.0
On the first and the last day, the compressor heats the tank up to 55 °C during the cheapest hours before each peak, and the tank covers the peaks without dropping below 30 °C. On the cold morning the tank starts full, and the compressor runs at full power through the peak, but the temperature falls below the comfort floor at around 08:00 and down to the inlet temperature (20 °C) at around 09:00, where the tank gives no more heat. The unmet heat is the heat that would have been needed to keep the tank at 30 °C: the heat the tank gives below 30 °C, and the demand it cannot serve at all at 20 °C. It follows how fast the temperature falls below the floor, not how far below it is: it ends when the temperature stops falling, at the end of the peak, while the compressor is still bringing the tank back above its floor.
With a backup heater
Now the heat pump also has a 1.5 kW backup heater
(Pb = 1.5):
P_backup <- add_tank_optimization(
opt_data,
opt_objective = "cost",
Tcap = Tcap,
Pc = 2,
SOCmin = SOCmin,
Pb = 1.5
)## ⚠️ Optimization warning: the tank goes below its comfort floor in some slots. That heat is unmet (attribute `unmet_heat_kw`).
results_backup <- tank_results(opt_data, P_backup)
daily_totals(results_backup)## # A tibble: 3 × 6
## date demand_kWh compressor_kWh backup_kWh unmet_kWh min_temp_C
## <date> <dbl> <dbl> <dbl> <dbl> <dbl>
## 1 2023-01-09 61.3 19.2 0 0 30.0
## 2 2023-01-10 91.4 31.4 6.76 1.95 24.4
## 3 2023-01-11 61.3 19.2 0 0 30.0
The compressor runs as before, and the backup heater switches on when the compressor at full power can no longer keep the tank above the comfort floor during the cold morning. The optimization looks ahead: it starts the backup heater as soon as the peak starts, rather than when the tank reaches 30 °C, because the heat it adds early is kept in the tank for later. The tank now reaches its comfort floor later, at around 08:45, and only then is heat unmet: the tank cools to about 24 °C, and the unmet heat is the heat that, on top of the compressor and the backup heater, would have kept it at 30 °C. It stops when the temperature reaches its lowest point. Right after the peak, the backup heater keeps running with the compressor until the tank is back above its floor. On the first and the last day the compressor suffices, and the backup heater stays off even though it could run in cheaper hours.
With a larger backup heater (e.g. Pb = 3), the tank
stays above its floor and the unmet heat disappears altogether.
