What
A new nwp500.thermal module that answers "when will the tank be hot?" from DeviceStatus, with zero new runtime dependencies.
Contents, roughly:
- the 18-row ASHRAE-34 R-134a saturation table + Magnus dew point — pure refrigerant physics, no fitting
- the forward-Euler recovery ODE (two-zone mass, see below)
- the standby-decay projection,
dT/dt = −(UA/mc_p)(T − T_amb) with the gap decaying as gap₀·exp(−h/τ)
- the parameter fitters (Nelder-Mead over three parameters — pure Python, no scipy)
- a
TimeToHeatEstimator holding a serializable calibration
It sits naturally beside usable_energy, already a derived computed_field on DeviceStatus with a measured validation behind it. Deterministic and testable without Home Assistant.
Why here
Every runtime input is already on the wire. This has been prototyped and run in production for ~400 days in a separate service (dhw-sensor-apps/time_to_heat), but that service needs InfluxDB, XGBoost, scipy and a hand-built training corpus. None of that is required for most of the accuracy, which makes the estimator a much better fit for this library than for a service.
Measured, on a 400-day / 274-event corpus:
| Estimator |
MAE |
| Production XGBoost + physics |
34.4 min |
| Physics alone |
36.3 min |
1.9 minutes. And physics alone is better than the tree in the deepest-recovery band. No xgboost, no scipy, no pickled artifact, no wheel-availability question on HA OS or ARM.
Two findings that should shape the implementation
The two-zone mass model is not optional. It is worth 8.1 min of MAE and 26 min of bias — about four times the entire ML contribution. The condenser coil wraps the lower stratum, so the ODE integrates the lower zone mass, not the whole tank. Integrating half-tank mass through cycles that have already destratified was a real bug. This is the single highest-value detail to carry across; the stratification_gap input it needs must come with it.
Do not ship a scalar gain on the physics estimate. It buys 0.7 min of mean error and costs 7.6 min of bias and 10 min of p95. Physics alone is already centred (−3.6 min); the gain de-centres it. A band-localised correction in the 35–45 °F band is where the remaining error lives, but at ~300 of ~1,900 rows that is near the label-noise floor and not worth a dependency.
Field mapping
Verified against c043f53 (v9.3.2) today — the source survey was written against 9.3.0 / 90a2c87 and the repo has moved since, so this supersedes it:
| Estimator needs |
DeviceStatus field |
Note |
| tank lower temp |
tank_lower_temperature |
0.1 °C resolution |
| tank upper temp |
tank_upper_temperature |
|
| recovery threshold |
hp_lower_off_temp_setting |
live in every frame, not change-only |
| evaporator temp |
evaporator_temperature |
|
| tank charge deficit |
energy_to_setpoint |
see traps |
| ambient |
ambient_temperature |
measured at the HP air intake — this is better than a room sensor, it is what the evaporator actually sees |
| tank volume |
DeviceFeature.volume_code |
50/65/80 gal — do not hard-code |
Having hp_lower_off_temp_setting live in every status frame removes a whole retry-and-fall-back-to-a-corpus-mean path that exists downstream, and a setpoint-parity bug that came with it.
Traps
- The TOU recovery cap (documented here in
c043f53, docs/explanation/tou-recovery-cap.rst). Under an active TOU schedule the device stops a heat-pump recovery at ~90 % dhw_charge_per, leaving tank_lower_temperature ~1.9 °C below hp_lower_off_temp_setting, and nothing in the protocol announces it. An estimator that predicts the setpoint crossing will predict an instant that never arrives — roughly 18 % of cycles by design. This postdates the original feasibility survey and is the most important new consideration; the module should either predict the cap when TOU is active or report the target it actually solved for.
energy_to_setpoint is a deficit, not stored energy — despite the name it counts energy still needed, falling to zero at setpoint. Scale is WH_PER_ENERGY_COUNT = 4.0 Wh/count (measured 4.11, p10 3.47, p90 4.45 over 183 recoveries). It was 10.0 before, so historical values are 2.5× larger for the same physical state.
current_inst_power excludes heating-element power. Element wattage has to be inferred from heat_upper_use / heat_lower_use × 3,755 W @208 V or 5,000 W @240 V — and mains voltage is not reported.
- The calibration corpus must exclude element-assisted recoveries. Fitting compressor parameters on cycles that had resistance help contaminates them with a different heat source entirely.
Non-goals
- No XGBoost, scipy, numpy, pandas or sklearn. If the module ever needs one of those, the design was wrong.
- No Home Assistant entities, storage or recalibration scheduling — those belong in
ha_nwp500 (event recorder, weekly recalibration, time_to_heat / ready_at / recovery_confidence sensors). This issue is the library half only, and is useful on its own.
- No recirculation-pump heat-extraction term. That is site-specific plumbing, not device physics.
dhw-sensor-apps keeps its own service. It has training features, a site model and a service contract this module has no reason to carry. This is not a migration.
Rollout
The estimator degrades sensibly rather than needing a warm-up gate:
| Tier |
Available |
Basis |
Expected |
| 0 |
first status message |
energy_to_setpoint ÷ modelled HP power |
rough |
| 1 |
~1–2 weeks |
per-unit UA + compressor-constant fit from observed events |
≈44 min MAE, under-predicts |
| 2 |
~4–8 weeks |
fitted physics, fully calibrated |
≈36 min MAE, unbiased |
Tier 0 is plug-and-play; 1 and 2 self-improve with no user action.
The three fitted parameters (compressor_constant, evap_approach_f, and the UA law) are already fitted in the reference implementation, so refitting is not a precondition for a first cut.
References
Reference implementation, all in dhw-sensor-apps/time_to_heat/src/time_to_heat/: physics/r134a.py, physics/model.py (predict_physics_detailed), physics/calibration.py (the fitters), inference/decay.py, constants.py.
Analysis: docs/time-to-heat-in-home-assistant-2026-08.md (feasibility, §2 what ports / §7 where the pieces belong) and docs/time-to-heat-scalar-gain-2026-08.md (§5, the physics-alone result above).
The forecast-free profile this module implements is named in RFC-006 §4.4.3 of that repo, so it is a conformant implementation of an existing contract rather than a fork of one. What it gives up is stated there as a number: without a demand forecast it carries a bias on recoveries that are drawn through while heating.
What
A new
nwp500.thermalmodule that answers "when will the tank be hot?" fromDeviceStatus, with zero new runtime dependencies.Contents, roughly:
dT/dt = −(UA/mc_p)(T − T_amb)with the gap decaying asgap₀·exp(−h/τ)TimeToHeatEstimatorholding a serializable calibrationIt sits naturally beside
usable_energy, already a derivedcomputed_fieldonDeviceStatuswith a measured validation behind it. Deterministic and testable without Home Assistant.Why here
Every runtime input is already on the wire. This has been prototyped and run in production for ~400 days in a separate service (
dhw-sensor-apps/time_to_heat), but that service needs InfluxDB, XGBoost, scipy and a hand-built training corpus. None of that is required for most of the accuracy, which makes the estimator a much better fit for this library than for a service.Measured, on a 400-day / 274-event corpus:
1.9 minutes. And physics alone is better than the tree in the deepest-recovery band. No xgboost, no scipy, no pickled artifact, no wheel-availability question on HA OS or ARM.
Two findings that should shape the implementation
The two-zone mass model is not optional. It is worth 8.1 min of MAE and 26 min of bias — about four times the entire ML contribution. The condenser coil wraps the lower stratum, so the ODE integrates the lower zone mass, not the whole tank. Integrating half-tank mass through cycles that have already destratified was a real bug. This is the single highest-value detail to carry across; the
stratification_gapinput it needs must come with it.Do not ship a scalar gain on the physics estimate. It buys 0.7 min of mean error and costs 7.6 min of bias and 10 min of p95. Physics alone is already centred (−3.6 min); the gain de-centres it. A band-localised correction in the 35–45 °F band is where the remaining error lives, but at ~300 of ~1,900 rows that is near the label-noise floor and not worth a dependency.
Field mapping
Verified against
c043f53(v9.3.2) today — the source survey was written against 9.3.0 /90a2c87and the repo has moved since, so this supersedes it:DeviceStatusfieldtank_lower_temperaturetank_upper_temperaturehp_lower_off_temp_settingevaporator_temperatureenergy_to_setpointambient_temperatureDeviceFeature.volume_codeHaving
hp_lower_off_temp_settinglive in every status frame removes a whole retry-and-fall-back-to-a-corpus-mean path that exists downstream, and a setpoint-parity bug that came with it.Traps
c043f53,docs/explanation/tou-recovery-cap.rst). Under an active TOU schedule the device stops a heat-pump recovery at ~90 %dhw_charge_per, leavingtank_lower_temperature~1.9 °C belowhp_lower_off_temp_setting, and nothing in the protocol announces it. An estimator that predicts the setpoint crossing will predict an instant that never arrives — roughly 18 % of cycles by design. This postdates the original feasibility survey and is the most important new consideration; the module should either predict the cap when TOU is active or report the target it actually solved for.energy_to_setpointis a deficit, not stored energy — despite the name it counts energy still needed, falling to zero at setpoint. Scale isWH_PER_ENERGY_COUNT = 4.0Wh/count (measured 4.11, p10 3.47, p90 4.45 over 183 recoveries). It was 10.0 before, so historical values are 2.5× larger for the same physical state.current_inst_powerexcludes heating-element power. Element wattage has to be inferred fromheat_upper_use/heat_lower_use× 3,755 W @208 V or 5,000 W @240 V — and mains voltage is not reported.Non-goals
ha_nwp500(event recorder, weekly recalibration,time_to_heat/ready_at/recovery_confidencesensors). This issue is the library half only, and is useful on its own.dhw-sensor-appskeeps its own service. It has training features, a site model and a service contract this module has no reason to carry. This is not a migration.Rollout
The estimator degrades sensibly rather than needing a warm-up gate:
energy_to_setpoint÷ modelled HP powerTier 0 is plug-and-play; 1 and 2 self-improve with no user action.
The three fitted parameters (
compressor_constant,evap_approach_f, and the UA law) are already fitted in the reference implementation, so refitting is not a precondition for a first cut.References
Reference implementation, all in
dhw-sensor-apps/time_to_heat/src/time_to_heat/:physics/r134a.py,physics/model.py(predict_physics_detailed),physics/calibration.py(the fitters),inference/decay.py,constants.py.Analysis:
docs/time-to-heat-in-home-assistant-2026-08.md(feasibility, §2 what ports / §7 where the pieces belong) anddocs/time-to-heat-scalar-gain-2026-08.md(§5, the physics-alone result above).The forecast-free profile this module implements is named in RFC-006 §4.4.3 of that repo, so it is a conformant implementation of an existing contract rather than a fork of one. What it gives up is stated there as a number: without a demand forecast it carries a bias on recoveries that are drawn through while heating.