Skip to content

A heat pump from a weather-compensated log

bestest_hydronic_heat_pump is BOPTEST's emulator of a single-zone house heated by a modulating heat pump. Here prescribe drives it closed loop, planning from a log that a weather-compensated controller left behind. That controller's outdoor-reset curve raised the modulation on cold days, and cold days are the days the house loses heat fastest, so a model fitted on the log as it stands mixes the heat pump's effect with the weather's.

This case study is not a script in this repository. It runs in causaldyn-bench, for hours, against a live emulator, and it was pre-registered: the module's docstring, committed before any scored episode.

just boptest-prescribe-live    # in causaldyn-bench, with the BOPTEST stack up

At every half-hour step both arms make the same call from the zone temperature just measured, and the heat pump gets the first action of the schedule:

prescribe(
    panel,
    levers=[Lever("modulation", 0, 1, unit_cost=0)],
    target=Target("zone", value=target),
    horizon=16,
    dt=0.5,
    tolerance=0.5,
    x0=[zone],
    adjustment=graph,
)  # the naive arm passes adjustment=()

The graph says what the logging controller and the house do: the reset curve reads the outdoor temperature, its comfort feedback reads the bound, and occupancy, which nothing measures, sets both the bound and the internal gains. prescribe resolves it to {outdoor, solar, bound}. The target is the highest lower comfort bound BOPTEST forecasts over the plan's eight hours, plus an offset swept over seven values from -0.5 to +1.5 K. The lever is free, so the offset is the only knob that trades comfort for energy. Each arm's seven weeks trace a front, and the gate reads its energy on that front at the discomfort of BOPTEST's own baseline controller in the same week, so neither arm can win by heating less.

What it found

Six replicates, each a 20-day log of the reset controller and the week after it, the weeks running from day 20 to day 62 of the year. Energy is BOPTEST's ener_tot in kWh/m2, read on each arm's front at the baseline's tdis_tot in K h, and excess = E_naive / E_adjusted - 1:

replicate baseline discomfort baseline energy adjusted energy naive energy excess
0 5.1359 1.7260 1.5813 1.4436 -0.0871
1 3.4514 1.8867 -- 1.4391 --
2 5.9027 1.7812 1.5764 1.4143 -0.1028
3 5.6058 1.6424 1.4856 1.3987 -0.0585
4 4.2318 1.6473 1.4717 1.1515 -0.2176
5 3.7969 1.4327 1.2583 -- --

The mean excess is -0.1165, with a 95% t-interval of [-0.2276, -0.0054] over the four replicates where both arms have a reading. So the decision reads REFUTED: at the baseline's comfort the naive call was the cheaper one, in every replicate that could be read. The verdict does not stand, because both validity checks failed:

  • V1: two fronts have no reading at the baseline's discomfort. In replicate 1 the adjusted arm's cheapest week was also free of discomfort, so its front is a single point more comfortable than the baseline. In replicate 5 the naive arm never got as comfortable as the baseline.
  • V2: one naive week had 15.2% of its solves stopped by the iteration budget, against a limit of 1%.

Beside the gate, not gated: at the baseline's discomfort the adjusted arm needed 10.45% less energy than the baseline itself (95% t-interval [8.57%, 12.33%]) and the naive arm 21.12% less ([13.52%, 28.72%]), each over the five replicates where it has a reading, which are not the same five.

Where the naive call breaks

Fitted on the log as it stands, the heat pump's channel is linear in the zone temperature, and it changes sign inside the range a heating target can reach. In replicate 2 it is 3.241 - 0.1413 T, zero at 22.94 C. Above that temperature the model reads more heat as cooling, so once the zone is above both the crossing and the target, the plan's answer to a room that is too warm is more heat. The naive loop ran away in eight weeks, all in the three replicates whose crossing sat lowest (2, 4 and 5). Over those weeks it held mean modulations of 0.63 to 0.97 and used 2.35 to 3.75 kWh/m2 against the baseline's 1.43 to 1.78, and its discomfort reached 522 to 1403 K h. BOPTEST's tdis_tot counts excursions above the upper comfort bound as well as below the lower one, and at those modulations these were overheating. In replicate 5 it happened at every offset from -0.25 K up, which is why that replicate's naive front never reaches the baseline. Track D-causal found the same sign change on the same emulator, at 22.62 C on one of its seeds, where it pinned the command at zero instead. The adjusted arm never ran away.

