2023-2024
I’ve been told to flush them every 10 years. But it’s also said that they should be flushed if the temperature difference between the top and bottom is significant.
Here, “significant” is pretty vague. Let’s consider that 5°C of difference is a good start and definitively 10°C says that we have to do something right now!
This winter, I installed a Zigbee sensor on the top of a radiator and one on the bottom. Let’s see what the results are.
First, let’s get the data out of my database and perform a little bit of cleanup and formatting to ease playing with the data.:
from redis import StrictRedis
import pandas as pd
from datetime import datetime
d = StrictRedis()
t = d.ts()
top = pd.DataFrame(t.range("zigbee.RadiatorTop", int(datetime.strptime("2024-01-23", "%Y-%m-%d").timestamp() * 1000), int(datetime.strptime("2024-03-01", "%Y-%m-%d").timestamp() * 1000)))
bottom = pd.DataFrame(t.range("zigbee.RadiatorBottom", int(datetime.strptime("2024-01-23", "%Y-%m-%d").timestamp() * 1000), int(datetime.strptime("2024-03-01", "%Y-%m-%d").timestamp() * 1000)))
top.columns = ['time', 'top']
bottom.columns = ['time', 'bottom']
top = top.set_index('time')
bottom = bottom.set_index('time')
r = pd.concat([top, bottom])
r.index = pd.to_datetime(r.index, unit="ms")
r = r.sort_index()
# remove the days we were out of the home and the radiator were off
r = r[(r.index < '2024-02-17') | (r.index > '2024-02-23')]
# let's try hard to have values for both top and bottom at each point in time, so that we can compare them
r = r.interpolate(method='polynomial', order=5).dropna()
file = "/tmp/radiator.csv"
r.to_csv(file)
return file
https://ipfs.konubinix.eu/p/bafybeib7nacfjayqutve75blqlrni6xg3iilssudn5atziyvur6xxm5nm4
This is the data I will use hereafter.
Let’s load it.
import pandas as pd
r = pd.read_csv(file, parse_dates=["time"], index_col="time")
print(r)
top bottom
time
2024-01-22 23:12:02 30.180000 25.047615
2024-01-22 23:15:29 29.770144 24.630000
2024-01-22 23:22:02 29.190000 24.002997
2024-01-22 23:25:56 28.872237 23.710000
2024-01-22 23:31:53 28.370000 23.250299
... ... ...
2024-02-29 22:43:00 34.462688 28.970000
2024-02-29 22:44:09 34.290000 28.825460
2024-02-29 22:48:37 33.702131 28.320000
2024-02-29 22:49:07 33.650000 28.267985
2024-02-29 22:53:37 33.300166 27.670000
[6124 rows x 2 columns]
Let’s try to simply plot them to have a feeling of how they look like.
The data looks clean.
from pathlib import Path
out=Path("/tmp/plot.html")
import plotly.io as pio
variable=locals()[var]
function=variable.__getattr__(fct)
kwargs={
"backend":"plotly",
}
if marker:
kwargs["markers"] = True
out.write_text(
pio.to_html(
function(**kwargs)
)
)
print(out)
In case I want to get a deeper interactive look at the data, I will also provide a plot made with plotly.
look at outliers
One of our hypothesis is that sludge is supposed to cause the bottom part of the radiator to be significantly colder than the top, as the sludge hinders the flow of water. The water will simply flow were the sludge is not, hence at the top.
Let’s find out whether that hypothesis holds.
print(r[r.bottom > r.top])
top bottom
time
2024-02-04 02:14:42 17.486029 17.620000
2024-02-04 02:30:59 17.490000 17.739859
2024-02-28 17:13:05 22.417144 22.810000
2024-02-28 17:19:12 22.420000 22.614221
Hmm, that’s not bad. But there are still two outliers. Let’s take a look at them.
First, at about , something apparently went wrong.
a = r["2024-02-04 00:00":"2024-02-04 04:00"]
This is strange. The values are pretty close, so maybe this goes into simple precision errors. I think this one won’t harm the analysis.
The second one occurs at .
a = r["2024-02-28 17:00":"2024-02-28 18:00"]
The temperature of the top probe suddenly drops before getting back to normal. This is most likely the effect of opening the window. I can assume that at that time, we had to open them for a little while, like for cleaning them. Again, it does not seem that problematic, so let’s keep it that way.
analysis
r.describe()
top bottom
count 6124.000000 6124.000000
mean 30.355279 26.400982
std 6.203890 5.397042
min 17.212234 16.480128
25% 24.970635 21.350000
50% 31.500000 27.006118
75% 35.810000 31.400295
max 43.140000 36.485341
The data are pretty close, let’s take a look at the difference.
a = (r.top - r.bottom)
a.describe()
count 6124.000000
mean 3.954297
std 1.397309
min -0.392856
25% 3.028367
50% 3.930668
75% 4.894372
max 9.447843
dtype: float64
This indicates that, for the most part, the top part is around 4°C hotter than the bottom part.
The mean and the median are very close, indicating a symmetry.
It might be easier to see this visually.
And the same with plotly (just for fun).
We can see what the data indicated: The hot part being about 4°C hotter for the most part.
conclusion
We decided that if the top part would be more than 5°C hotter than the bottom one, we would consider flushing the radiators.
This in not the case this year. Therefore, without a good reason to believe, we claim that this is not needed (this year).
2024-2025
Let’s try again, using another set of radiators this time.
Let’s go directly to the relevant part.
from redis import StrictRedis
import pandas as pd
from datetime import datetime
d = StrictRedis()
t = d.ts()
top = pd.DataFrame(t.range("zigbee.RadiatorTop", int(datetime.strptime("2024-10-06", "%Y-%m-%d").timestamp() * 1000), int(datetime.strptime("2025-04-28", "%Y-%m-%d").timestamp() * 1000)))
bottom = pd.DataFrame(t.range("zigbee.RadiatorBottom", int(datetime.strptime("2024-10-06", "%Y-%m-%d").timestamp() * 1000), int(datetime.strptime("2025-04-28", "%Y-%m-%d").timestamp() * 1000)))
top.columns = ['time', 'top']
bottom.columns = ['time', 'bottom']
top = top.set_index('time')
bottom = bottom.set_index('time')
r = pd.concat([top, bottom])
r.index = pd.to_datetime(r.index, unit="ms")
r = r.sort_index()
# remove the days we were out of the home and the radiator were off
r = r[(r.index < '2024-12-23') | (r.index > '2024-12-28')]
# something went strange in those days. I don't know what
r = r[(r.index < '2025-01-16') | (r.index > '2025-01-17')]
r = r[(r.index < '2024-11-15') | (r.index > '2024-11-16')]
r = r[(r.index < '2024-12-01') | (r.index > '2024-12-02')]
# let's try hard to have values for both top and bottom at each point in time, so that we can compare them
r = r.interpolate(method='polynomial', order=5).dropna()
file = "/tmp/radiator.csv"
r.to_csv(file)
return file
https://ipfs.konubinix.eu/p/bafybeibda7hcaotbaogceltbmak6fmlq6j4na3vc5qka6hmjpuebosrvgm
import pandas as pd
r = pd.read_csv(file, parse_dates=["time"], index_col="time")
print((r.top - r.bottom).describe())
count 66772.000000
mean 4.265862
std 1.563009
min -0.239465
25% 3.335824
50% 4.400335
75% 5.377492
max 9.079097
dtype: float64
Most of the time, the difference is less than 5°C. The bigger values are easily explained by inertia when the radiator gets up in temperature.
Therefore, I don’t see a point in flushing them this year also.
2025-2026
Note: the following was almost entirely made using Claude Opus. It needed a little help and made strange hypothesis from time to time. But overall after a few iterations, this is the result.
from redis import StrictRedis
import pandas as pd
from datetime import datetime
d = StrictRedis()
t = d.ts()
top = pd.DataFrame(t.range("zigbee.Temp.RadiatorTop", int(datetime.strptime("2025-10-01", "%Y-%m-%d").timestamp() * 1000), int(datetime.strptime("2026-04-01", "%Y-%m-%d").timestamp() * 1000)))
bottom = pd.DataFrame(t.range("zigbee.Temp.RadiatorBottom", int(datetime.strptime("2025-10-01", "%Y-%m-%d").timestamp() * 1000), int(datetime.strptime("2026-04-01", "%Y-%m-%d").timestamp() * 1000)))
top.columns = ['time', 'top']
bottom.columns = ['time', 'bottom']
top = top.set_index('time')
bottom = bottom.set_index('time')
r = pd.concat([top, bottom])
r.index = pd.to_datetime(r.index, unit="ms")
r = r.sort_index()
# remove a single bogus top sensor reading (4.17°C glitch surrounded by ~19°C)
r = r[(r.index < '2025-10-08 06:50') | (r.index > '2025-10-08 06:51')]
# remove bottom sensor spike (values up to 1177°C)
r = r[(r.index < '2025-10-21 17:02') | (r.index > '2025-10-21 19:33')]
# let's try hard to have values for both top and bottom at each point in time, so that we can compare them
r = r.interpolate(method='polynomial', order=5).dropna()
file = "/tmp/radiator2026.csv"
r.to_csv(file)
return file
https://ipfs.konubinix.eu/p/bafybeicjpzyfsk4q7er4h5ht3jnvpgtnw2zbidx5nj64iyt4tow3ome74u
import pandas as pd
r = pd.read_csv(file, parse_dates=["time"], index_col="time")
print((r.top - r.bottom).describe())
count 15164.000000
mean 0.138452
std 0.853415
min -6.108131
25% -0.236968
50% -0.006891
75% 0.318501
max 20.654299
dtype: float64
Something is off: the min is -6°C, the std is large, and the mean is near zero. Previous years showed a consistent ~4°C difference. Let’s investigate.
Let’s load the data into a session for further analysis.
import pandas as pd
r = pd.read_csv(file, parse_dates=["time"], index_col="time")
print(r)
top bottom
time
2025-10-01 09:00:01 14.981538 13.100000
2025-10-01 09:13:14 17.060000 16.614807
2025-10-01 09:14:01 17.204665 16.720000
2025-10-01 09:15:14 17.420000 16.866503
2025-10-01 09:17:01 17.699998 17.050000
... ... ...
2026-01-30 19:43:17 17.288472 17.320000
2026-01-30 19:44:53 17.280000 17.318960
2026-01-30 20:13:16 17.225580 17.290000
2026-01-30 20:14:53 17.230000 17.288455
2026-01-30 20:43:15 17.267005 17.300000
[15164 rows x 2 columns]
look at outliers
Before the cleanup, the raw data contained two sensor anomalies. Let’s visualize what was removed.
The bottom sensor spiked to absurd values (up to 1177°C) on between 17:02 and 19:33. The top sensor read normally (~18°C) during that time.
The top sensor also had a single bogus reading of 4.17°C on at 06:50, surrounded by ~19°C values, followed by a 9-hour data gap. This looks like a sensor glitch.
Both anomalies were removed in the data extraction step above.
analysis
r.describe()
top bottom
count 15164.000000 15164.000000
mean 17.926148 17.787697
std 1.532137 1.591077
min 9.980000 -1.624299
25% 16.970000 16.836432
50% 17.919019 17.632768
75% 18.780000 18.551108
max 25.550000 30.450000
The mean difference (top - bottom) is near zero (0.14°C), compared to ~4°C in previous years. Let’s look at the distribution.
a = (r.top - r.bottom)
The distribution is tightly centered around zero, with a left tail going down to -6°C. This tail comes from a single event: the Christmas heat-up.
Christmas heat-up anomaly
On Christmas Eve, the radiator was off and both sensors cooled down to ~10°C. When the heating kicked back in around 23:05, the bottom sensor heated up much faster than the top, reaching ~30°C while the top was only at ~24°C. This created a sustained -6°C inversion lasting about 5 hours.
Since hot water enters the radiator at the top, we would expect the top sensor to heat up first. The fact that the bottom heats faster is surprising.
One hypothesis is that the sensor labels (“top” and “bottom”) were accidentally swapped. However, this doesn’t hold up: during normal heating cycles (e.g. Oct 1), the current “top” label consistently leads, which is the expected behavior.
# Check Oct 1 morning heat-up: does "top" lead?
warmup = r["2025-10-01 09:00":"2025-10-01 11:00"]
diff_warmup = warmup["top"] - warmup["bottom"]
print(f"Oct 1 heat-up: top always > bottom? {(diff_warmup > 0).all()}")
print(f"Oct 1 mean diff: {diff_warmup.mean():.2f}°C")
print()
# Check stable periods
stable = r[r["top"].diff().abs() < 0.1]
stable_diff = stable["top"] - stable["bottom"]
print(f"Stable periods: mean diff = {stable_diff.mean():.3f}°C")
print(f"Stable periods: top > bottom {100*(stable_diff > 0).mean():.1f}% of the time")
Oct 1 heat-up: top always > bottom? True
Oct 1 mean diff: 0.94°C
Stable periods: mean diff = -0.010°C
Stable periods: top > bottom 41.0% of the time
So the labels are not simply swapped. The Christmas inversion remains unexplained – possibly related to the radiator restarting after a prolonged cold period with different flow dynamics.
conclusion
Unlike previous years where the top-bottom difference was consistently ~4°C (well below the 5°C flushing threshold), this year’s data shows a mean difference near zero (0.14°C).
This makes the “is the bottom significantly colder?” diagnostic unreliable for this setup: the two sensors track each other too closely during normal operation to reveal any meaningful gradient.
The only moment with a significant difference is the Christmas heat-up (-6°C), but its cause is ambiguous and it occurs in a transient regime, not during steady-state operation.
Without a consistent top-bottom gradient comparable to previous years, the data this year is inconclusive. The flushing question cannot be reliably answered from this dataset. To get a definitive answer, we would need to either reproduce the measurement on the same radiator as previous years, or verify the sensor positions on this one.
2025-2026 other radiator
Sensors moved to another radiator on . The analysis is restricted to the period the probes spent on this radiator during the heating season (up to ); afterwards the radiator was no longer used.
from redis import StrictRedis
import pandas as pd
from datetime import datetime
d = StrictRedis()
t = d.ts()
start = int(datetime.strptime("2026-02-03 10:00", "%Y-%m-%d %H:%M").timestamp() * 1000)
# the radiator was no longer used after mid-April, so stop on 2026-04-16
end = int(datetime.strptime("2026-04-16 13:00", "%Y-%m-%d %H:%M").timestamp() * 1000)
top = pd.DataFrame(t.range("zigbee.Temp.RadiatorTop", start, end))
bottom = pd.DataFrame(t.range("zigbee.Temp.RadiatorBottom", start, end))
top.columns = ['time', 'top']
bottom.columns = ['time', 'bottom']
top = top.set_index('time')
bottom = bottom.set_index('time')
r = pd.concat([top, bottom])
r.index = pd.to_datetime(r.index, unit="ms")
r = r.sort_index()
# heating off and home cold from Feb 9 to Feb 13
r = r[(r.index < '2026-02-09') | (r.index > '2026-02-13 22:00')]
# linear-in-time interpolation: at each timestamp only one probe is measured,
# the other is filled. A quintic spline overshoots on heating ramps and
# fabricates spurious extremes, so we interpolate linearly in time instead.
r = r.interpolate(method='time').dropna()
file = "/tmp/radiator2026_other.csv"
r.to_csv(file)
return file
https://ipfs.konubinix.eu/p/bafybeicc7e7pum4uneaotbzvv5v46hpiowiierdjqpwrhjoygtbzuyxgp4
A note on the interpolation: at each point in time only one of the two probes
reports a value, so the other has to be filled in. The previous years fitted a
quintic spline (polynomial, order 5) for this. I realized that this is a poor
choice: a high-order spline overshoots on the heating ramps and fabricates
values that are not in the data – it produced spurious differences below -5°C
and above 10°C during heating. A plain linear interpolation in time turns out
to be more faithful: it leaves the median and the share above 5°C essentially
unchanged, but it removes those invented extremes. With it, the top comes out
hotter than the bottom 100% of the time during heating, as physics says it
should. So I switched this analysis to linear-in-time interpolation.
import pandas as pd
r = pd.read_csv(file, parse_dates=["time"], index_col="time")
print(r)
top bottom
time
2026-02-03 09:09:47 25.188285 24.550000
2026-02-03 09:11:08 25.090000 24.565169
2026-02-03 09:14:14 24.867542 24.600000
2026-02-03 09:16:09 24.730000 24.519141
2026-02-03 09:21:09 24.340000 24.308203
... ... ...
2026-04-16 10:05:34 20.310000 21.281423
2026-04-16 10:25:21 20.000000 21.031199
2026-04-16 10:27:49 19.978271 21.000000
2026-04-16 10:32:31 19.936868 20.980000
2026-04-16 10:35:34 19.910000 20.980000
[24601 rows x 2 columns]
a = (r.top - r.bottom)
a.describe()
count 24601.000000
mean 2.614410
std 2.887542
min -2.130067
25% -0.153320
50% 2.359190
75% 5.184000
max 9.593627
dtype: float64
Restricting to moments when the radiator is actually hot (top above 25°C):
hot_diff = (r[r.top > 25].top - r[r.top > 25].bottom)
hot_diff.describe()
count 14310.000000
mean 4.662160
std 1.922482
min 0.080000
25% 3.076667
50% 4.733583
75% 6.228575
max 9.593627
dtype: float64
print(f"|diff| > 5°C: {100*(hot_diff.abs() > 5).mean():.1f}%")
print(f"|diff| > 10°C: {100*(hot_diff.abs() > 10).mean():.1f}%")
print(f"top hotter than bottom: {100*(hot_diff > 0).mean():.1f}%")
|diff| > 5°C: 45.9%
|diff| > 10°C: 0.0%
top hotter than bottom: 100.0%
conclusion
This radiator shows a clear top-bottom gradient: during heating (top > 25°C), the top is hotter than the bottom essentially all the time, with a median difference of about 4.7°C and a mean of 4.7°C. Around 46% of the hot samples exceed the 5°C threshold.
The gradient is a bit larger than the previous radiators (~4°C in 2023-2024 and 2024-2025) and sits right at the 5°C threshold I had set, with nearly half the hot samples above it.
Unlike the previous years, this radiator is a borderline case: the gradient is close enough to the threshold that flushing it becomes a reasonable option.
Change of approach: a thermal camera
The two-probe approach was weak on two counts. In execution, my probes turned out to sit on the inlet and outlet pipes, so I was measuring a flow-return ΔT, not the radiator body. And in principle a single ΔT is a noisy proxy for sludge: a radiator runs cold or uneven for several reasons other than silting – an unbalanced system, a stuck pin in a valve, a faulty hot water pump1. I also reason, physically rather than from any source, that even the sign of the gradient is ambiguous: a silted bottom would both shrink the active surface and choke the flow, and I expect those to pull ΔT in opposite directions, so one number cannot say which dominates. A localized cold patch, I expect, washes out once the radiator is reduced to a single figure.
A thermal camera is a better fit. With the radiator hot and seen face-on, a silted one shows a cold patch along the bottom – the cold spot the source ties to corrosion, with sludge collected at the bottom2 – a spatial pattern rather than a single number. After a power flush the radiator should show a more or less uniform colour, without those cold patches3, which is the reading I would use to tell whether the flush worked. It is not interpretation-free – a cold band can equally be one of those other faults rather than sludge1, and I still need a threshold for “how cold counts” – but it reads the actual thing. The catch, I expect, is that it needs the heating on, so the real test waits for next season.
Picked: a TOPDON TC001 (256×192 resolution4), read with the open-source thermal-camera-android app. Getting it to actually stream took some doing – see making the TOPDON TC001N thermal camera stream on Android.
Notes linking here
Permalink
-
Trade guide – Trade Radiators, “Why is my radiator cold at the top/bottom?”: “If you’ve got radiators in some rooms heating up but in other rooms staying cold, then your heating system might be unbalanced.” – “Your thermostatic radiator valve (TRV) could have a stuck pin resulting in the valve not letting hot water into the radiator.” – “Uneven heating could occur due to a faulty hot water pump or other component of your heating system that is stopping hot water from circulating around your system properly.” https://www.traderadiators.com/blog/cold-hot-radiator (secondary – a retailer’s how-to, cited because domestic-heating symptoms like this are not covered by any freely reachable primary standard). ↩︎ ↩︎
-
Test-Meter, “Using Thermal Imaging to Check Radiator Power Flushing”: “Corrosion usually causes cold spots at the bottom of a radiator, as shown in Figure 1 where a significant amount of sludge has collected in the bottom of a radiator.” – “When checking whether flushing is required, it is important to look for lower temperatures than expected on any part of the radiator, especially for cold spots or patches.”
https://www.test-meter.co.uk/blog/using-thermal-imaging-to-check-radiator-power-flushing
(secondary – an instrument retailer’s technical guide, cited because no freely reachable primary standard covers domestic-heating thermal diagnostics). ↩︎
-
Test-Meter guide, same source as the preceding note (secondary – an instrument retailer’s technical guide, cited because no freely reachable primary standard covers domestic-heating thermal diagnostics): “Once power flushing has been completed, the user should see a more or less uniform colour across the radiator, without the telltale cold patches caused by corrosion and debris.” https://www.test-meter.co.uk/blog/using-thermal-imaging-to-check-radiator-power-flushing ↩︎
-
Manufacturer’s product page – TOPDON TC001 specifications: “Resolution: 256×192 Pixels”. https://eu.topdon.com/products/tc001 (primary, for the hardware spec).
↩︎