Most consumer Zigbee temperature sensors report on change, not on a fixed schedule. That reporting model has a consequence that only shows up when you compute long-term aggregates. A naive mean() over a month weights every sample equally, regardless of how much real time each sample stands for. When the sensor produces hundreds of readings in a few minutes during a transient (direct sunlight, a draft, a fast temperature swing), all those readings cluster around the anomalous value and pull the average with them. At a daily scale the effect is not visible. On a monthly stat panel, the bias can reach two or three degrees.
Setup
The setup is a Sonoff SNZB-02D outdoor sensor mounted in a spot that catches morning sun, fed through Zigbee2MQTT into Home Assistant, with the InfluxDB add-on for storage. Grafana pulls monthly means from InfluxDB using plain SELECT mean("value") queries, one stat panel per month. The dashboard had been running for months and the numbers looked plausible.
Cross-check against Filaret
I compared the sensor’s monthly mean with the București-Filaret station published on meteoromania.ro.
| Month | OAT sensor | Filaret | Δ |
|---|---|---|---|
| Nov 2025 | 9.1 | 8.8 | +0.3 |
| Dec 2025 | 3.9 | 3.3 | +0.6 |
| Jan 2026 | −0.2 | −0.5 | +0.3 |
| Feb 2026 | 5.2 | 1.5 | +3.7 |
| Mar 2026 | 11.2 | 8.0 | +3.2 |
| Apr 2026 | 14.7 | 11.9 | +2.8 |
Winter months agreed within a degree. February through April were off by more than three degrees. Urban heat island explains a consistent one to two degree offset between two points in the same city. It does not explain three to four degrees, and it does not concentrate the offset in the months with the strongest solar exposure.
First hypothesis, solar spikes pulling the mean up
The sensor catches direct morning sun, so the obvious suspect is sensor body heating. I cataloged 23 hourly windows between November and April where the reported temperature spiked well above the real outdoor air temperature, cross-referenced with a co-located Shelly Plus2PM and DS18B20 in a sheltered position.
Example, 11 March 2026, local time:
| Hour | OAT | Roof (DS18B20) |
|---|---|---|
| 07:00 | 2.68 | 2.30 |
| 08:00 | 13.28 | 2.40 |
| 09:00 | 20.43 | 4.87 |
| 10:00 | 12.87 | 8.52 |
| 12:00 | 11.31 | 15.24 |
At 09:00 the OAT sensor reports 20.4°C while the real air temperature is around 5°C. The sensor body is heating in the sun.
I then recomputed the monthly mean while excluding all 23 anomalous hourly samples. The correction was 0.07°C for February and 0.16°C for March. Two orders of magnitude smaller than the gap to Filaret. The hypothesis was right about the mechanism and wrong about the size of the effect.
Actual cause, sample density bias
Sample counts per day for February told a different story.
| Date | Sample count |
|---|---|
| 14 Feb | 52 |
| 18 Feb | 65 |
| 19 Feb | 317 |
| 20 Feb | 178 |
| 24 Feb | 86 |
| 25 Feb | 274 |
| 27 Feb | 255 |
The high-count days were the same days as the solar spikes. On 19 February, more than 200 samples were logged between 10:24 and 11:33 local time, roughly one every few seconds, with the reading rising from −1.3°C to 19.9°C and returning to 1.2°C.
This is documented SNZB-02D behavior. The sensor reports on every 0.2°C change, with a 30-minute fallback when the reading is stable. Battery-powered Zigbee firmware avoids transmitting redundant data because radio time dominates power consumption.
In stable conditions this produces a handful of samples per hour. In a thermal transient it produces 200 or more, and each one carries an anomalous value.
InfluxDB stores each report as one row. SELECT mean(value) computes the arithmetic mean over rows, with no awareness of how much real time each row represents. February had roughly 1,800 samples in total. About 850 of those came from the seven anomalous days. Those days account for a quarter of the month by real time but almost half of the sample population, and their readings skew high. The result is a monthly mean that drifts toward the anomalous days.
Fix
InfluxQL 1.x supports nested subqueries, which is enough to fix this at query time without changing the ingestion path:
SELECT mean("mean_value") FROM (
SELECT mean("value") AS "mean_value"
FROM "°C"
WHERE "entity_id" = 'oat_sensor'
AND time >= 'start' AND time < 'end'
GROUP BY time(1h)
)
The inner query collapses each hour into one value. The outer query averages those hourly values with equal weight. A quiet hour at 03:00 and a spiking hour at 09:00 both contribute a single number to the monthly mean. Sample density stops mattering.
Results after applying the fix:
| Month | Raw mean | Hourly mean | Filaret |
|---|---|---|---|
| Nov 2025 | 9.1 | 8.6 | 8.8 |
| Dec 2025 | 3.9 | 3.9 | 3.3 |
| Jan 2026 | −0.2 | 0.1 | −0.5 |
| Feb 2026 | 5.2 | 2.5 | 1.5 |
| Mar 2026 | 11.2 | 8.8 | 8.0 |
| Apr 2026 | 14.7 | 13.7 | 11.9 |
The three to four degree gaps are gone. The residual 0.6 to 1.8°C offsets are consistent with urban heat island and with the smaller solar heating component that the hourly mean still lets through.
Generalization
The problem is general. Any sensor that reports on change rather than on a schedule, feeding any aggregation that treats samples as equally weighted, carries this bias. Candidates worth checking on any similar setup:
- Smart plugs reporting power on every change get oversampled during load cycling.
- CO2 and VOC sensors with adaptive reporting behave the same way.
- Any delta-based telemetry, which covers most consumer IoT.
The fix is always the same shape. Aggregate to a uniform time grid before averaging. Time-weighted means computed with integral() in InfluxQL, or the equivalent in other time-series databases, are the strictly correct approach. Fixed-interval pre-aggregation is easier to reason about and solves the problem in practice for most cases.
Detection
The original monthly numbers were internally consistent. Nothing in the sensor data itself signaled that anything was wrong. The bias only became visible against an independent reference, in this case ANM Filaret. When sensor output drives decisions such as heating optimization, efficiency calculations, or degree-day analysis, checking against a trusted external source is not optional.
Stack: Home Assistant 2026.5, Zigbee2MQTT 2.10, InfluxDB add-on 1.8, Grafana 12.3. Reference: meteoromania.ro București-Filaret monthly characterizations.