Skip to content

Add a pure-Python thermal module: recovery-time estimation from device state #133

Description

@eman

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions