From 9ceac47945ddb00fe473f563460bee60b1ea24b8 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Sun, 6 Sep 2026 08:56:34 -0700 Subject: [PATCH 1/2] Document the TOU recovery cap Under an active TOU schedule the NWP500 stops a heat-pump recovery at about 90% dhw_charge_per rather than running to the setpoint, leaving tank_lower_temperature ~1.9 degC below hp_lower_off_temp_setting. Nothing in the protocol announces it and no error is raised, so code that waits for the setpoint waits forever. Measured over 333 recovery cycles (15-480 min) from 2025-12-28 to 2026-09-06 on one unit. 196 reached the lower-off setpoint; 137 did not, and their shortfall is bimodal rather than a spread: median 3.42 degF with 59 cycles at exactly that value, then a tail past 8 degF that is ordinary interruption. 3.42 degF is 1.9 degC, 19 steps of the 0.1 degC probe quantum, and it holds across eight setpoints from 140.0 to 147.2 degF and across all nine months observed -- an offset in the control logic, not a proportional or seasonal effect. tou_status separates the two populations completely: 59/59 of the capped cycles had it True with tou_override_status False, against 98/196 of the cycles that reached setpoint. All 59 ran in HEAT_PUMP mode, so this is not ENERGY_SAVER or VACATION behaviour. Two candidate mechanisms were tested and refuted. The upper zone did not satisfy its own cut-out first (tank_upper_temperature reached hp_upper_off_temp_setting on 2 of 137, against 179 of 196 reached cycles), and the device does not terminate on outlet temperature (dhw_outlet_temperature reached the setpoint on 2 of 136). The page records what is not established as well: tou_status True is necessary but not sufficient, since 98 cycles reached setpoint with it True, and the 90% ceiling is one unit under one schedule -- whether it is fixed in firmware, derived from the schedule, or configurable is unknown. Also notes that tou_status emits sub-second `unknown` blips on reconnect and that a low change count means the state was held, not that data was lost: the month with 5 changes was the month TOU was active nearly continuously. --- docs/explanation/index.rst | 1 + docs/explanation/tou-recovery-cap.rst | 154 ++++++++++++++++++++++++++ src/nwp500/models/status.py | 10 +- 3 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 docs/explanation/tou-recovery-cap.rst diff --git a/docs/explanation/index.rst b/docs/explanation/index.rst index 56fd465..64a5a97 100644 --- a/docs/explanation/index.rst +++ b/docs/explanation/index.rst @@ -10,4 +10,5 @@ Understanding-oriented deep dives into the library's design and advanced feature advanced-features architecture tank-energy + tou-recovery-cap unknown-values diff --git a/docs/explanation/tou-recovery-cap.rst b/docs/explanation/tou-recovery-cap.rst new file mode 100644 index 0000000..2c88d99 --- /dev/null +++ b/docs/explanation/tou-recovery-cap.rst @@ -0,0 +1,154 @@ +========================== +The TOU Recovery Cap +========================== + +When a Time-of-Use schedule is active, the NWP500 stops a heat-pump +recovery at about **90 % charge** instead of running to the setpoint. The +tank finishes roughly 1.9 degC below ``hp_lower_off_temp_setting`` and +stays there. Nothing in the protocol announces this, and no error is +raised - the compressor simply stops early. + +This matters for anything that waits for the tank to reach its setpoint: +under an active TOU window that instant never arrives. + +.. contents:: + :local: + :depth: 2 + + +The short version +================= + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Observation + - Value + * - Charge at termination + - ``dhw_charge_per`` = 90 % (p10 89, p90 91). Never reaches 100. + * - Shortfall below setpoint + - ``tank_lower_temperature`` finishes 1.9 degC (3.42 degF) below + ``hp_lower_off_temp_setting`` + * - Precondition + - ``tou_status`` True **and** ``tou_override_status`` False + * - Frequency + - 59 of 333 recoveries (18 %) over 260 days + +A normal recovery, for contrast, ends with ``dhw_charge_per`` at 100 % +(p10 99) and the lower probe at or above the setpoint. + + +Evidence +======== + +Measured across 333 heat-pump recovery cycles of 15-480 minutes, from +2025-12-28 to 2026-09-06, on one unit. 196 reached the lower-off +setpoint; 137 did not. + +The shortfall of the 137 is **bimodal**, not a spread: + +.. code:: text + + shortfall below hp_lower_off_temp_setting, cycles that never reached it + + min 0.18 p10 1.66 p25 3.42 median 3.42 p75 4.32 + p90 31.03 max 58.86 (degF) + + most common values: 3.42 degF x 59 cycles 3.24 x 6 3.60 x 6 + +The spike at 3.42 degF is 1.9 degC exactly, and 19 steps of the 0.1 degC +tank-probe quantum. It holds across eight different setpoints from 140.0 +to 147.2 degF and appears in all nine months observed, so it is an +offset in the control logic rather than a proportional effect or a +seasonal one. The long tail beyond 8 degF is ordinary interruption - +a draw starting mid-recovery, a mode change - and is unrelated. + +``tou_status`` separates the two populations completely: + +.. list-table:: + :header-rows: 1 + :widths: 34 14 26 26 + + * - Population + - n + - ``tou_status`` True + - ``tou_override_status`` True + * - Stopped 3.42 degF short + - 59 + - **59 / 59 (100 %)** + - 0 / 59 + * - Other unreached + - 78 + - 49 / 78 (63 %) + - 6 / 78 + * - Reached setpoint + - 196 + - 98 / 196 (50 %) + - 27 / 196 + +All 59 ran in ``HEAT_PUMP`` mode throughout, so this is not +``ENERGY_SAVER`` or ``VACATION`` behaviour. + +Two plausible explanations were tested and **refuted**: + +- *The upper zone satisfied its own cut-out first.* No - + ``tank_upper_temperature`` reached ``hp_upper_off_temp_setting`` on + only 2 of 137 unreached cycles, against 179 of 196 reached ones. The + upper probe finishes short too, by a median of 4.5 degF. +- *The device terminates on outlet temperature.* No - + ``dhw_outlet_temperature`` reached the setpoint on only 2 of 136. + + +What is not yet established +=========================== + +``tou_status`` being True is **necessary but not sufficient**. 98 cycles +that did reach the setpoint also had ``tou_status`` True, so a further +condition decides whether the cap binds on a given recovery. The most +likely candidate is whether the TOU period's own energy budget was +constraining at that moment, but this has not been confirmed against the +schedule the device was holding. + +The 90 % figure has been observed on one unit under one TOU schedule. It +is not known whether the ceiling is fixed in firmware, derived from the +schedule, or configurable. + +A note on reading ``tou_status`` over time: the field emits brief +``unknown`` values on integration reconnect - sub-second blips that +return immediately to the value they interrupted - so a raw state history +contains entries that are not transitions. Stores that keep only boolean +values drop these, which is the right behaviour and leaves a faithful +record of the genuine changes. + +Do not read a low record count as lost data. It means the state was +*held*: on the unit measured, ``tou_status`` logged 165 changes in one +month and 5 in another, and the sparse month was the one in which TOU was +active nearly continuously. Forward-filling the genuine transitions is +correct. It can be corroborated against the ``*_diff_temp_setting`` +fields, which toggle away from zero at the start of each TOU window and +back at the end. + + +Working with the cap +==================== + +**Do not wait for the setpoint.** Code that treats "tank reached +``hp_lower_off_temp_setting``" as the completion signal will block +indefinitely on a capped recovery. Watch ``dhw_charge_per`` plateauing, +or the compressor stopping, instead. + +**Do not read a capped cycle as a fault or as degraded capacity.** The +appliance is doing what the schedule told it to. A recovery that ends at +90 % charge with ``tou_status`` True is a normal outcome. + +**When measuring recovery duration, treat capped cycles separately.** +They are not censored observations of a full recovery - they are +complete observations of a different, shorter target. Pooling them with +uncapped recoveries biases any duration estimate downward; discarding +them biases it upward, because capped cycles are systematically shorter +(median 161 minutes against 235 for cycles that ran to setpoint). + +**To force a full recovery,** set ``tou_override_status`` - none of the +59 capped cycles had it set, and 27 of the cycles that ran to setpoint +under an active TOU schedule did. diff --git a/src/nwp500/models/status.py b/src/nwp500/models/status.py index 2cf0f9a..256292d 100644 --- a/src/nwp500/models/status.py +++ b/src/nwp500/models/status.py @@ -201,7 +201,15 @@ class DeviceStatus(NavienBaseModel): tou_status: TouStatus = Field( description=( "Time of Use (TOU) scheduling enabled. " - "True = TOU is active/enabled, False = TOU is disabled" + "True = TOU is active/enabled, False = TOU is disabled. " + "While this is True and tou_override_status is False the " + "device caps a heat-pump recovery at about 90% " + "dhw_charge_per, finishing ~1.9 degC below " + "hp_lower_off_temp_setting rather than reaching it - so code " + "that waits for the setpoint will wait forever. See the " + "'TOU Recovery Cap' explanation page. This field emits " + "sub-second 'unknown' blips on reconnect; a low change count " + "means the state was held, not that data was lost." ) ) dr_override_status: int = Field( From b6c30dc7420170ed4a5a29c90634e14b0c729ca3 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Sun, 6 Sep 2026 09:39:16 -0700 Subject: [PATCH 2/2] Correct the TOU cap precondition: enabled is not in-period tou_status reports only that TOU scheduling is *enabled*. It says nothing about whether an expensive period is in force, and the cap bites only when one is -- outside a period an enabled schedule does nothing. The page presented the flag as the precondition, which is why the numbers read as "necessary but not sufficient": 98 cycles reached the setpoint with it True, because they ran off-peak. The device marks a period itself, applying non-zero *_diff_temp_setting offsets while inside one and reverting them at the end (observed toggling at 21:00 and 04:00 UTC daily). Among tou_status True cycles that marker separates the outcomes: inside a period 42 capped (62 %), 12 reached, 14 other outside a period 16 capped (12 %), 86 reached, 34 other Test it at the cycle *end*, where the cap bites: a recovery that begins off-peak and runs into a period is still capped, and testing the start catches 17 of 59 against 43 testing the end. Still not deterministic -- 12 reached from inside a period and 16 were capped from outside -- and the page now says so, along with the likely cause (the marker is sampled at one instant and a boundary crossed mid-recovery is not captured) and what would settle it (configure_tou_schedule_confirmed reads back the schedule the device holds). --- docs/explanation/tou-recovery-cap.rst | 50 ++++++++++++++++++++++----- src/nwp500/models/status.py | 20 ++++++----- 2 files changed, 54 insertions(+), 16 deletions(-) diff --git a/docs/explanation/tou-recovery-cap.rst b/docs/explanation/tou-recovery-cap.rst index 2c88d99..0b3ae18 100644 --- a/docs/explanation/tou-recovery-cap.rst +++ b/docs/explanation/tou-recovery-cap.rst @@ -31,7 +31,8 @@ The short version - ``tank_lower_temperature`` finishes 1.9 degC (3.42 degF) below ``hp_lower_off_temp_setting`` * - Precondition - - ``tou_status`` True **and** ``tou_override_status`` False + - An expensive TOU period in force, un-overridden. ``tou_status`` + alone is **not** the test - see below. * - Frequency - 59 of 333 recoveries (18 %) over 260 days @@ -64,7 +65,7 @@ offset in the control logic rather than a proportional effect or a seasonal one. The long tail beyond 8 degF is ordinary interruption - a draw starting mid-recovery, a mode change - and is unrelated. -``tou_status`` separates the two populations completely: +``tou_status`` looks like a clean separator, and is not one: .. list-table:: :header-rows: 1 @@ -90,6 +91,38 @@ a draw starting mid-recovery, a mode change - and is unrelated. All 59 ran in ``HEAT_PUMP`` mode throughout, so this is not ``ENERGY_SAVER`` or ``VACATION`` behaviour. +But read that table carefully: 98 cycles reached the setpoint with +``tou_status`` True. **The flag only reports that TOU scheduling is +enabled.** It says nothing about whether the recovery ran inside an +expensive period, and the cap bites only when it did - outside one, an +enabled schedule does nothing at all. + +The device marks a period itself. While inside one it applies non-zero +``*_diff_temp_setting`` offsets and reverts them at the end; on the unit +measured this toggles at 21:00 and 04:00 UTC daily. Using that as the +in-period test, among ``tou_status`` True cycles: + +.. list-table:: + :header-rows: 1 + :widths: 34 22 22 22 + + * - At cycle end + - Capped + - Reached + - Other + * - Inside a period + - **42 (62 %)** + - 12 + - 14 + * - Outside a period + - 16 (12 %) + - 86 + - 34 + +Test it at the moment the cycle *ended*, not when it began: a recovery +that starts off-peak and runs into a period is still capped, and testing +the start catches 17 of 59 against 43 testing the end. + Two plausible explanations were tested and **refuted**: - *The upper zone satisfied its own cut-out first.* No - @@ -103,12 +136,13 @@ Two plausible explanations were tested and **refuted**: What is not yet established =========================== -``tou_status`` being True is **necessary but not sufficient**. 98 cycles -that did reach the setpoint also had ``tou_status`` True, so a further -condition decides whether the cap binds on a given recovery. The most -likely candidate is whether the TOU period's own energy budget was -constraining at that moment, but this has not been confirmed against the -schedule the device was holding. +Being inside a period is a strong predictor but not a deterministic one: +12 cycles reached the setpoint from inside one, and 16 were capped from +outside. Some of that is the marker's own resolution - it is sampled at +the cycle end, and a period boundary crossed mid-recovery is not captured +- but it has not been reconciled against the schedule the device was +actually holding, which ``configure_tou_schedule_confirmed`` can read +back. The 90 % figure has been observed on one unit under one TOU schedule. It is not known whether the ceiling is fixed in firmware, derived from the diff --git a/src/nwp500/models/status.py b/src/nwp500/models/status.py index 256292d..d6f7129 100644 --- a/src/nwp500/models/status.py +++ b/src/nwp500/models/status.py @@ -202,14 +202,18 @@ class DeviceStatus(NavienBaseModel): description=( "Time of Use (TOU) scheduling enabled. " "True = TOU is active/enabled, False = TOU is disabled. " - "While this is True and tou_override_status is False the " - "device caps a heat-pump recovery at about 90% " - "dhw_charge_per, finishing ~1.9 degC below " - "hp_lower_off_temp_setting rather than reaching it - so code " - "that waits for the setpoint will wait forever. See the " - "'TOU Recovery Cap' explanation page. This field emits " - "sub-second 'unknown' blips on reconnect; a low change count " - "means the state was held, not that data was lost." + "This reports only that scheduling is enabled - NOT that an " + "expensive period is currently in force, which is what " + "actually changes behaviour. Inside a period the device caps " + "a heat-pump recovery at about 90% dhw_charge_per, finishing " + "~1.9 degC below hp_lower_off_temp_setting rather than " + "reaching it, so code that waits for the setpoint will wait " + "forever; outside one an enabled schedule does nothing. The " + "device marks a period by applying non-zero " + "*_diff_temp_setting offsets. See the 'TOU Recovery Cap' " + "explanation page. This field emits sub-second 'unknown' " + "blips on reconnect; a low change count means the state was " + "held, not that data was lost." ) ) dr_override_status: int = Field(