What the certificate said, and what the plant did

Every step is one prescribe call and only its first action is applied, so the step ahead is the part of each plan the plant can check. Over every week of an arm: the range of the trustworthy prefix, the one-step figures pooled, the largest finite regret bound and the share of calls where it was infinite, and the largest share of any one week's calls stopped by the iteration budget or left at the zero guess:

arm trustworthy steps tube one step ahead K one-step error rms K largest error K inside the tube inside the tolerance regret bound bound infinite stopped by budget no progress
adjusted 1-1 0.2926 0.1305 0.7931 0.959 0.991 2.15e-06 0.034 0.000 0.193
naive 0-0 -- 0.3263 1.6513 -- 0.872 5.34e-05 0.121 0.152 0.247

Where they disagree:

  • The adjusted certificate trusted one step on every call, with an error tube of 0.29 K on average. The plant's one-step error left that tube on 4.1% of steps, reaching 0.79 K. The tube priced the channel's standard error alone, which is a scale and not a coverage guarantee.
  • That error has been read three other ways since, and the run was not repeated under any of them. It was the homoskedastic one; from 0.8.0 it is robust to noise that moves with the state. It was the root mean of the variances of the channel's two coefficients, and one of them is the channel's value at 0 C, which a log in Celsius reaches only by extrapolation; from 0.13.0 it is the error of the channel's value at the log's states. It took each half-hour as independent; from 0.16.0 one unit's error is read across its periods by cosines (ADR 0075), wider where the scores persist. The run's logs were not kept, so they cannot be refitted. On 600 logs simulated with this case's logging policy, size and units, the noise drawn afresh or persisting up to AR(0.99) a half-hour (scripts/bench_boptest_tube.py), the run's library read the tube one step ahead at 0.25 K on average and 0.16.0 at 0.051 K, 0.11 to 0.43 of it on nine logs in ten. The cosines widened it, 1.0 times where the noise was drawn afresh and 1.8 times where it was AR(0.99), and the error at the log's states narrowed it to 0.12 to 0.23 of what it was. Those ratios put the run's 0.29 K at 0.033 to 0.125 K, under the plant's one-step error of 0.1305 K rms, and how often the plant would have left such a tube is not known. On the simulated logs 0.16.0's certificate trusted 6 to 13 steps on nine logs in ten, where the run's library trusted 1 to 5.
  • None of those tubes held the plant's own noise. From 0.17.0 the tube carries it (ADR 0080): it adds the radius that holds the plan's one-step error on the log with probability 0.95 at each step, the error read off the fit's defect with its lag-1 correlation. The same 600 logs were each run on from their last row 200 times under the plan, the noise and the weather going on from the log's (cover). The plant's error one step ahead reached 0.16 to 0.22 K at 95%, cell by cell, and it passed the tolerance of 0.5 K at 95% after 2 or 3 steps. 0.16.0's tube read 0.051 K one step ahead on average and held the plant at the first step on 40% of draws. The new tube reads 0.21 to 0.29 K and holds it at the first step on 97% to 99% of draws, cell by cell, and on 98.8% of the steps it trusts. Its certificate trusts 1 or 2 steps on nine logs in ten and 3 at most, and more than the log's own plant allows on 0 to 2% of the logs in a cell. From 0.18.0 the tube is carried by the fitted field's slope at each planned action and adds the drift's own error to the channel's (ADR 0096). Measured the same way before and after, the tube one step ahead does not move, the mean trusted steps move from 1.56 to 2.54 by cell to 1.59 to 2.71, at most 4 where it was 3, and the logs trusted past their own plant's horizon rise by one in one cell and by two in another, of 100.
  • The regret bound stayed small wherever it was finite, the runaway weeks included. It is a gap in the planning objective on the fitted model, not a statement about the plant. It was infinite, the objective not convex over the box, on 3.4% of the adjusted calls and 12.1% of the naive ones.
  • Nothing in the certificate bounds comfort, which is what the gate matches on and what the runaway weeks lost: prescribe refused a constraint on its target column, so there was no barrier and no gamma*. From 0.7.0 it accepts one (ADR 0005). Whether its audit would have flagged these weeks is open: it prices the fitted model, under which more heat cools a zone above the crossing.

