Radiator-by-radiator balancing from room temperature curves
Thermostatic valves on seven radiators learn each room's heating curve over two weeks, then trim the valve position to hold ±0.4 °C instead of ±1.6 °C, which cut gas use by 12 % in a leaky 1970s flat.
# HearthNodes recipe 14 - radiator balancing from room curves
alias: "Climate - per room radiator balancing"
id: hn_climate_radiator_balance
description: "Learned per-room offsets hold +/-0.4 C instead of +/-1.6 C"
mode: queued
max_exceeded: 4
trigger:
- platform: state
entity_id: binary_sensor.heating_season_active
to: "on"
- platform: time
at: "02:30:00"
id: nightly_refit
- platform: template
value_template: >-
{{ (states('sensor.living_temperature') | float(20)
- states('input_number.living_target') | float(20)) | abs > 0.8 }}
for: "00:20:00"
id: deviation
variables:
rooms:
living: { sensor: sensor.living_temperature, valve: climate.radiator_valve_living }
study: { sensor: sensor.study_temperature, valve: climate.radiator_valve_study }
main_bed: { sensor: sensor.main_bed_temperature, valve: climate.radiator_valve_main_bed }
second_bed: { sensor: sensor.second_bed_temperature, valve: climate.radiator_valve_second_bed }
bath: { sensor: sensor.bath_temperature, valve: climate.radiator_valve_bath }
hall: { sensor: sensor.hall_temperature, valve: climate.radiator_valve_hall }
kitchen: { sensor: sensor.kitchen_temperature, valve: climate.radiator_valve_kitchen }
condition:
- condition: numeric_state
entity_id: sensor.outdoor_temperature
below: 15
- condition: numeric_state
entity_id: sensor.boiler_flow_temperature
above: 45
action:
- choose:
- conditions:
- condition: trigger
id: nightly_refit
sequence:
- service: script.learn_heating_curve
data:
rooms: "{{ rooms }}"
min_r2: 0.6
- conditions: []
sequence:
- repeat:
for_each: "{{ rooms | dict2items }}"
sequence:
- service: climate.set_temperature
target:
entity_id: "{{ repeat.value.valve }}"
data:
temperature: >-
{{ [[states('input_number.' ~ repeat.key ~ '_target') | float(20)
+ states('input_number.' ~ repeat.key ~ '_offset') | float(0),
5] | max, 30] | min }}
# Offsets are clamped to +/-2 C and are only enabled after a two week learning
# run. More than two heads offline -> balancing stops and every valve goes to the
# plain schedule target, with the notification saying so. Hydraulic faults are
# fixed at the lockshield valve, never in software.
Trigger · state
binary_sensor.heating_season_active
A room's temperature deviates from its schedule target by more than 0.8 °C for 20 minutes.
Step-by-step
- climate.radiator_valve_[room]Apply a per-room offset learned from the curve model, clamped to ±2 °C, so a slow room gets a higher valve target than a fast one.
set_temperature · {{ target + offset }} - script.learn_heating_curveRefit each room's first-order response curve nightly using the day's valve position and temperature data, and reject the fit if R² is below 0.6.
nightly · 02:30 · update coefficients - climate.boiler_flowSet the boiler's flow temperature from the outdoor temperature curve, which is what makes the valves' job possible at all.
set_flow_temp · weather compensated - counter.balance_cycleProduce a weekly report of per-room deviation so a radiator that is genuinely undersized shows up as a persistent negative bias.
increment · weekly report
Put the temperature sensor where people are, not on the radiator
A valve head's built-in sensor reads the pipe, not the room. Mount a separate sensor on an interior wall at 1.2 m, away from direct sun and at least 50 cm from any heat source. This single change typically removes 70 % of the complaint that 'the thermostat is wrong'.
Learn for two weeks before you trust it
The first-order curve needs at least ten heating cycles per room to be meaningful. Run the system on the plain schedule for two weeks with logging on and only then enable offsets. Enabling offsets on day one produces oscillation that looks like a hardware fault.
Do not balance a system that is not balanced hydraulically
If one radiator is always cold at the bottom or noisy, the problem is the lockshield valve, not the smart head. Set the lockshield valves by the temperature-drop method first: 10–12 °C drop across each radiator. Software cannot fix a hydraulic fault.
Hardware
| Model | Protocol | Link | Role in this chain |
|---|---|---|---|
| Z-Wave thermostatic radiator valve heads (e.g. Danfoss Ally or Eurotronic Spirit) | zwave |
wireless | Seven heads, one per radiator. Z-Wave is used because it reports the valve's actual opening percentage, which the learning script needs. |
| Room temperature sensors, one per room, 1.2 m height | zwave |
wireless | Separate from the valve heads — a valve head's own sensor sits 10 cm from a hot pipe and reads 3 °C high. |
| Outdoor temperature sensor on a north wall | zwave |
wireless | The weather-compensation input; a sensor in sunlight will disable the boiler in winter. |
When it fails
If a valve head loses communication, it reverts to its own local schedule, which is a reasonable default. If more than two heads are offline, the automation stops balancing and sets all valves to the schedule target without offsets, and the notification says so — degrading to a dumb system is better than balancing against missing data.