From fd8cbd76940ba6d1f16fedcf432ec1fb766c4c63 Mon Sep 17 00:00:00 2001 From: emmanuel Date: Sun, 2 Aug 2026 18:19:04 -0700 Subject: [PATCH 1/6] Correct tank energy scale and reversed field semantics The two tank energy fields were wrong in two independent ways, and the published docs described them three mutually incompatible ways. Unit scale. totalEnergyCapacity/availableEnergyCapacity were multiplied by 10 on the assumption the device reports 10 Wh units. It reports about 4 Wh per count. Measured across 183 heating recoveries by comparing the device's reported change against the tank's sensible-heat gain from its thermistors, which is independent of heat pump efficiency: 4.11 Wh/count, p10 3.47, p90 4.45. The library now uses 4.0. The old scale is refuted without any physical assumption: integrating current_inst_power over the same recoveries, the reported energy gain divided by electrical input gives a COP of 7.02, which no heat pump can achieve. At 4 Wh/count it is 2.89, an ordinary figure. That argument needs no tank volume, specific heat or mixing model. Reported tank energy is now 2.5x smaller. Series logged from earlier versions need rescaling by 0.4 to compare. Semantics. availableEnergyCapacity is not available energy; it is the energy still needed to reach the setpoint. It falls as the tank heats and reaches zero when fully charged, so consumers treating it as stored energy had the signal inverted (regression against mean tank temperature: negative slope, R-squared 0.93, zero crossing at the setpoint). totalEnergyCapacity is not a fixed tank size but the cost of a full recovery to the current setpoint, measured from the device minimum setpoint; it moves ~143 Wh per 0.5 degC of setpoint change. total_energy_capacity -> full_recovery_energy available_energy_capacity -> energy_to_setpoint Old names are removed rather than aliased, per the project's backward compatibility policy. A missed rename fails with AttributeError instead of silently returning a value 2.5x too large. Wire field names are unchanged. converters.mul_10 is removed. Docs. The published docs claimed Wh with no conversion (protocol reference), Wh with a x10 scale (the code), and a 0-100 percentage (track-energy, models, history) - the last was never true of any version. Also corrects four field names in track-energy.rst that do not exist on DeviceStatus, and documents that dhwTemperature is measured inside the tank rather than in the outlet pipe despite its name. Note the 4 Wh quantum was calibrated on a single 65-gallon unit and is applied to all volume codes. That is correct if the quantum is a fixed firmware constant, which is the natural design, but is unverified for the 50 and 80 gallon variants. Co-Authored-By: Claude Opus 5 --- CHANGELOG.rst | 75 +++++++ docs/explanation/index.rst | 1 + docs/explanation/tank-energy.rst | 208 +++++++++++++++++++ docs/how-to/manage-units.rst | 4 +- docs/how-to/track-energy.rst | 74 ++++--- docs/project/history.rst | 2 +- docs/reference/protocol/data_conversions.rst | 20 +- docs/reference/protocol/device_status.rst | 10 +- docs/reference/python_api/models.rst | 14 +- examples/intermediate/periodic_requests.py | 2 +- src/nwp500/cli/presentation.py | 8 +- src/nwp500/converters.py | 47 +++-- src/nwp500/models/__init__.py | 4 +- src/nwp500/models/status.py | 34 ++- tests/conftest.py | 6 +- tests/test_model_converters.py | 107 +++++----- tests/test_models.py | 38 ++++ 17 files changed, 522 insertions(+), 132 deletions(-) create mode 100644 docs/explanation/tank-energy.rst diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 244c7848..cb3f9a65 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,81 @@ Changelog Unreleased ========== +**BREAKING CHANGES**: tank energy values were wrong in two independent +ways - a 2.5x unit-scale error and two actively misleading field names - +and both are corrected. Reported tank energy is now 2.5x smaller and two +public field names are removed. + +Changed +------- +- **Energy unit scale corrected.** ``totalEnergyCapacity`` and ``availableEnergyCapacity`` were + scaled by 10 on the assumption the device reported 10 Wh units. It does + not. Measuring across 183 heating recoveries on a 65-gallon NWP500 - + comparing the device's reported change against the tank's sensible-heat + gain from its two thermistors, a comparison independent of heat-pump + efficiency - gives a quantum of 4.11 Wh/count (p10 3.47, p90 4.45). The + library now uses 4.0. An independent cross-check settles it: integrating + ``currentInstPower`` over the same recoveries, the corrected scale + implies a heat-pump COP of 2.89, while the old scale implied 7.02, which + is physically impossible. **Reported tank energy is now 2.5x smaller.** + Historical series logged from earlier versions need rescaling by 0.4 to + be comparable. + +- **Energy fields renamed.** + ``availableEnergyCapacity`` is not available energy - it is the energy + still *needed* to reach the setpoint. It falls as the tank heats and + reaches zero when the tank is fully charged, so code treating it as + stored energy had the signal backwards (regression against mean tank + temperature: negative slope, R-squared 0.93, zero crossing at the + setpoint). Likewise ``totalEnergyCapacity`` is not a fixed tank size but + the cost of a full recovery to the *current setpoint*, measured from the + device's own minimum setpoint of 104.9 degF; it moves by about 143 Wh + per 0.5 degC of setpoint change. + + .. code-block:: python + + # OLD (removed) + status.total_energy_capacity # 15800.0 + status.available_energy_capacity # 11660.0 + + # NEW + status.full_recovery_energy # 6320.0 + status.energy_to_setpoint # 4664.0 + + The protocol field names on the wire are unchanged. CLI rows are + relabelled from "Total Capacity"/"Available Capacity" to + "Full Recovery"/"Energy to Setpoint". + +Removed +------- +- **Misnamed energy fields**: removed ``DeviceStatus.total_energy_capacity`` + and ``DeviceStatus.available_energy_capacity`` outright rather than + aliasing them, so a missed rename fails with ``AttributeError`` instead + of silently returning a number 2.5x too large. Use + ``full_recovery_energy`` and ``energy_to_setpoint``. + +- **Incorrect converter**: removed ``converters.mul_10``, which existed + only to apply the wrong 10 Wh scale. Use + ``converters.energy_count_to_wh`` and ``converters.WH_PER_ENERGY_COUNT``. + +Fixed +----- +- **Documentation contradicted itself and the code on energy capacity.** + Three incompatible descriptions were published: Watt-hours with no + conversion (protocol reference), Watt-hours with a x10 scale (the code), + and a 0-100 percentage (``how-to/track-energy.rst``, + ``reference/python_api/models.rst``, ``project/history.rst``). The + percentage claim was never true of any library version. All are now + consistent. +- ``how-to/track-energy.rst`` documented four fields that do not exist on + ``DeviceStatus`` (``dhw_tank_upper_temp``, ``dhw_tank_lower_temp``, + ``comp_temp``, ``dhw_heatex_out_temp``); replaced with the real names. +- ``dhwTemperature`` is documented as an outlet temperature but is + measured inside the tank: it tracks ``tankUpperTemperature`` to within + one 0.5 degC step, and the device has no sensor downstream of itself. +- New ``docs/explanation/tank-energy.rst`` records what the two fields + actually measure and the calibration evidence behind the scale change. + Version 9.2.1 (2026-07-30) ========================== diff --git a/docs/explanation/index.rst b/docs/explanation/index.rst index f1fe4c9c..a04ab752 100644 --- a/docs/explanation/index.rst +++ b/docs/explanation/index.rst @@ -9,3 +9,4 @@ Understanding-oriented deep dives into the library's design and advanced feature advanced-features architecture + tank-energy diff --git a/docs/explanation/tank-energy.rst b/docs/explanation/tank-energy.rst new file mode 100644 index 00000000..65baac41 --- /dev/null +++ b/docs/explanation/tank-energy.rst @@ -0,0 +1,208 @@ +=========== +Tank Energy +=========== + +The NWP500 reports two energy figures, ``totalEnergyCapacity`` and +``availableEnergyCapacity``. Both names mislead, and before v10.0 this +library also scaled them wrongly. This page explains what they +actually measure and shows the evidence for the correction. + +.. contents:: + :local: + :depth: 2 + + +The short version +================= + +.. list-table:: + :header-rows: 1 + :widths: 22 30 48 + + * - Protocol field + - What the name suggests + - What it actually is + * - ``availableEnergyCapacity`` + - Energy available in the tank + - Energy still **needed** to reach the setpoint. It *falls* as the + tank heats and hits zero when the tank is fully charged - the + exact inverse of the name. + * - ``totalEnergyCapacity`` + - Fixed tank capacity + - Cost of a full recovery to the **current setpoint**. It moves + whenever the setpoint moves. + +Both are raw counts of about 4 Wh each, not Watt-hours. Library versions +before 10.0 multiplied by 10, overstating tank energy by 2.43x. + +Neither field tells you how much hot water you can actually draw. That +depends on tank volume, the inlet temperature and the temperature you +want water delivered at, none of which the device reports. + + +How the fields behave +===================== + +Both fields fit a single two-parameter model: + +.. code:: text + + energy_to_setpoint = k * (setpoint - tank_mean_temperature) + full_recovery_energy = k * (setpoint - reference_temperature) + +where ``reference_temperature`` is the device's own minimum setpoint, +``dhwTemperatureMin`` (40.5 degC / 104.9 degF), and ``k`` is the tank's +heat capacity. + +Two consequences follow, and both matter: + +* ``energy_to_setpoint`` is a **deficit**. Code that treats it as stored + energy has the signal backwards: it is largest when the tank is + coldest. +* ``full_recovery_energy`` is **not a constant**. Raising the setpoint by + 0.5 degC raises it by about 143 Wh on a 65-gallon tank. Seven months of + history on one device shows sixteen distinct values as the setpoint was + adjusted. + + +The evidence +============ + +Deficit, not stored energy +-------------------------- + +During a heating recovery on a 65-gallon unit at a 140.9 degF setpoint, +with the tank warming and no draws: + +.. list-table:: + :header-rows: 1 + + * - Mean tank temp + - Setpoint minus tank + - ``availableEnergyCapacity`` + - ``dhwChargePer`` + * - 119.5 degF + - 21.4 degF + - 8816 + - 54.5 % + * - 127.4 degF + - 13.5 degF + - 5300 + - 69.5 % + * - 135.1 degF + - 5.8 degF + - 2275 + - 85.7 % + +The field falls as the tank fills with heat. Regressed against mean tank +temperature over two weeks of five-minute samples, the slope is negative +with an R-squared of 0.93 and a zero crossing at the setpoint. + +The 4 Wh quantum +---------------- + +The quantum was measured across **183 independent heating recoveries**. +For each, the tank's sensible-heat gain was computed from the two +thermistors and the known tank mass: + +.. code:: text + + gain_Wh = mass_kg * 4.186 kJ/kg/K * temperature_rise_K / 3.6 + +and divided by the device's own reported change. This comparison is +independent of the heat pump's efficiency, because it never uses +electrical input. + +.. list-table:: + :header-rows: 1 + + * - Quantity + - Median + - p10 + - p90 + * - Wh per raw count + - 4.11 + - 3.47 + - 4.45 + +The library uses **4.0**. The 2.8 % gap is consistent with the tank +holding slightly less than its nominal 65 gallons, which is normal. + +The efficiency cross-check +-------------------------- + +An independent check settles it. Integrating ``currentInstPower`` over +each recovery gives the electrical energy in, and dividing the tank's +heat gain by it gives the heat pump's coefficient of performance: + +.. list-table:: + :header-rows: 1 + + * - Scale used + - Implied COP (median) + - Verdict + * - 4 Wh/count (corrected) + - 2.89 + - Normal for a heat pump water heater + * - 10 Wh/count (pre-10.0) + - 7.02 + - Physically impossible + +A heat pump water heater in a 72 degF room runs at a COP of roughly 2 to +4. A COP of 7 would mean the device generated energy it never consumed. + +The reference temperature +------------------------- + +Solving ``reference = setpoint - full_recovery_energy / k`` across the +same history gives a median of 104.5 degF, matching the device's +``dhwTemperatureMin`` of 104.9 degF. The device measures a full recovery +from its own minimum setpoint, not from the cold water inlet. + + +What ``dhwChargePer`` does +========================== + +``dhwChargePer`` is a fourth signal and does not reconcile with the other +two. On one device, ``energy_to_setpoint / full_recovery_energy`` +implies 30 % charged while ``dhwChargePer`` reads 59 %; over two weeks +the two differ by a mean of 48 points with 32 points of scatter. + +Treat it as an opaque vendor heuristic rather than a defined fraction of +anything. + + +Migrating from before v10.0 +=========================== + +.. list-table:: + :header-rows: 1 + :widths: 40 60 + + * - Old + - New + * - ``status.total_energy_capacity`` (removed) + - ``status.full_recovery_energy`` - and the value is 2.5x smaller + * - ``status.available_energy_capacity`` (removed) + - ``status.energy_to_setpoint`` - 2.5x smaller, and note it was + never "available" + * - Treating the value as available energy + - It is a deficit; invert the logic. ``full_recovery_energy - + energy_to_setpoint`` is the energy added above the device's + reference temperature + * - Treating the value as a percentage + - It never was one + +The old attribute names are removed rather than aliased, so a +rename that is missed fails immediately with ``AttributeError`` instead +of silently returning a number 2.5x too large. + +If you logged these values historically, the stored series needs +rescaling by 0.4 to be comparable with values from v10.0 onward. + + +See also +======== + +- :doc:`../how-to/track-energy` - Monitoring energy and power +- :doc:`../reference/protocol/data_conversions` - Protocol field conversions diff --git a/docs/how-to/manage-units.rst b/docs/how-to/manage-units.rst index eec13f65..6dd038f1 100644 --- a/docs/how-to/manage-units.rst +++ b/docs/how-to/manage-units.rst @@ -215,8 +215,8 @@ The following fields have universal units that don't need conversion: **Electrical** - ``current_inst_power`` (Watts) -- ``total_energy_capacity`` (Watt-hours) -- ``available_energy_capacity`` (Watt-hours) +- ``full_recovery_energy`` (Watt-hours) +- ``energy_to_setpoint`` (Watt-hours) **Mechanical/Signal** diff --git a/docs/how-to/track-energy.rst b/docs/how-to/track-energy.rst index facd2e4d..becf9d6b 100644 --- a/docs/how-to/track-energy.rst +++ b/docs/how-to/track-energy.rst @@ -123,24 +123,35 @@ Request detailed daily energy usage data for specific months: Energy Capacity --------------- -Monitor available stored energy: +.. warning:: + The device's two energy fields do **not** report stored energy, and + the names in the protocol are misleading. See + :doc:`../explanation/tank-energy` for the evidence. + +Monitor the tank's heating deficit: .. code:: python def on_status(status: DeviceStatus): - capacity = status.available_energy_capacity - print(f"Energy Capacity: {capacity}%") - - if capacity < 20: - print("Low energy - heating may be needed") - elif capacity > 80: - print("High energy - tank is hot") + needed = status.energy_to_setpoint + print(f"Energy still needed: {needed:.0f} Wh") + + if needed < 200: + print("Tank is essentially fully charged") -| **Field:** ``available_energy_capacity`` -| **Type:** ``int`` -| **Units:** Percentage (0-100) -| **Description:** Available energy in the tank as a percentage, - indicating how much hot water is available. +| **Field:** ``energy_to_setpoint`` (protocol: ``availableEnergyCapacity``) +| **Type:** ``float`` +| **Units:** Watt-hours +| **Description:** Energy the device still has to add to reach the + setpoint. It *falls* as the tank heats and reaches zero when charged. + Despite the protocol name, it is the inverse of available energy. + +| **Field:** ``full_recovery_energy`` (protocol: ``totalEnergyCapacity``) +| **Type:** ``float`` +| **Units:** Watt-hours +| **Description:** Cost of a full recovery to the *current setpoint*, + measured from the device reference temperature of 104.9 degF. It moves + with the setpoint, so it is not a fixed tank capacity. Temperature Monitoring ---------------------- @@ -172,10 +183,10 @@ Monitor individual heating component temperatures: .. code:: python def on_status(status: DeviceStatus): - print(f"Compressor Temp: {status.comp_temp}°F") - print(f"Upper Tank Temp: {status.dhw_tank_upper_temp}°F") - print(f"Lower Tank Temp: {status.dhw_tank_lower_temp}°F") - print(f"Heat Exchanger Out: {status.dhw_heatex_out_temp}°F") + print(f"Discharge Temp: {status.discharge_temperature}°F") + print(f"Upper Tank Temp: {status.tank_upper_temperature}°F") + print(f"Lower Tank Temp: {status.tank_lower_temperature}°F") + print(f"Ambient Temp: {status.ambient_temperature}°F") Complete Energy Monitoring Example ---------------------------------- @@ -228,8 +239,8 @@ Complete Energy Monitoring Example print(f" Upper Heater: {status.heater1_running_minute_total / 60:.1f} hours") print(f" Lower Heater: {status.heater2_running_minute_total / 60:.1f} hours") - # Energy capacity and temperature - print(f"\nEnergy Capacity: {status.available_energy_capacity}%") + # Heating deficit and temperature + print(f"\nEnergy to setpoint: {status.energy_to_setpoint:.0f} Wh") print(f"Water Temp: {status.dhw_temperature}°F " f"(Target: {status.dhw_temperature_setting}°F)") @@ -295,11 +306,13 @@ Cumulative Usage Energy Capacity ~~~~~~~~~~~~~~~ -=============================== ==== ===== ========================= -Field Type Units Description -=============================== ==== ===== ========================= -``available_energy_capacity`` int % Available energy (0-100%) -=============================== ==== ===== ========================= +========================== ===== ===== ========================================== +Field Type Units Description +========================== ===== ===== ========================================== +``energy_to_setpoint`` float Wh Energy still **needed** to reach setpoint +``full_recovery_energy`` float Wh Cost of a full recovery to the setpoint +``dhw_charge_per`` float % Device's own charge estimate +========================== ===== ===== ========================================== Temperature ~~~~~~~~~~~ @@ -308,11 +321,11 @@ Temperature Field Type Units Description ============================== ===== ===== ================================= ``dhw_temperature`` float °F Current water temperature -``dhw_temperature_setting`` int °F Target temperature setting -``comp_temp`` float °F Heat pump compressor temperature -``dhw_tank_upper_temp`` float °F Upper tank temperature -``dhw_tank_lower_temp`` float °F Lower tank temperature -``dhw_heatex_out_temp`` float °F Heat exchanger outlet temperature +``dhw_temperature_setting`` float °F Target temperature setting +``discharge_temperature`` float °F Compressor discharge temperature +``tank_upper_temperature`` float °F Upper tank temperature +``tank_lower_temperature`` float °F Lower tank temperature +``ambient_temperature`` float °F Air intake temperature ============================== ===== ===== ================================= Notes @@ -323,11 +336,12 @@ Notes - Status updates are sent automatically by the device approximately every few seconds - Cumulative runtime values persist across device power cycles -- Energy capacity calculation is based on temperature and usage patterns +- The device's energy fields are heating *deficits*, not stored energy See Also -------- +- :doc:`../explanation/tank-energy` - What the energy fields really mean - :doc:`../reference/protocol/device_status` - Complete list of all status fields - :doc:`../reference/python_api/mqtt_client` - How to connect and subscribe to device updates - :doc:`../reference/protocol/mqtt_protocol` - Message format reference diff --git a/docs/project/history.rst b/docs/project/history.rst index 5255886e..dfc74d0e 100644 --- a/docs/project/history.rst +++ b/docs/project/history.rst @@ -129,7 +129,7 @@ Complete energy monitoring capabilities including historical data: - ``heater2RunningMinuteTotal``: Lower electric heater runtime **Energy Capacity (DeviceStatus):** -- ``availableEnergyCapacity``: Available energy percentage (0-100%) +- ``availableEnergyCapacity``: energy still needed to reach the setpoint, in Watt-hours (documented as a percentage at the time, which was wrong) - Heat pump and electric heater temperature thresholds **Historical Energy Usage (EMS API via MQTT):** diff --git a/docs/reference/protocol/data_conversions.rst b/docs/reference/protocol/data_conversions.rst index 5e4f631a..e5708caf 100644 --- a/docs/reference/protocol/data_conversions.rst +++ b/docs/reference/protocol/data_conversions.rst @@ -350,13 +350,23 @@ Power and Energy Fields - W - **Instantaneous power consumption**. Real-time measurement. Does **NOT** include electric heating element power draw. Heat pump only. * - ``totalEnergyCapacity`` - - None (direct value) + - ``x 4`` (see note) - Wh - - **Tank energy capacity** at full charge. Theoretical maximum heat content. Useful for recovery time estimation. + - **Cost of a full recovery** to the *current setpoint*, measured from the device reference temperature (``dhwTemperatureMin``, 104.9 degF). Exposed as ``full_recovery_energy``. This is **not** a fixed tank capacity: it moves with the setpoint, by about 143 Wh per 0.5 degC on a 65-gallon tank. * - ``availableEnergyCapacity`` - - None (direct value) + - ``x 4`` (see note) - Wh - - **Available energy in tank right now**. Indicates how much hot water capacity remains before next heating cycle. Lower value = lower DHW charge percentage. + - **Energy still needed to reach the setpoint** - a heating deficit, despite the name. Exposed as ``energy_to_setpoint``. It *falls* as the tank heats and reaches zero when the tank is fully charged, so it is the inverse of available energy. + +.. note:: + **Energy quantum.** The two energy fields are raw counts in a fixed + quantum, not Watt-hours. The quantum was measured at 4.11 Wh/count + (p10 3.47, p90 4.45) across 183 heating recoveries on a 65-gallon + NWP500, by comparing the device's reported change against the tank's + sensible-heat gain -- a comparison independent of the heat pump's + efficiency. The library uses 4.0. Versions before 10.0 used 10, which + overstated tank energy by 2.43x and implied a physically impossible + heat-pump COP of 7.0. See :doc:`../../explanation/tank-energy`. .. note:: ``currentInstPower`` excludes electric heating element power. If the heater is actively heating with electric elements, the actual power draw will be higher (typically +3755W @ 208V or +5000W @ 240V). @@ -633,7 +643,7 @@ Practical Applications of Conversions Understanding these conversions helps with: -1. **Energy Monitoring**: Combine ``totalEnergyCapacity``, ``availableEnergyCapacity``, and ``currentInstPower`` to estimate recovery times +1. **Energy Monitoring**: Combine ``availableEnergyCapacity`` (the remaining heating deficit) with ``currentInstPower`` to estimate recovery times. Note this is a deficit, not stored energy -- see :doc:`../../explanation/tank-energy` 2. **Efficiency Analysis**: Compare ``ambientTemperature`` against current COP (Coefficient of Performance) to verify expected efficiency 3. **Fault Diagnosis**: Monitor ``dischargeTemperature`` and ``currentSuperHeat`` for refrigerant circuit health 4. **Maintenance Scheduling**: Track ``airFilterAlarmElapsed`` and ``cumulatedOpTimeEvaFan`` for preventative maintenance diff --git a/docs/reference/protocol/device_status.rst b/docs/reference/protocol/device_status.rst index b03d25a4..753aedbb 100644 --- a/docs/reference/protocol/device_status.rst +++ b/docs/reference/protocol/device_status.rst @@ -76,7 +76,7 @@ This document lists the fields found in the ``status`` object of device status m * - ``dhwTemperature`` - integer - °F - - Current Domestic Hot Water (DHW) outlet temperature. + - Current DHW temperature. **Measured inside the tank, not in the outlet pipe** despite the "outlet" naming: it tracks ``tankUpperTemperature`` to within one 0.5 °C step, while ``tankLowerTemperature`` is uncorrelated. Prefer ``tankUpperTemperature``, which reports the same water at 0.1 °C resolution. The device has no sensor downstream of itself. - HalfCelsiusToF * - ``dhwTemperatureSetting`` - integer @@ -451,13 +451,13 @@ This document lists the fields found in the ``status`` object of device status m * - ``totalEnergyCapacity`` - integer - Wh - - Total energy capacity of the tank in Watt-hours. - - None + - Energy needed for a full recovery to the current setpoint, from the device reference temperature (104.9 degF). Tracks the setpoint rather than being a fixed tank size. Python name: ``full_recovery_energy``. + - ``x 4`` (energy quantum) * - ``availableEnergyCapacity`` - integer - Wh - - Available energy capacity - remaining hot water energy available in Watt-hours. - - None + - Energy still **needed** to reach the setpoint - a heating deficit, not available energy. Falls to zero as the tank charges. Python name: ``energy_to_setpoint``. + - ``x 4`` (energy quantum) DHW Operation Setting Modes ---------------------------- diff --git a/docs/reference/python_api/models.rst b/docs/reference/python_api/models.rst index 52c78e92..4dd8847a 100644 --- a/docs/reference/python_api/models.rst +++ b/docs/reference/python_api/models.rst @@ -202,10 +202,18 @@ Complete real-time device status with 100+ fields. **Power/Energy Fields:** * ``current_inst_power`` (float) - Current power consumption (Watts) - * ``total_energy_capacity`` (float) - Total energy capacity (%) - * ``available_energy_capacity`` (float) - Available energy (%) + * ``full_recovery_energy`` (float) - Energy needed to heat the whole tank from the device reference temperature to the current setpoint (Wh). Tracks the setpoint; not a fixed tank size. + * ``energy_to_setpoint`` (float) - Energy still **needed** to reach the setpoint (Wh). A deficit: it falls as the tank heats and reaches zero when fully charged. * ``dhw_charge_per`` (float) - DHW charge percentage + .. warning:: + These replace ``total_energy_capacity`` and + ``available_energy_capacity``, which were **removed** in v10.0. The + old names were misleading -- ``available_energy_capacity`` is the + energy *needed*, not the energy available -- and both were scaled + 2.5x too large. For energy you can actually draw, see + :doc:`../../explanation/tank-energy`. + **Operation Mode Fields:** * ``operation_mode`` (CurrentOperationMode) - Current operational state (read-only) @@ -276,7 +284,7 @@ Complete real-time device status with 100+ fields. # Power consumption print(f"Power: {status.current_inst_power}W") - print(f"Energy: {status.available_energy_capacity}%") + print(f"Energy still needed: {status.energy_to_setpoint} Wh") # Operation mode print(f"Mode: {status.dhw_operation_setting.name}") diff --git a/examples/intermediate/periodic_requests.py b/examples/intermediate/periodic_requests.py index a7b9ec32..fa61dfce 100755 --- a/examples/intermediate/periodic_requests.py +++ b/examples/intermediate/periodic_requests.py @@ -80,7 +80,7 @@ def on_device_status(status: DeviceStatus): print(f"\n--- Status Response #{status_count} ---") print(f" Temperature: {status.dhw_temperature:.1f}{unit}") print(f" Power: {status.current_inst_power:.1f}W") - print(f" Available Energy: {status.available_energy_capacity:.0f} Wh") + print(f" Energy to Setpoint: {status.energy_to_setpoint:.0f} Wh") def on_device_feature(feature: DeviceFeature): """Callback receives parsed DeviceFeature objects.""" diff --git a/src/nwp500/cli/presentation.py b/src/nwp500/cli/presentation.py index 8fcbf9dc..f2b9eff0 100644 --- a/src/nwp500/cli/presentation.py +++ b/src/nwp500/cli/presentation.py @@ -375,16 +375,16 @@ def build_device_status_rows(device_status: Any) -> list[StatusRow]: _add_numeric_item( all_items, device_status, - "total_energy_capacity", + "full_recovery_energy", "POWER & ENERGY", - "Total Capacity", + "Full Recovery", ) _add_numeric_item( all_items, device_status, - "available_energy_capacity", + "energy_to_setpoint", "POWER & ENERGY", - "Available Capacity", + "Energy to Setpoint", ) # Fan Control diff --git a/src/nwp500/converters.py b/src/nwp500/converters.py index 01dec4ee..2f47988d 100644 --- a/src/nwp500/converters.py +++ b/src/nwp500/converters.py @@ -15,10 +15,32 @@ "device_bool_from_python", "tou_override_to_python", "div_10", - "mul_10", + "energy_count_to_wh", + "WH_PER_ENERGY_COUNT", "enum_validator", ] +#: Watt-hours represented by one raw device energy count. +#: +#: The device reports ``totalEnergyCapacity`` and ``availableEnergyCapacity`` +#: as small integers in a fixed energy quantum. The quantum was determined +#: empirically from 183 heating recoveries on a 65-gallon NWP500 by comparing +#: the device's reported change against the tank's sensible-heat gain +#: (mass x specific heat x temperature rise), which is independent of the +#: heat pump's coefficient of performance: +#: +#: * measured quantum: 4.11 Wh/count (p10 3.47, p90 4.45) +#: * the 2.8% excess over 4.0 is consistent with the tank holding slightly +#: less than its nominal 65 gallons, as is typical +#: * cross-check: using 4 Wh/count the implied heat pump COP is 2.89 +#: (p10 2.24, p90 3.46), a normal figure for a HPWH +#: +#: Library versions before 10.0 used 10 Wh/count, which overstated tank +#: energy by 2.43x and implied a physically impossible COP of 7.0. +#: +#: See ``docs/explanation/tank-energy.rst`` for the full derivation. +WH_PER_ENERGY_COUNT = 4.0 + def device_bool_to_python(value: Any) -> bool: """Convert device boolean representation to Python bool. @@ -103,25 +125,26 @@ def div_10(value: Any) -> float: return float(value) / 10.0 -def mul_10(value: Any) -> float: - """Multiply numeric value by 10.0. +def energy_count_to_wh(value: Any) -> float: + """Convert a raw device energy count to Watt-hours. - Used for energy capacity fields where the device reports in 10Wh units, - but we want to store standard Wh. + The device reports tank energy in a fixed quantum of + :data:`WH_PER_ENERGY_COUNT` Watt-hours per count, not in Watt-hours + directly. See that constant for how the quantum was measured. Args: - value: Numeric value to multiply. + value: Raw device energy count. Returns: - Value multiplied by 10.0. + Energy in Watt-hours. Example: - >>> mul_10(150) - 1500.0 - >>> mul_10(25.5) - 255.0 + >>> energy_count_to_wh(1580) + 6320.0 + >>> energy_count_to_wh(0) + 0.0 """ - return float(value) * 10.0 + return float(value) * WH_PER_ENERGY_COUNT def enum_validator(enum_class: type[Any]) -> Callable[[Any], Any]: diff --git a/src/nwp500/models/__init__.py b/src/nwp500/models/__init__.py index 2311709e..54270557 100644 --- a/src/nwp500/models/__init__.py +++ b/src/nwp500/models/__init__.py @@ -41,7 +41,7 @@ DeviceBool, DeviceStatus, Div10, - TenWhToWh, + EnergyCountToWh, TouOverride, TouStatus, ) @@ -58,7 +58,7 @@ "DeviceBool", "CapabilityFlag", "Div10", - "TenWhToWh", + "EnergyCountToWh", "TouStatus", "TouOverride", "VolumeCodeField", diff --git a/src/nwp500/models/status.py b/src/nwp500/models/status.py index 1c231502..31fa8069 100644 --- a/src/nwp500/models/status.py +++ b/src/nwp500/models/status.py @@ -6,7 +6,7 @@ from ..converters import ( device_bool_to_python, div_10, - mul_10, + energy_count_to_wh, tou_override_to_python, ) from ..enums import ( @@ -30,7 +30,7 @@ DeviceBool = Annotated[bool, BeforeValidator(device_bool_to_python)] Div10 = Annotated[float, BeforeValidator(div_10)] -TenWhToWh = Annotated[float, BeforeValidator(mul_10)] +EnergyCountToWh = Annotated[float, BeforeValidator(energy_count_to_wh)] TouStatus = Annotated[bool, BeforeValidator(bool)] TouOverride = Annotated[bool, BeforeValidator(tou_override_to_python)] @@ -211,17 +211,29 @@ class DeviceStatus(NavienBaseModel): "False = device follows TOU schedule normally" ) ) - total_energy_capacity: TenWhToWh = Field( - description="Total energy capacity of the tank in Watt-hours", + full_recovery_energy: EnergyCountToWh = Field( + alias="totalEnergyCapacity", + description=( + "Energy required to heat the whole tank from the device's " + "reference temperature (dhw_temperature_min, 104.9 degF) up to " + "the current setpoint, in Watt-hours. This is NOT a fixed tank " + "size: it tracks the setpoint, rising about 143 Wh per 0.5 degC " + "of setpoint increase. Use it as the cost of a full recovery, " + "not as the tank's total heat content." + ), json_schema_extra={ "unit_of_measurement": "Wh", "device_class": "energy", }, ) - available_energy_capacity: TenWhToWh = Field( + energy_to_setpoint: EnergyCountToWh = Field( + alias="availableEnergyCapacity", description=( - "Available energy capacity - " - "remaining hot water energy available in Watt-hours" + "Energy still NEEDED to bring the tank up to the setpoint, in " + "Watt-hours - a heating deficit, not stored energy. It falls as " + "the tank heats and reaches zero at the setpoint. Despite the " + "protocol name 'availableEnergyCapacity' it is the inverse of " + "available energy." ), json_schema_extra={ "unit_of_measurement": "Wh", @@ -383,7 +395,13 @@ class DeviceStatus(NavienBaseModel): # Raw temperature, flow, and volume fields dhw_temperature_raw: int = temperature_field( - "Current Domestic Hot Water (DHW) outlet temperature", + "Current DHW temperature. Despite the protocol calling this an " + "'outlet' temperature it is measured inside the tank, not in the " + "outlet pipe: it tracks tank_upper_temperature to within one " + "0.5 degC step, while tank_lower_temperature is uncorrelated. " + "Prefer tank_upper_temperature, which reports the same water at " + "0.1 degC resolution. The device has no sensor downstream of " + "itself, so this cannot measure water leaving the appliance", alias="dhwTemperature", ) dhw_temperature_setting_raw: int = temperature_field( diff --git a/tests/conftest.py b/tests/conftest.py index 81144a09..0be264d1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -38,8 +38,10 @@ def device_status_dict() -> dict[str, Any]: "touStatus": False, "drOverrideStatus": 0, "touOverrideStatus": False, - "totalEnergyCapacity": 0.0, - "availableEnergyCapacity": 0.0, + # Raw device energy counts, not Watt-hours: 1580 counts x 4 Wh + # = 6320 Wh full recovery, 1166 counts = 4664 Wh still needed. + "totalEnergyCapacity": 1580.0, + "availableEnergyCapacity": 1166.0, "recircOperationMode": 0, "recircPumpOperationStatus": 0, "recircHotBtnReady": 0, diff --git a/tests/test_model_converters.py b/tests/test_model_converters.py index 706718d8..2a0cc019 100644 --- a/tests/test_model_converters.py +++ b/tests/test_model_converters.py @@ -12,10 +12,11 @@ import pytest from nwp500.converters import ( + WH_PER_ENERGY_COUNT, device_bool_to_python, div_10, + energy_count_to_wh, enum_validator, - mul_10, tou_override_to_python, ) from nwp500.enums import DhwOperationSetting, OnOffFlag @@ -242,75 +243,67 @@ def test_known_values(self, input_value, expected): assert result == pytest.approx(expected, abs=0.001) -class TestMul10Converter: - """Test mul_10 converter (multiply by 10). +class TestEnergyCountToWh: + """Test energy_count_to_wh converter. - Used for energy capacity fields where the device reports in 10Wh units, - but we want to store standard Wh. - Multiplies all input types (converts to float first if needed). + The device reports tank energy in a fixed quantum of + WH_PER_ENERGY_COUNT Watt-hours per raw count, measured empirically at + 4.11 Wh/count across 183 heating recoveries on a 65-gallon NWP500. + Versions before 10.0 used 10 Wh/count, overstating energy by 2.43x. """ def test_zero(self): - """0 * 10 = 0.0.""" - assert mul_10(0) == 0.0 + """0 counts is 0 Wh.""" + assert energy_count_to_wh(0) == 0.0 def test_positive_value(self): - """100 * 10 = 1000.0.""" - assert mul_10(100) == 1000.0 - - def test_negative_value(self): - """-50 * 10 = -500.0.""" - assert mul_10(-50) == -500.0 - - def test_single_digit(self): - """5 * 10 = 50.0.""" - assert mul_10(5) == 50.0 + """100 counts at 4 Wh each is 400 Wh.""" + assert energy_count_to_wh(100) == 100 * WH_PER_ENERGY_COUNT def test_float_input(self): - """50.5 * 10 = 505.0.""" - assert mul_10(50.5) == 505.0 + """Fractional counts scale linearly.""" + assert energy_count_to_wh(50.5) == pytest.approx( + 50.5 * WH_PER_ENERGY_COUNT + ) def test_string_numeric(self): - """String '100' is converted to float and multiplied.""" - result = mul_10("100") - assert result == pytest.approx(1000.0) - - def test_energy_capacity_example(self): - """Test with realistic energy capacity values from issue #70.""" - # Device reports 1404.0 (10Wh units), should convert to 14040.0 Wh - device_value = 1404.0 - expected_wh = 14040.0 - assert mul_10(device_value) == expected_wh - - def test_large_value(self): - """1000 * 10 = 10000.0.""" - assert mul_10(1000) == 10000.0 - - def test_very_small_value(self): - """0.1 * 10 = 1.0.""" - assert mul_10(0.1) == 1.0 - - def test_negative_small_value(self): - """-0.5 * 10 = -5.0.""" - assert mul_10(-0.5) == -5.0 + """String input is converted to float first.""" + assert energy_count_to_wh("100") == pytest.approx( + 100 * WH_PER_ENERGY_COUNT + ) + + def test_observed_full_recovery(self): + """A real reading: 1580 counts at a 145.4 degF setpoint. + + The tank is 65 gallons and the device's reference temperature is + dhw_temperature_min (104.9 degF), so a full recovery spans 22.5 K: + 246.05 kg * 4.186 kJ/kg/K * 22.5 K = 23170 kJ = 6436 Wh. + """ + assert energy_count_to_wh(1580) == pytest.approx(6320.0) + + def test_quantum_matches_physics(self): + """The quantum must stay within measurement scatter of 4.11 Wh. + + Guards against a regression to the pre-10.0 value of 10, which + implied a heat-pump COP of 7.0. + """ + assert 3.4 < WH_PER_ENERGY_COUNT < 4.5 + + def test_linearity(self): + """Conversion is linear, so sums are preserved.""" + assert energy_count_to_wh(300) == pytest.approx( + energy_count_to_wh(100) + energy_count_to_wh(200) + ) @pytest.mark.parametrize( - "input_value,expected", - [ - (0, 0.0), - (10, 100.0), - (50, 500.0), - (100, 1000.0), - (1000, 10000.0), - (-100, -1000.0), - (1.5, 15.0), - (99.9, 999.0), - ], + "counts", + [0, 1, 10, 250, 1166, 1580, 1685], ) - def test_known_values(self, input_value, expected): - """Test known mul_10 conversions for numeric types.""" - result = mul_10(input_value) - assert result == pytest.approx(expected, abs=0.001) + def test_known_values(self, counts): + """Raw counts scale by exactly the documented quantum.""" + assert energy_count_to_wh(counts) == pytest.approx( + counts * WH_PER_ENERGY_COUNT, abs=0.001 + ) class TestEnumValidator: diff --git a/tests/test_models.py b/tests/test_models.py index 3e097bf7..183a217f 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -273,3 +273,41 @@ def test_skips_empty_entries(self): ) assert len(schedule.reservation) == 1 assert schedule.reservation[0].week == 62 + + +class TestEnergyFields: + """The energy capacity fields were misnamed and mis-scaled. + + See docs/explanation/tank-energy.rst: availableEnergyCapacity is a + heating deficit, not available energy, and the device quantum is + 4 Wh/count rather than the 10 Wh assumed before 10.0. + """ + + def test_wire_aliases_still_parse(self, device_status_dict): + """The protocol field names are unchanged on the wire.""" + status = DeviceStatus(**device_status_dict) + assert status.full_recovery_energy == pytest.approx(6320.0) + assert status.energy_to_setpoint == pytest.approx(4664.0) + + def test_old_names_are_gone(self, device_status_dict): + """The misleading names are removed, not aliased. + + Per the project's backward compatibility policy, renamed fields + are removed outright rather than kept as shims. + """ + status = DeviceStatus(**device_status_dict) + assert not hasattr(status, "total_energy_capacity") + assert not hasattr(status, "available_energy_capacity") + + def test_deficit_is_smaller_than_full_recovery(self, device_status_dict): + """A partly charged tank needs less than a full recovery costs.""" + status = DeviceStatus(**device_status_dict) + assert status.energy_to_setpoint < status.full_recovery_energy + + def test_protocol_dump_uses_wire_names(self, device_status_dict): + """Renaming the Python fields must not change what is sent.""" + status = DeviceStatus(**device_status_dict) + dumped = status.to_protocol_dict() + assert "totalEnergyCapacity" in dumped + assert "availableEnergyCapacity" in dumped + assert "full_recovery_energy" not in dumped From 5bc83c8ce3bf0c6df3ed73d6b359f3574e490d81 Mon Sep 17 00:00:00 2001 From: emmanuel Date: Sun, 2 Aug 2026 18:47:05 -0700 Subject: [PATCH 2/6] Document the quantum from the setpoint slope, not per-recovery gains The 4 Wh quantum was justified by dividing per-recovery thermistor heat gains by the device's reported change, which gave 4.11 Wh/count with a p10-p90 spread of 3.47-4.45. That route depends on (upper + lower) / 2 approximating the true mean tank temperature, which is the weakest assumption available. totalEnergyCapacity is a whole-tank quantity, so regressing it against the setpoint measures the same thing with no stratification assumption at all: 70.25 raw counts per Kelvin, R-squared 0.99999 over ten setpoints. Converting that to Watt-hours still needs a water mass, so rather than assume nominal volume and derive an odd quantum, the docs now assume the quantum is round - as every other conversion in this protocol is - and show that 4 Wh/count is the only candidate implying a water volume below the nameplate. Also promotes the reference-temperature result, which was previously derived through the quantum and so was partly circular. Extrapolating the setpoint regression to zero gives 104.92 degF against the device's dhwTemperatureMin of 104.9 degF, using only the device's own two numbers. That is the strongest evidence on the page and was buried. Demotes the COP cross-check to what it actually establishes: it rules out the pre-10.0 scale without any physical assumption, but cannot identify the quantum. No functional change - WH_PER_ENERGY_COUNT stays 4.0, which all three methods support. Co-Authored-By: Claude Opus 5 --- CHANGELOG.rst | 20 +++-- docs/explanation/tank-energy.rst | 129 ++++++++++++++++++++++++------- src/nwp500/converters.py | 27 ++++--- 3 files changed, 130 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index cb3f9a65..c91a6eb9 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,14 +14,18 @@ Changed ------- - **Energy unit scale corrected.** ``totalEnergyCapacity`` and ``availableEnergyCapacity`` were scaled by 10 on the assumption the device reported 10 Wh units. It does - not. Measuring across 183 heating recoveries on a 65-gallon NWP500 - - comparing the device's reported change against the tank's sensible-heat - gain from its two thermistors, a comparison independent of heat-pump - efficiency - gives a quantum of 4.11 Wh/count (p10 3.47, p90 4.45). The - library now uses 4.0. An independent cross-check settles it: integrating - ``currentInstPower`` over the same recoveries, the corrected scale - implies a heat-pump COP of 2.89, while the old scale implied 7.02, which - is physically impossible. **Reported tank energy is now 2.5x smaller.** + not. Because ``totalEnergyCapacity`` is a whole-tank quantity, its slope + against the setpoint measures the quantum with no stratification + assumption: on a 65-gallon NWP500 that is 70.25 raw counts per Kelvin + (R-squared 0.99999 over ten setpoints). Converting to Watt-hours needs a + water mass, and a "65 gallon" tank does not hold 65 gallons - so taking + the quantum to be round, as every other conversion in this protocol is, + 4 Wh/count is the only candidate implying a water volume below the + nameplate (241.7 L). Two further checks agree: 183 individual heating + recoveries give 4.11 Wh/count by a noisier route, and integrating + ``currentInstPower`` over them implies a heat-pump COP of 2.89 at the new + scale against 7.02 at the old, the latter being physically impossible. + **Reported tank energy is now 2.5x smaller.** Historical series logged from earlier versions need rescaling by 0.4 to be comparable. diff --git a/docs/explanation/tank-energy.rst b/docs/explanation/tank-energy.rst index 65baac41..294d6503 100644 --- a/docs/explanation/tank-energy.rst +++ b/docs/explanation/tank-energy.rst @@ -60,7 +60,7 @@ Two consequences follow, and both matter: energy has the signal backwards: it is largest when the tank is coldest. * ``full_recovery_energy`` is **not a constant**. Raising the setpoint by - 0.5 degC raises it by about 143 Wh on a 65-gallon tank. Seven months of + 0.5 degC raises it by 35 counts, about 140 Wh, on a 65-gallon tank. Seven months of history on one device shows sixteen distinct values as the setpoint was adjusted. @@ -101,39 +101,87 @@ with an R-squared of 0.93 and a zero crossing at the setpoint. The 4 Wh quantum ---------------- -The quantum was measured across **183 independent heating recoveries**. -For each, the tank's sensible-heat gain was computed from the two -thermistors and the known tank mass: +``totalEnergyCapacity`` is a whole-tank quantity, so its slope against +the setpoint measures the quantum without needing any assumption about +how the tank stratifies. Pairing each setpoint with the ``total`` it +produces, on a 65-gallon unit: -.. code:: text - - gain_Wh = mass_kg * 4.186 kJ/kg/K * temperature_rise_K / 3.6 +.. list-table:: + :header-rows: 1 -and divided by the device's own reported change. This comparison is -independent of the heat pump's efficiency, because it never uses -electrical input. + * - Setpoint + - ``total`` + - Setpoint + - ``total`` + * - 140.0 degF + - 13690 + - 144.5 degF + - 15450 + * - 140.9 degF + - 14040 + - 145.4 degF + - 15800 + * - 141.8 degF + - 14390 + - 146.3 degF + - 16150 + * - 142.7 degF + - 14750 + - 147.2 degF + - 16500 + * - 143.6 degF + - 15100 + - 148.1 degF + - 16850 + +An arithmetic sequence: least squares over those ten points gives +**R-squared 0.99999** and a slope of **70.25 raw counts per Kelvin** of +whole-tank temperature rise. + +Converting that to Watt-hours needs a water mass, and this is where care +is required: a "65 gallon" tank does not hold 65 gallons of water. Rather +than assume nominal volume and derive an odd-looking quantum, assume the +quantum is a round number - every other conversion in this protocol is +(half-degrees, tenths) - and see which one implies a sensible volume: .. list-table:: :header-rows: 1 - * - Quantity - - Median - - p10 - - p90 - * - Wh per raw count - - 4.11 - - 3.47 - - 4.45 - -The library uses **4.0**. The 2.8 % gap is consistent with the tank -holding slightly less than its nominal 65 gallons, which is normal. + * - Candidate quantum + - Implied water volume + - Plausible? + * - **4 Wh** (1/250 kWh) + - **241.7 L / 63.9 gal** + - Yes - slightly under nominal, as expected + * - 1/240 kWh (4.167 Wh) + - 251.8 L / 66.5 gal + - No - more than the nameplate + * - 10 kJ + - 167.8 L / 44.3 gal + - No + * - 15 kJ + - 251.7 L / 66.5 gal + - No + +**4 Wh per count** is the only round candidate implying a volume below +the nameplate, which is the only physically sensible direction. The +library uses 4.0. + +A second, noisier method agrees. Across 183 individual heating +recoveries, dividing each tank sensible-heat gain (from the two +thermistors and the nominal mass) by the device's reported change gives a +median of 4.11 Wh/count, p10 3.47 and p90 4.45. That route depends on +``(upper + lower) / 2`` approximating the true mean tank temperature, so +it is far less precise, but it is an independent confirmation and it does +not use electrical input at all. The efficiency cross-check -------------------------- -An independent check settles it. Integrating ``currentInstPower`` over -each recovery gives the electrical energy in, and dividing the tank's -heat gain by it gives the heat pump's coefficient of performance: +A third check rules out the old scale on its own. Integrating +``currentInstPower`` over each recovery gives the electrical energy in, +and dividing the device's *reported* energy gain by it gives an implied +coefficient of performance: .. list-table:: :header-rows: 1 @@ -151,13 +199,38 @@ heat gain by it gives the heat pump's coefficient of performance: A heat pump water heater in a 72 degF room runs at a COP of roughly 2 to 4. A COP of 7 would mean the device generated energy it never consumed. +This argument is worth stating separately because it needs no tank +volume, specific heat or stratification model - only the device's own +reported energy and its own reported power. It cannot tell you what the +quantum *is*, but it rules out the pre-10.0 value regardless of anything +assumed elsewhere on this page. + The reference temperature ------------------------- -Solving ``reference = setpoint - full_recovery_energy / k`` across the -same history gives a median of 104.5 degF, matching the device's -``dhwTemperatureMin`` of 104.9 degF. The device measures a full recovery -from its own minimum setpoint, not from the cold water inlet. +This is the strongest result here, because it needs no physics at all. +Extrapolating the setpoint regression above to ``total = 0`` gives: + +.. list-table:: + :header-rows: 1 + + * - Quantity + - Value + * - Regression zero crossing + - **104.92 degF** + * - Device ``dhwTemperatureMin`` + - **104.9 degF** + +Those agree to within a fiftieth of a degree, and the calculation uses +only the device's own two numbers - no tank mass, no specific heat, no +thermistor readings, no assumption about the quantum. It establishes + +.. code:: text + + full_recovery_energy = k * (setpoint - dhwTemperatureMin) + +as fact rather than inference. The device measures a full recovery from +its own minimum setpoint, not from the cold water inlet. What ``dhwChargePer`` does diff --git a/src/nwp500/converters.py b/src/nwp500/converters.py index 2f47988d..4258d687 100644 --- a/src/nwp500/converters.py +++ b/src/nwp500/converters.py @@ -23,17 +23,24 @@ #: Watt-hours represented by one raw device energy count. #: #: The device reports ``totalEnergyCapacity`` and ``availableEnergyCapacity`` -#: as small integers in a fixed energy quantum. The quantum was determined -#: empirically from 183 heating recoveries on a 65-gallon NWP500 by comparing -#: the device's reported change against the tank's sensible-heat gain -#: (mass x specific heat x temperature rise), which is independent of the -#: heat pump's coefficient of performance: +#: as small integers in a fixed energy quantum, not in Watt-hours. #: -#: * measured quantum: 4.11 Wh/count (p10 3.47, p90 4.45) -#: * the 2.8% excess over 4.0 is consistent with the tank holding slightly -#: less than its nominal 65 gallons, as is typical -#: * cross-check: using 4 Wh/count the implied heat pump COP is 2.89 -#: (p10 2.24, p90 3.46), a normal figure for a HPWH +#: ``totalEnergyCapacity`` is a whole-tank quantity, so regressing it against +#: the setpoint measures the quantum with no stratification assumption. On a +#: 65-gallon NWP500 that slope is 70.25 raw counts per Kelvin of whole-tank +#: rise (R-squared 0.99999 over ten setpoints). +#: +#: Converting to Watt-hours needs a water mass, and a "65 gallon" tank does +#: not hold 65 gallons. Assuming instead that the quantum is round - as every +#: other conversion in this protocol is - 4 Wh/count is the only candidate +#: implying a water volume below the nameplate (241.7 L / 63.9 gal); 1/240 +#: kWh and 15 kJ both imply more water than the tank holds. +#: +#: Confirmed twice over: +#: +#: * 183 individual heating recoveries give 4.11 Wh/count (p10 3.47, +#: p90 4.45), agreeing to within 2% by a noisier route +#: * the same recoveries imply a heat pump COP of 2.89 at 4 Wh/count #: #: Library versions before 10.0 used 10 Wh/count, which overstated tank #: energy by 2.43x and implied a physically impossible COP of 7.0. From 35a00419cd6a06050d61890476c9a2a42925e5df Mon Sep 17 00:00:00 2001 From: emmanuel Date: Sun, 2 Aug 2026 18:56:32 -0700 Subject: [PATCH 3/6] Disclose that totalEnergyCapacity is bimodal The setpoint regression was presented as R-squared 0.99999 without saying it was a modal fit. The field is not a function of the setpoint alone: at a fixed setpoint it takes one of two values, flipping between them several times a day, separated by a constant 2 degC of setpoint. Fitted separately the branches are 68%/32% and parallel: primary 390.56 units/degF, zero at 104.95 degF secondary 389.81 units/degF, zero at 108.48 degF This cuts both ways. The quantum gets stronger - two independent populations agree on the slope to within 0.2%, which is better evidence than either alone. The reference temperature gets weaker: only the primary branch lands on dhwTemperatureMin, and what selects between the branches is unknown. hpUpperOnTemperatureSetting correlates with the choice, but on 22 paired samples that is a lead, not a finding. Adds a warning against deriving tank heat capacity from a live full_recovery_energy reading, which lands on the wrong branch about a third of the time and is then off by 9%. Also states plainly in the summary that both fields are measured from the setpoint and so describe potential rather than content - neither is a state of charge. No functional change. Co-Authored-By: Claude Opus 5 --- CHANGELOG.rst | 6 ++- docs/explanation/tank-energy.rst | 88 ++++++++++++++++++++++---------- src/nwp500/converters.py | 4 +- 3 files changed, 69 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index c91a6eb9..8fb2790a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,8 +16,10 @@ Changed scaled by 10 on the assumption the device reported 10 Wh units. It does not. Because ``totalEnergyCapacity`` is a whole-tank quantity, its slope against the setpoint measures the quantum with no stratification - assumption: on a 65-gallon NWP500 that is 70.25 raw counts per Kelvin - (R-squared 0.99999 over ten setpoints). Converting to Watt-hours needs a + assumption: on a 65-gallon NWP500 that is 70.25 raw counts per Kelvin. + The field turns out to be bimodal - at a fixed setpoint it takes one of + two values exactly 2 degC apart - but both branches give the same slope + to within 0.2%, so the quantum is unaffected. Converting to Watt-hours needs a water mass, and a "65 gallon" tank does not hold 65 gallons - so taking the quantum to be round, as every other conversion in this protocol is, 4 Wh/count is the only candidate implying a water volume below the diff --git a/docs/explanation/tank-energy.rst b/docs/explanation/tank-energy.rst index 294d6503..e1b18174 100644 --- a/docs/explanation/tank-energy.rst +++ b/docs/explanation/tank-energy.rst @@ -32,8 +32,12 @@ The short version - Cost of a full recovery to the **current setpoint**. It moves whenever the setpoint moves. -Both are raw counts of about 4 Wh each, not Watt-hours. Library versions -before 10.0 multiplied by 10, overstating tank energy by 2.43x. +Both are raw counts of 4 Wh each, not Watt-hours. Library versions before +10.0 multiplied by 10, overstating tank energy by 2.5x. + +Both are also measured **from the setpoint**, so both describe potential +rather than content: move the setpoint and both change while the water in +the tank does not. Neither is a state of charge. Neither field tells you how much hot water you can actually draw. That depends on tank volume, the inlet temperature and the temperature you @@ -50,9 +54,10 @@ Both fields fit a single two-parameter model: energy_to_setpoint = k * (setpoint - tank_mean_temperature) full_recovery_energy = k * (setpoint - reference_temperature) -where ``reference_temperature`` is the device's own minimum setpoint, -``dhwTemperatureMin`` (40.5 degC / 104.9 degF), and ``k`` is the tank's -heat capacity. +where ``k`` is the tank's heat capacity and ``reference_temperature`` is +the device's own minimum setpoint, ``dhwTemperatureMin`` (40.5 degC / +104.9 degF) - though only about two thirds of the time, see +`Two branches`_. Two consequences follow, and both matter: @@ -103,8 +108,11 @@ The 4 Wh quantum ``totalEnergyCapacity`` is a whole-tank quantity, so its slope against the setpoint measures the quantum without needing any assumption about -how the tank stratifies. Pairing each setpoint with the ``total`` it -produces, on a 65-gallon unit: +how the tank stratifies. + +The device does not report a single ``total`` per setpoint - see +`Two branches`_ below - so the table lists the most common value at each +setpoint, which covers 68 % of samples: .. list-table:: :header-rows: 1 @@ -134,9 +142,13 @@ produces, on a 65-gallon unit: - 148.1 degF - 16850 -An arithmetic sequence: least squares over those ten points gives -**R-squared 0.99999** and a slope of **70.25 raw counts per Kelvin** of -whole-tank temperature rise. +An arithmetic sequence: least squares gives **R-squared 0.99999** and a +slope of **70.25 raw counts per Kelvin** of whole-tank temperature rise. + +The slope is the robust part of this. The second branch, fitted +separately, gives 389.81 units/degF against the primary's 390.56 - the +same figure to within 0.2 %. Two independent populations agreeing on the +slope is stronger evidence for the quantum than either alone. Converting that to Watt-hours needs a water mass, and this is where care is required: a "65 gallon" tank does not hold 65 gallons of water. Rather @@ -205,32 +217,56 @@ reported energy and its own reported power. It cannot tell you what the quantum *is*, but it rules out the pre-10.0 value regardless of anything assumed elsewhere on this page. -The reference temperature -------------------------- +.. _two branches: -This is the strongest result here, because it needs no physics at all. -Extrapolating the setpoint regression above to ``total = 0`` gives: +Two branches +------------ + +``totalEnergyCapacity`` is **not** a function of the setpoint alone. At a +fixed setpoint it takes one of two values, flipping between them several +times a day. Over four months at nine setpoints: .. list-table:: :header-rows: 1 - * - Quantity - - Value - * - Regression zero crossing - - **104.92 degF** - * - Device ``dhwTemperatureMin`` - - **104.9 degF** - -Those agree to within a fiftieth of a degree, and the calculation uses -only the device's own two numbers - no tank mass, no specific heat, no -thermistor readings, no assumption about the quantum. It establishes + * - Branch + - Share + - Slope + - Zero crossing + * - Primary + - 68 % + - 390.56 units/degF + - **104.95 degF** + * - Secondary + - 32 % + - 389.81 units/degF + - **108.48 degF** + +The two are parallel, separated by a constant 1400-1410 units - exactly +**2 degC** of setpoint - at every setpoint measured. + +The primary branch's zero crossing matches the device's +``dhwTemperatureMin`` of 104.9 degF to within a twentieth of a degree, +using only the device's own two numbers: no tank mass, no specific heat, +no thermistors, no assumption about the quantum. On that branch, .. code:: text full_recovery_energy = k * (setpoint - dhwTemperatureMin) -as fact rather than inference. The device measures a full recovery from -its own minimum setpoint, not from the cold water inlet. +The secondary branch behaves identically with a reference 2 degC higher, +and **what selects between them is unknown**. The device's +``hpUpperOnTemperatureSetting`` correlates with the choice - 104.9 degF +when the primary is active, 143.4 degF when the secondary is - which +would fit the device computing recovery cost from its own turn-on +threshold, but only 22 paired samples were available and that is a lead +rather than a finding. + +.. warning:: + Because of this, do not derive the tank's heat capacity from a live + ``full_recovery_energy`` reading: landing on the wrong branch gives an + error of about 9 %. Use the slope, which is stable across both + branches, or compute stored energy from the thermistors directly. What ``dhwChargePer`` does diff --git a/src/nwp500/converters.py b/src/nwp500/converters.py index 4258d687..aa8797c3 100644 --- a/src/nwp500/converters.py +++ b/src/nwp500/converters.py @@ -28,7 +28,9 @@ #: ``totalEnergyCapacity`` is a whole-tank quantity, so regressing it against #: the setpoint measures the quantum with no stratification assumption. On a #: 65-gallon NWP500 that slope is 70.25 raw counts per Kelvin of whole-tank -#: rise (R-squared 0.99999 over ten setpoints). +#: rise. The field is bimodal - at a fixed setpoint it takes one of two +#: values 2 degC apart - but both branches give the same slope to within +#: 0.2%, so the quantum is unaffected. #: #: Converting to Watt-hours needs a water mass, and a "65 gallon" tank does #: not hold 65 gallons. Assuming instead that the quantum is round - as every From b46a4c54e72cdab054ecbc48404fe737470e0cdf Mon Sep 17 00:00:00 2001 From: emmanuel Date: Sun, 2 Aug 2026 19:07:22 -0700 Subject: [PATCH 4/6] Add DeviceStatus.usable_energy for drawable tank energy Both device energy fields are measured from the setpoint, so neither is a state of charge: move the setpoint and both change while the water in the tank does not. Subtracting them cancels the setpoint. full_recovery_energy - energy_to_setpoint = k * (setpoint - reference) - k * (setpoint - tank_temp) = k * (tank_temp - reference) The reference is dhw_temperature_min, 104.9 degF. A shower runs about 105 degF, so water below the reference still holds heat but not heat you can wash with, and excluding it is the behaviour wanted. A mixing valve does not move this floor - it caps how hot water can be delivered, and below its setting it passes through, so water stays usable down to the temperature actually wanted at the tap. Robust despite full_recovery_energy being bimodal (it takes one of two values 2 degC apart at a fixed setpoint): both fields shift together, so the difference is unaffected. Against the tank thermistors over 12275 samples the implied tank temperature agrees to a standard deviation of 0.57 degF, with 97.5% of samples inside 2 degF. Rendered by the CLI as "Usable Energy". Verified against a live device: 5284 Wh at a 139 degF tank, consistent with the 156 Wh/degF heat capacity of the 65-gallon unit. Co-Authored-By: Claude Opus 5 --- CHANGELOG.rst | 13 ++++++++ docs/explanation/tank-energy.rst | 56 +++++++++++++++++++++++++++++--- src/nwp500/cli/presentation.py | 8 +++++ src/nwp500/models/status.py | 33 +++++++++++++++++++ tests/test_models.py | 49 ++++++++++++++++++++++++++++ 5 files changed, 155 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8fb2790a..ad7d06a2 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -56,6 +56,19 @@ Changed relabelled from "Total Capacity"/"Available Capacity" to "Full Recovery"/"Energy to Setpoint". +Added +----- +- **``DeviceStatus.usable_energy``**: drawable energy in Watt-hours, + computed as ``full_recovery_energy - energy_to_setpoint``. Both raw + fields are measured from the setpoint, so neither is a state of charge; + subtracting them cancels the setpoint and leaves the tank's heat above + the device's minimum operating temperature (104.9 degF), which is about + the lowest temperature usable for a shower. Robust despite + ``full_recovery_energy`` being bimodal, since both fields shift + together: the implied tank temperature tracks the thermistor mean to a + standard deviation of 0.57 degF over 12275 samples. Rendered by the CLI + as "Usable Energy". + Removed ------- - **Misnamed energy fields**: removed ``DeviceStatus.total_energy_capacity`` diff --git a/docs/explanation/tank-energy.rst b/docs/explanation/tank-energy.rst index e1b18174..1c0ba99d 100644 --- a/docs/explanation/tank-energy.rst +++ b/docs/explanation/tank-energy.rst @@ -39,9 +39,17 @@ Both are also measured **from the setpoint**, so both describe potential rather than content: move the setpoint and both change while the water in the tank does not. Neither is a state of charge. -Neither field tells you how much hot water you can actually draw. That -depends on tank volume, the inlet temperature and the temperature you -want water delivered at, none of which the device reports. +Their **difference** is a state of charge, and is exposed as +``DeviceStatus.usable_energy``: + +.. code:: text + + usable_energy = full_recovery_energy - energy_to_setpoint + = k * (tank_temperature - 104.9 degF) + +The setpoint cancels. What remains is the tank's heat above the device's +minimum operating temperature - close enough to the lowest useful shower +temperature that it is a good estimate of what you can actually draw. How the fields behave @@ -278,7 +286,47 @@ implies 30 % charged while ``dhwChargePer`` reads 59 %; over two weeks the two differ by a mean of 48 points with 32 points of scatter. Treat it as an opaque vendor heuristic rather than a defined fraction of -anything. +anything. For a charge figure with defined meaning, use +``usable_energy``. + + +Drawable energy +=============== + +``DeviceStatus.usable_energy`` is the difference of the two fields, and +is the one number here that describes the tank's state rather than its +distance from a target: + +.. code:: text + + usable_energy = full_recovery_energy - energy_to_setpoint + +Raising the setpoint inflates both inputs equally, so the result does not +move - which is what makes it a state of charge and the two raw fields +not. + +The implied reference is ``dhw_temperature_min``, 104.9 degF. A shower +runs around 105 degF, so heat below that reference is real but not +useful, and excluding it is the behaviour you want. Note that a mixing +valve does not change this floor: it caps how *hot* water can be +delivered, and once the tank falls below its setting it simply passes +through, so water stays usable down to the temperature you actually want +at the tap. + +Despite ``full_recovery_energy`` being bimodal (see `Two branches`_), +the difference is robust, because both fields shift together. Checked +against the tank thermistors over 12275 samples, the tank temperature +implied by ``usable_energy`` agrees with the thermistor mean to a +standard deviation of **0.57 degF**, with 97.5 % of samples inside +2 degF. + +If you need a different floor - a bath at 100 degF, or energy above the +cold inlet - compute it from the thermistors instead. On a 65-gallon tank +the heat capacity is 156 Wh per degF: + +.. code:: text + + drawable_Wh = 156 * (tank_mean_temperature - your_floor_degF) Migrating from before v10.0 diff --git a/src/nwp500/cli/presentation.py b/src/nwp500/cli/presentation.py index f2b9eff0..27cbdf09 100644 --- a/src/nwp500/cli/presentation.py +++ b/src/nwp500/cli/presentation.py @@ -372,6 +372,14 @@ def build_device_status_rows(device_status: Any) -> list[StatusRow]: f"{_format_number(device_status.wh_electric_heater_power)}Wh", ) ) + if hasattr(device_status, "usable_energy"): + all_items.append( + ( + "POWER & ENERGY", + "Usable Energy", + f"{_format_number(device_status.usable_energy)} Wh", + ) + ) _add_numeric_item( all_items, device_status, diff --git a/src/nwp500/models/status.py b/src/nwp500/models/status.py index 31fa8069..e7bba624 100644 --- a/src/nwp500/models/status.py +++ b/src/nwp500/models/status.py @@ -912,6 +912,39 @@ def freeze_protection_temp_max(self) -> float: self._is_celsius() ) + @computed_field # type: ignore[prop-decorator] + @property + def usable_energy(self) -> float: + """Energy drawable from the tank as useful hot water, in Wh. + + Both device energy fields are measured from the setpoint, so + neither is a state of charge - move the setpoint and both change + while the water in the tank does not. Subtracting them cancels + the setpoint and leaves the tank's actual heat content: + + .. code:: text + + full_recovery_energy - energy_to_setpoint + = k * (setpoint - reference) - k * (setpoint - tank_temp) + = k * (tank_temp - reference) + + The reference is the device's minimum operating temperature, + ``dhw_temperature_min`` (40.5 degC / 104.9 degF). That is close + to the lowest temperature usable for a shower, so this is a good + estimate of what can actually be drawn - water colder than that + still holds heat, but not heat you can wash with. + + Robust in practice: ``full_recovery_energy`` is bimodal, taking + one of two values 2 degC apart at a fixed setpoint, but both + fields shift together so the difference is unaffected. Checked + against the tank thermistors over 12275 samples, the implied tank + temperature agrees to a standard deviation of 0.57 degF. + + Returns: + Drawable energy in Watt-hours, clamped at zero. + """ + return max(0.0, self.full_recovery_energy - self.energy_to_setpoint) + def get_field_unit(self, field_name: str) -> str: """Get the correct unit suffix based on temperature preference. diff --git a/tests/test_models.py b/tests/test_models.py index 183a217f..1cf17c3b 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -311,3 +311,52 @@ def test_protocol_dump_uses_wire_names(self, device_status_dict): assert "totalEnergyCapacity" in dumped assert "availableEnergyCapacity" in dumped assert "full_recovery_energy" not in dumped + + +class TestUsableEnergy: + """Drawable energy, derived by cancelling the setpoint.""" + + def test_is_the_difference(self, device_status_dict): + """Usable energy is full recovery minus the remaining deficit.""" + status = DeviceStatus(**device_status_dict) + assert status.usable_energy == pytest.approx( + status.full_recovery_energy - status.energy_to_setpoint + ) + + def test_known_value(self, device_status_dict): + """1580 and 1166 raw counts at 4 Wh give 6320 - 4664 Wh.""" + status = DeviceStatus(**device_status_dict) + assert status.usable_energy == pytest.approx(1656.0) + + def test_zero_when_tank_at_reference(self, device_status_dict): + """A tank at the reference temperature has nothing drawable.""" + d = dict(device_status_dict) + d["availableEnergyCapacity"] = d["totalEnergyCapacity"] + assert DeviceStatus(**d).usable_energy == 0.0 + + def test_clamped_below_reference(self, device_status_dict): + """Below the reference the result clamps rather than going negative.""" + d = dict(device_status_dict) + d["availableEnergyCapacity"] = d["totalEnergyCapacity"] + 500 + assert DeviceStatus(**d).usable_energy == 0.0 + + def test_independent_of_setpoint(self, device_status_dict): + """The setpoint cancels, so raising it must not change the result. + + This is the property that makes usable_energy a state of charge + while the two raw fields are not: raising the setpoint inflates + both of them by the same amount. + """ + base = DeviceStatus(**device_status_dict) + d = dict(device_status_dict) + bump = 200 # raw counts of extra setpoint headroom + d["totalEnergyCapacity"] += bump + d["availableEnergyCapacity"] += bump + assert DeviceStatus(**d).usable_energy == pytest.approx( + base.usable_energy + ) + + def test_excluded_from_protocol_dump(self, device_status_dict): + """Computed fields must never be sent back to the device.""" + status = DeviceStatus(**device_status_dict) + assert "usable_energy" not in status.to_protocol_dict() From 2df3bf7660ab8f7107957159a60bc79bece049c2 Mon Sep 17 00:00:00 2001 From: emmanuel Date: Sun, 2 Aug 2026 20:18:53 -0700 Subject: [PATCH 5/6] Fix contradictory arithmetic in the energy quantum test docstring The docstring computed 6436 Wh from a nominal 65 gallons while the assertion expected 6320 Wh, so it read as asserting a value its own reasoning contradicted. 6320 Wh is 1580 counts x 4 Wh, and implies 241.5 kg of water over the 22.5 K recovery span - just under the 246.05 kg nominal 65 gallons would weigh, which is expected. The docstring now shows that and explains why the nominal-volume figure is the wrong one to anchor on. Reported by Copilot review on #119. Co-Authored-By: Claude Opus 5 --- tests/test_model_converters.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/test_model_converters.py b/tests/test_model_converters.py index 2a0cc019..1417b90d 100644 --- a/tests/test_model_converters.py +++ b/tests/test_model_converters.py @@ -275,9 +275,17 @@ def test_string_numeric(self): def test_observed_full_recovery(self): """A real reading: 1580 counts at a 145.4 degF setpoint. - The tank is 65 gallons and the device's reference temperature is - dhw_temperature_min (104.9 degF), so a full recovery spans 22.5 K: - 246.05 kg * 4.186 kJ/kg/K * 22.5 K = 23170 kJ = 6436 Wh. + 1580 * 4 Wh = 6320 Wh. Sanity-check that against physics: the + device's reference is dhw_temperature_min (104.9 degF), so a full + recovery spans 22.5 K, and 6320 Wh implies + + 6320 Wh * 3.6 / (4.186 kJ/kg/K * 22.5 K) = 241.5 kg + + of water. That is just under the 246.05 kg a nominal 65 gallons + would weigh, which is expected - a "65 gallon" tank does not hold + 65 gallons of water. Assuming nominal volume instead would give + 6436 Wh and a quantum of 4.07, which is not a round number and so + is the less likely reading. See WH_PER_ENERGY_COUNT. """ assert energy_count_to_wh(1580) == pytest.approx(6320.0) From 10e4e2202e0e21456a7575b82d0f90e10f00f8f6 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Mon, 3 Aug 2026 10:10:03 -0700 Subject: [PATCH 6/6] Fix a factor-of-10 in the tank energy tables and record the app teardown The tables in tank-energy.rst listed totalEnergyCapacity and availableEnergyCapacity at ten times their raw wire values, under column headings naming the raw protocol fields. The series had been logged through the pre-fix library, which multiplied by 10, and that scaling was never undone when the numbers were tabulated. The prose beside the tables was already in true raw counts - "70.25 raw counts per Kelvin", "35 counts, about 140 Wh" - so the page contradicted itself by exactly the factor it exists to correct. A reader fitting the printed table gets 700 counts/K, then 4 Wh/count implies 2400 L of water, and concludes the derivation is wrong. Tables are now in raw counts. 1580 counts at a 145.4 degF setpoint matches tests/conftest.py and the 6320 Wh the CLI reports, so the table is self-verifying against the fixtures. Branch slopes are restated as 39.06 and 38.98 counts/degF and the separation as 140-141 counts. The deficit table is labelled as binned means, since those values are not whole counts, and gains a check against the 156 Wh/degF heat capacity. No numeric conclusion changes. Also corrected while checking: - hpUpperOnTemperatureSetting -> hpUpperOnTempSetting, the real protocol name - 143 Wh per 0.5 degC -> 140 Wh, which is what the documented slope gives at 4 Wh/count, in the changelog, the model field description and the protocol reference - "overstated by 2.43x" -> 2.5x where the sentence is about what the library reported; 2.43 is 10/4.11, the measured estimate, not the 10/4.0 the code actually applied - the data_conversions note led with the per-recovery method that the explanation page had already demoted; it now leads with the setpoint slope Adds a section recording that Navien's own NaviLink app reads neither field. Decompiling the current release - 2.03.00, versionCode 141, March 2026 - gives 8101 sources and neither name appears in any of them, nor in the raw dex string pool, while dhwChargePer and tankUpperTemperature do appear as controls. The app requests no field subset, so the device sends both and the app discards them. There is therefore no vendor label, scale or formula to check this page against, which is worth stating explicitly since "just look at the app" is the obvious next question. The app does corroborate three surrounding facts, now cited inline: its volume table gives the 246.0 L nominal the quantum candidates are judged against, it reads dhwTemperatureMin as half-degrees C confirming the 104.9 degF reference, and it labels dhwTemperature "DHW Temp." beside the tank thermistors while giving dischargeTemperature its own row. --- CHANGELOG.rst | 24 +++- docs/explanation/tank-energy.rst | 138 ++++++++++++++----- docs/reference/protocol/data_conversions.rst | 19 +-- src/nwp500/converters.py | 4 +- src/nwp500/models/status.py | 7 +- tests/test_model_converters.py | 8 +- 6 files changed, 151 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ad7d06a2..770a152f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -39,7 +39,7 @@ Changed temperature: negative slope, R-squared 0.93, zero crossing at the setpoint). Likewise ``totalEnergyCapacity`` is not a fixed tank size but the cost of a full recovery to the *current setpoint*, measured from the - device's own minimum setpoint of 104.9 degF; it moves by about 143 Wh + device's own minimum setpoint of 104.9 degF; it moves by about 140 Wh per 0.5 degC of setpoint change. .. code-block:: python @@ -96,8 +96,28 @@ Fixed - ``dhwTemperature`` is documented as an outlet temperature but is measured inside the tank: it tracks ``tankUpperTemperature`` to within one 0.5 degC step, and the device has no sensor downstream of itself. + Navien's own app agrees, labelling it "DHW Temp." beside the tank + thermistors and showing ``dischargeTemperature`` separately. +- ``docs/explanation/tank-energy.rst`` tabulated ``totalEnergyCapacity`` + and ``availableEnergyCapacity`` at ten times their raw wire values, + under column headings naming the raw protocol fields. The series had + been logged through the pre-fix library, which multiplied by 10. The + prose beside the tables ("70.25 raw counts per Kelvin", "35 counts") + was already in true raw counts, so the page contradicted itself by + exactly the factor it exists to correct. Tables are now in raw counts; + 1580 counts at a 145.4 degF setpoint matches the test fixtures and the + 6320 Wh the CLI reports. No numeric conclusion changes. +- ``docs/explanation/tank-energy.rst`` referred to a field + ``hpUpperOnTemperatureSetting``; the protocol name is + ``hpUpperOnTempSetting``. +- The setpoint-per-0.5-degC figure is 140 Wh, not 143 Wh, which is what + the documented slope of 70.25 counts/K gives at 4 Wh/count. Corrected + in the changelog, the model field description and the protocol + reference. - New ``docs/explanation/tank-energy.rst`` records what the two fields - actually measure and the calibration evidence behind the scale change. + actually measure and the calibration evidence behind the scale change, + including that Navien's own NaviLink app (2.03.00, versionCode 141) + reads neither field, so no vendor-side corroboration exists. Version 9.2.1 (2026-07-30) ========================== diff --git a/docs/explanation/tank-energy.rst b/docs/explanation/tank-energy.rst index 1c0ba99d..deac19fe 100644 --- a/docs/explanation/tank-energy.rst +++ b/docs/explanation/tank-energy.rst @@ -85,32 +85,40 @@ Deficit, not stored energy -------------------------- During a heating recovery on a 65-gallon unit at a 140.9 degF setpoint, -with the tank warming and no draws: +with the tank warming and no draws. Each row is a mean over the samples +in that temperature bin, so the counts are not whole numbers: .. list-table:: :header-rows: 1 * - Mean tank temp - Setpoint minus tank - - ``availableEnergyCapacity`` + - mean ``availableEnergyCapacity`` - ``dhwChargePer`` * - 119.5 degF - 21.4 degF - - 8816 + - 881.6 - 54.5 % * - 127.4 degF - 13.5 degF - - 5300 + - 530.0 - 69.5 % * - 135.1 degF - 5.8 degF - - 2275 + - 227.5 - 85.7 % The field falls as the tank fills with heat. Regressed against mean tank temperature over two weeks of five-minute samples, the slope is negative with an R-squared of 0.93 and a zero crossing at the setpoint. +The last two rows also check out against the tank's heat capacity of +156 Wh/degF: 227.5 counts x 4 Wh = 910 Wh against 5.8 degF x 156 = +905 Wh, and 530.0 counts = 2120 Wh against 13.5 degF x 156 = 2106 Wh. +The coldest row runs about 6 % high, which is the stratification error +in using ``(upper + lower) / 2`` as the mean tank temperature - it is +worst when the tank is least mixed. + The 4 Wh quantum ---------------- @@ -118,51 +126,57 @@ The 4 Wh quantum the setpoint measures the quantum without needing any assumption about how the tank stratifies. -The device does not report a single ``total`` per setpoint - see -`Two branches`_ below - so the table lists the most common value at each -setpoint, which covers 68 % of samples: +The device does not report a single ``totalEnergyCapacity`` per setpoint - +see `Two branches`_ below - so the table lists the most common value at +each setpoint, which covers 68 % of samples. Values are raw counts as +they arrive on the wire: .. list-table:: :header-rows: 1 * - Setpoint - - ``total`` + - ``totalEnergyCapacity`` - Setpoint - - ``total`` + - ``totalEnergyCapacity`` * - 140.0 degF - - 13690 + - 1369 - 144.5 degF - - 15450 + - 1545 * - 140.9 degF - - 14040 + - 1404 - 145.4 degF - - 15800 + - 1580 * - 141.8 degF - - 14390 + - 1439 - 146.3 degF - - 16150 + - 1615 * - 142.7 degF - - 14750 + - 1475 - 147.2 degF - - 16500 + - 1650 * - 143.6 degF - - 15100 + - 1510 - 148.1 degF - - 16850 + - 1685 An arithmetic sequence: least squares gives **R-squared 0.99999** and a slope of **70.25 raw counts per Kelvin** of whole-tank temperature rise. +The endpoints alone give the same figure: (1685 - 1369) / 4.5 K = 70.2. The slope is the robust part of this. The second branch, fitted -separately, gives 389.81 units/degF against the primary's 390.56 - the +separately, gives 38.98 counts/degF against the primary's 39.06 - the same figure to within 0.2 %. Two independent populations agreeing on the slope is stronger evidence for the quantum than either alone. Converting that to Watt-hours needs a water mass, and this is where care -is required: a "65 gallon" tank does not hold 65 gallons of water. Rather -than assume nominal volume and derive an odd-looking quantum, assume the -quantum is a round number - every other conversion in this protocol is -(half-degrees, tenths) - and see which one implies a sensible volume: +is required: a "65 gallon" tank does not hold 65 gallons of water. The +nameplate is an upper bound - the vendor's own app hard-codes it, mapping +``volumeCode`` 1/2/3 to 189.2 L, **246.0 L** and 302.8 L (see `What the +vendor app does with these fields`_) - and the water actually in the tank +must come in under it. Rather than assume nominal volume and derive an +odd-looking quantum, assume the quantum is a round number - every other +conversion in this protocol is (half-degrees, tenths) - and see which one +implies a sensible volume: .. list-table:: :header-rows: 1 @@ -243,20 +257,21 @@ times a day. Over four months at nine setpoints: - Zero crossing * - Primary - 68 % - - 390.56 units/degF + - 39.06 counts/degF - **104.95 degF** * - Secondary - 32 % - - 389.81 units/degF + - 38.98 counts/degF - **108.48 degF** -The two are parallel, separated by a constant 1400-1410 units - exactly +The two are parallel, separated by a constant 140-141 counts - exactly **2 degC** of setpoint - at every setpoint measured. The primary branch's zero crossing matches the device's -``dhwTemperatureMin`` of 104.9 degF to within a twentieth of a degree, -using only the device's own two numbers: no tank mass, no specific heat, -no thermistors, no assumption about the quantum. On that branch, +``dhwTemperatureMin`` of 104.9 degF to within a twentieth of a degree - +140.0 - 1369 / 39.06 = 104.95 degF - using only the device's own two +numbers: no tank mass, no specific heat, no thermistors, no assumption +about the quantum. On that branch, .. code:: text @@ -264,7 +279,7 @@ no thermistors, no assumption about the quantum. On that branch, The secondary branch behaves identically with a reference 2 degC higher, and **what selects between them is unknown**. The device's -``hpUpperOnTemperatureSetting`` correlates with the choice - 104.9 degF +``hpUpperOnTempSetting`` correlates with the choice - 104.9 degF when the primary is active, 143.4 degF when the secondary is - which would fit the device computing recovery cost from its own turn-on threshold, but only 22 paired samples were available and that is a lead @@ -285,11 +300,70 @@ two. On one device, ``energy_to_setpoint / full_recovery_energy`` implies 30 % charged while ``dhwChargePer`` reads 59 %; over two weeks the two differ by a mean of 48 points with 32 points of scatter. +It is nonetheless the number Navien shows its own users: the NaviLink app +prints it unmodified as a percentage labelled "DHW Charge", with no +client-side arithmetic of any kind. Whatever it means, it is computed on +the device, and a user comparing the app against this library will see +the app's figure and not the ratio above. + Treat it as an opaque vendor heuristic rather than a defined fraction of anything. For a charge figure with defined meaning, use ``usable_energy``. +.. _what the vendor app does with these fields: + +What the vendor app does with these fields +========================================== + +Nothing. Navien's own NaviLink app never reads either field. + +Decompiling the current release - version 2.03.00, versionCode 141, +published March 2026 - gives 8,101 Java sources, and neither +``totalEnergyCapacity`` nor ``availableEnergyCapacity`` appears in any of +them. Neither string appears in the raw dex string pool either, which +rules out the names having been lost to obfuscation. The app's status +model, ``KDResponseMgppStatus.Status``, declares about 140 fields - +including ``dhwChargePer``, ``tankUpperTemperature``, +``tankLowerTemperature``, ``currentInstPower`` and ``mixingRate`` - and +neither energy field is among them. The app requests no field subset, so +the device sends both and the app discards them on deserialization. + +As a control, ``dhwChargePer`` and ``tankUpperTemperature`` *are* present +in the dex strings, so the absence of the other two is a real result and +not a broken search. + +This matters for reading the rest of this page. There is no vendor label, +no vendor scale factor and no vendor formula to check the conclusions +above against - the evidence here is the only account of these two fields +that exists. It also explains why the protocol names are so misleading: +nothing Navien ships ever has to act on them. + +The app does corroborate the surrounding facts this page leans on: + +.. list-table:: + :header-rows: 1 + :widths: 40 60 + + * - What the app does + - What it confirms + * - ``MgppStatusFragment.getVolume()`` maps ``volumeCode`` 1/2/3 to + "50"/"65"/"80" gallons and 189.2/246.0/302.8 L, displayed under + the label "Volume" + - The nominal volume the quantum candidates are judged against is + the vendor's own figure, not an assumption of ours + * - ``MgppControlFragment.makeTempMap()`` reads + ``dhwTemperatureMin / 2.0f`` as degC before converting + - ``dhwTemperatureMin`` is in half-degrees C, so 40.5 degC / + 104.9 degF is the exact value the regression lands on. The app + rounds for display and shows 105 degF + * - The status screen labels ``dhwTemperature`` "DHW Temp." next to + "Upper Temp." and "Lower Temp.", and gives ``dischargeTemperature`` + a separate "Discharge Temp." row + - The vendor UI does not treat ``dhwTemperature`` as an outlet + reading, and reserves a different field for what leaves the unit + + Drawable energy =============== diff --git a/docs/reference/protocol/data_conversions.rst b/docs/reference/protocol/data_conversions.rst index e5708caf..2ebab628 100644 --- a/docs/reference/protocol/data_conversions.rst +++ b/docs/reference/protocol/data_conversions.rst @@ -352,7 +352,7 @@ Power and Energy Fields * - ``totalEnergyCapacity`` - ``x 4`` (see note) - Wh - - **Cost of a full recovery** to the *current setpoint*, measured from the device reference temperature (``dhwTemperatureMin``, 104.9 degF). Exposed as ``full_recovery_energy``. This is **not** a fixed tank capacity: it moves with the setpoint, by about 143 Wh per 0.5 degC on a 65-gallon tank. + - **Cost of a full recovery** to the *current setpoint*, measured from the device reference temperature (``dhwTemperatureMin``, 104.9 degF). Exposed as ``full_recovery_energy``. This is **not** a fixed tank capacity: it moves with the setpoint, by about 140 Wh per 0.5 degC on a 65-gallon tank. * - ``availableEnergyCapacity`` - ``x 4`` (see note) - Wh @@ -360,13 +360,16 @@ Power and Energy Fields .. note:: **Energy quantum.** The two energy fields are raw counts in a fixed - quantum, not Watt-hours. The quantum was measured at 4.11 Wh/count - (p10 3.47, p90 4.45) across 183 heating recoveries on a 65-gallon - NWP500, by comparing the device's reported change against the tank's - sensible-heat gain -- a comparison independent of the heat pump's - efficiency. The library uses 4.0. Versions before 10.0 used 10, which - overstated tank energy by 2.43x and implied a physically impossible - heat-pump COP of 7.0. See :doc:`../../explanation/tank-energy`. + quantum, not Watt-hours. Because ``totalEnergyCapacity`` is a + whole-tank quantity, regressing it against the setpoint measures the + quantum with no stratification assumption: on a 65-gallon NWP500 the + slope is 70.25 counts per Kelvin, and 4 Wh/count is the only round + candidate implying a water volume below the nameplate. Two further + checks agree -- 183 heating recoveries give 4.11 Wh/count by a + noisier route, and the same recoveries imply a heat-pump COP of 2.89. + The library uses 4.0. Versions before 10.0 used 10, which overstated + reported tank energy by 2.5x and implied a physically impossible COP + of 7.0. See :doc:`../../explanation/tank-energy`. .. note:: ``currentInstPower`` excludes electric heating element power. If the heater is actively heating with electric elements, the actual power draw will be higher (typically +3755W @ 208V or +5000W @ 240V). diff --git a/src/nwp500/converters.py b/src/nwp500/converters.py index aa8797c3..da6d3b5d 100644 --- a/src/nwp500/converters.py +++ b/src/nwp500/converters.py @@ -44,8 +44,8 @@ #: p90 4.45), agreeing to within 2% by a noisier route #: * the same recoveries imply a heat pump COP of 2.89 at 4 Wh/count #: -#: Library versions before 10.0 used 10 Wh/count, which overstated tank -#: energy by 2.43x and implied a physically impossible COP of 7.0. +#: Library versions before 10.0 used 10 Wh/count, which overstated reported +#: tank energy by 2.5x and implied a physically impossible COP of 7.0. #: #: See ``docs/explanation/tank-energy.rst`` for the full derivation. WH_PER_ENERGY_COUNT = 4.0 diff --git a/src/nwp500/models/status.py b/src/nwp500/models/status.py index e7bba624..e34f86b6 100644 --- a/src/nwp500/models/status.py +++ b/src/nwp500/models/status.py @@ -217,7 +217,7 @@ class DeviceStatus(NavienBaseModel): "Energy required to heat the whole tank from the device's " "reference temperature (dhw_temperature_min, 104.9 degF) up to " "the current setpoint, in Watt-hours. This is NOT a fixed tank " - "size: it tracks the setpoint, rising about 143 Wh per 0.5 degC " + "size: it tracks the setpoint, rising about 140 Wh per 0.5 degC " "of setpoint increase. Use it as the cost of a full recovery, " "not as the tank's total heat content." ), @@ -401,7 +401,10 @@ class DeviceStatus(NavienBaseModel): "0.5 degC step, while tank_lower_temperature is uncorrelated. " "Prefer tank_upper_temperature, which reports the same water at " "0.1 degC resolution. The device has no sensor downstream of " - "itself, so this cannot measure water leaving the appliance", + "itself, so this cannot measure water leaving the appliance. " + "Navien's own app agrees: it labels this 'DHW Temp.' alongside " + "the tank thermistors and shows dischargeTemperature separately " + "as 'Discharge Temp.'", alias="dhwTemperature", ) dhw_temperature_setting_raw: int = temperature_field( diff --git a/tests/test_model_converters.py b/tests/test_model_converters.py index 1417b90d..1660e097 100644 --- a/tests/test_model_converters.py +++ b/tests/test_model_converters.py @@ -247,9 +247,11 @@ class TestEnergyCountToWh: """Test energy_count_to_wh converter. The device reports tank energy in a fixed quantum of - WH_PER_ENERGY_COUNT Watt-hours per raw count, measured empirically at - 4.11 Wh/count across 183 heating recoveries on a 65-gallon NWP500. - Versions before 10.0 used 10 Wh/count, overstating energy by 2.43x. + WH_PER_ENERGY_COUNT Watt-hours per raw count, measured from the slope + of totalEnergyCapacity against the setpoint on a 65-gallon NWP500 + (70.25 counts per Kelvin) and corroborated at 4.11 Wh/count across + 183 heating recoveries. Versions before 10.0 used 10 Wh/count, + overstating reported energy by 2.5x. """ def test_zero(self):