Where they agree: the naive certificate trusted nothing, because an asserted identification never earns a step whatever the model predicts, and the runaway weeks bore that out. They are also what pushed the naive arm past the V2 limit.

Scope, and it bounds what the numbers mean:

  • The verdict is void, so this page makes no claim about what adjustment costs or saves here. The four readings that exist all favour the naive call.
  • The lever is priced at zero, so each plan minimises distance to the target, not energy. In replicate 4 at offset 0 the naive arm held a higher mean modulation than the adjusted one (0.296 against 0.253) and used less energy (1.1529 against 1.4770 kWh/m2). Energy use here is not proportional to modulation, and neither arm was optimising what ener_tot measures.
  • The façade could not state three things the problem has: a bound on the steered state, as above; a weather term in the drift, so the weather the adjusted arm partials out never enters its forecast; and a target that changes within the horizon, so the comfort schedule reaches the plan only through re-planning, which pre-heats up to one horizon early. From 0.7.0 a Target takes a schedule (ADR 0006) and prescribe takes a forecast of the weather as a driver in the drift (ADR 0007).
  • Six replicates, sized to the emulator time available rather than for power. Neighbouring logs share 13 of their 20 days, so the interval treats replicates as more independent than they are.
  • Every number is float64, from the library at commit 7796c69: after 0.5.1, which is the version it reports, and before 0.6.0.

Results: results/boptest_prescribe/results.md. Code: causaldyn_bench/boptest_prescribe.py, through prescribe.

The rerun, with the band, the weather and the schedule stated

From 0.7.0 prescribe states all three things the first run could not. A second run asked the same question with them stated. It was pre-registered the same way, on the same six logs and weeks, with the library at f3f9eae, in float64:

prescribe(
    panel,
    levers=[Lever("modulation", 0, 1, unit_cost=0)],
    target=Target("zone", value=lower[1:] + offset),
    constraints=[Constraint("zone", hi=min(upper[1:]))],
    hold_constraints=True,
    drivers=[Driver("outdoor", outdoor), Driver("solar", solar)],
    horizon=16,
    dt=0.5,
    tolerance=0.5,
    x0=[zone],
    adjustment=graph,
)  # the naive arm passes adjustment=()
  • The target now follows the lower comfort bound step by step, plus an offset swept from +1 to +2.5 K.
  • The band's upper edge is held inside the solve.
  • BOPTEST's weather forecast enters the drift. The fit puts every driver into the channel's nuisance covariates, whatever adjustment says, so both arms are adjusted for the weather. The arms now differ only in bound: the logging controller's comfort feedback, and the occupancy behind it.
replicate baseline discomfort baseline energy adjusted energy naive energy excess
0 5.1359 1.7260 1.4627 1.4627 -0.0000
1 3.4514 1.8867 -- -- --
2 5.9027 1.7812 1.5339 1.5429 +0.0059
3 5.6058 1.6424 1.4973 1.4967 -0.0004
4 4.2318 1.6473 1.3024 1.3414 +0.0299
5 3.7969 1.4327 -- -- --

