diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 244c7848..770a152f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,120 @@ 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. 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. + 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 + 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. + +- **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 140 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". + +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`` + 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. + 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, + 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/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..deac19fe --- /dev/null +++ b/docs/explanation/tank-energy.rst @@ -0,0 +1,439 @@ +=========== +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 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. + +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 +===================== + +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 ``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: + +* ``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 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. + + +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. 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 + - mean ``availableEnergyCapacity`` + - ``dhwChargePer`` + * - 119.5 degF + - 21.4 degF + - 881.6 + - 54.5 % + * - 127.4 degF + - 13.5 degF + - 530.0 + - 69.5 % + * - 135.1 degF + - 5.8 degF + - 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 +---------------- + +``totalEnergyCapacity`` is a whole-tank quantity, so its slope against +the setpoint measures the quantum without needing any assumption about +how the tank stratifies. + +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 + - ``totalEnergyCapacity`` + - Setpoint + - ``totalEnergyCapacity`` + * - 140.0 degF + - 1369 + - 144.5 degF + - 1545 + * - 140.9 degF + - 1404 + - 145.4 degF + - 1580 + * - 141.8 degF + - 1439 + - 146.3 degF + - 1615 + * - 142.7 degF + - 1475 + - 147.2 degF + - 1650 + * - 143.6 degF + - 1510 + - 148.1 degF + - 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 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. 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 + + * - 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 +-------------------------- + +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 + + * - 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. + +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. + +.. _two branches: + +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 + + * - Branch + - Share + - Slope + - Zero crossing + * - Primary + - 68 % + - 39.06 counts/degF + - **104.95 degF** + * - Secondary + - 32 % + - 38.98 counts/degF + - **108.48 degF** + +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 - +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 + + full_recovery_energy = k * (setpoint - dhwTemperatureMin) + +The secondary branch behaves identically with a reference 2 degC higher, +and **what selects between them is unknown**. The device's +``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 +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 +========================== + +``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. + +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 +=============== + +``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 +=========================== + +.. 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..2ebab628 100644 --- a/docs/reference/protocol/data_conversions.rst +++ b/docs/reference/protocol/data_conversions.rst @@ -350,13 +350,26 @@ 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 140 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. 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). @@ -633,7 +646,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..27cbdf09 100644 --- a/src/nwp500/cli/presentation.py +++ b/src/nwp500/cli/presentation.py @@ -372,19 +372,27 @@ 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, - "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..da6d3b5d 100644 --- a/src/nwp500/converters.py +++ b/src/nwp500/converters.py @@ -15,10 +15,41 @@ "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, not in Watt-hours. +#: +#: ``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. 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 +#: 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 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 + def device_bool_to_python(value: Any) -> bool: """Convert device boolean representation to Python bool. @@ -103,25 +134,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..e34f86b6 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 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." + ), 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,16 @@ 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. " + "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( @@ -894,6 +915,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/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..1660e097 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,77 @@ 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 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): - """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. + + 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) + + 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..1cf17c3b 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -273,3 +273,90 @@ 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 + + +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()