The mean excess is +0.0088, with a 95% t-interval of [-0.0140, +0.0317] over the four replicates where both arms have a reading, so the decision reads INCONCLUSIVE. This verdict does not stand either:

  • V1: in replicates 1 and 5, neither arm got as comfortable as the baseline at any of the offsets. The pilot had ended them at its own least discomfort, and these weeks needed more.
  • V2: counting only calls made from inside the band, the iteration budget stopped up to 7.1% of an adjusted week's calls and 5.5% of a naive week's, against a limit of 1%.

Beside the gate, not gated: at the baseline's discomfort, the adjusted arm needed 14.73% less energy than the baseline (95% t-interval [6.81%, 22.65%]) and the naive arm 14.02% less ([7.58%, 20.46%]). Each is over the four replicates where it has a reading.

What the held band did. The naive arm, now adjusted for the weather, no longer ran away: in no week did the zone end a step above the held edge more than 3.3% of the time.

The adjusted arm ran away once, in replicate 1 at the two highest offsets. There its fitted channel, 3.579 - 0.1505 T, crosses zero at 23.78 C. It is the only one of the twelve fits under which more heat cools a zone inside the band. The zone ended above the edge on 17.6% and 24.4% of steps, and discomfort reached 114 and 194 K h.

The band holds the fitted model's zone inside it, and under that model more heat cools a zone above 23.78 C. Once the plant's zone is above the edge, no call can meet the barrier. So a held constraint is a statement about the model, and it protects the plant only where the model's channel has the right sign.

What the certificate said. The adjusted plans were trusted for between none and two steps, where the first run's were trusted for one step on every call. The tube one step ahead averaged 0.31 K, and the plant stayed inside it on 98.1% of steps, against 95.9% in the first run. The tube now prices a standard error that is robust to noise moving with the state. The naive plans were trusted for no step, as before.

That error was still the root mean of the coefficients' variances, each half-hour taken as independent, and the rerun was not repeated either. On the same simulated logs, under the rerun's call, 0.16.0 read the tube one step ahead at 0.10 to 0.42 of the rerun's library's on nine logs in ten, which puts the rerun's 0.31 K at 0.032 to 0.131 K, and its certificate trusted 6 to 13 steps on nine in ten, where the rerun's library trusted 1 to 5.

From 0.17.0 the tube carries the plant's own noise here too. Run on as above, with the drivers held at their last logged values in the plan and drawn on in the plant, the plant's error stays within 0.5 K at 95% for 2 to 5 steps, cell by cell. 0.16.0's tube held the plant at the first step on 35% to 51% of draws. The new one holds it there on 98% to 99% of draws, and on 98.6% of the steps it trusts. Its certificate trusts 2 or 3 steps on nine logs in ten, 4 at most, and more than the log's own plant allows on 0 to 2% of the logs in a cell. The held forecast's own error is not in the tube, and over these few steps the weather moves little.

Scope: the same six replicates, the same shared log days, and the same zero lever price as above. Neither verdict stands, so this page makes no claim about what adjustment costs or saves on this building.

Results: results/boptest_rerun/results.md. Code: causaldyn_bench/boptest_rerun.py.

Before prescribe: forecast-MPC against the baseline

The first run on this emulator did not go through prescribe. It identified a thermal model from a slow pseudo-random exploration, T_next = 0.980·T + 0.243·u + 5.76 with u the heat pump's modulation in [0, 1], and drove the house with a forecast-driven comfort MPC, over a one-day episode at a 30-minute step:

KPI BOPTEST's baseline forecast-MPC
tdis_tot (thermal discomfort, K·h) 8.01 7.32
ener_tot (energy) 0.393 0.354
cost_tot 0.100 0.090
emis_tot (emissions) 0.066 0.059
pele_tot (peak electrical demand) 0.0190 0.0194

It beat the tuned baseline on discomfort, energy, cost and emissions at once, four KPIs of five; its peak electrical demand was 2.0 % above the baseline's. Results: results/boptest.md.