nwp500-python 9.5.1__tar.gz → 9.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (342) hide show
  1. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/CHANGELOG.rst +59 -0
  2. {nwp500_python-9.5.1/src/nwp500_python.egg-info → nwp500_python-9.7.0}/PKG-INFO +1 -1
  3. nwp500_python-9.7.0/docs/how-to/calibrate-simulator.rst +337 -0
  4. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/reference/python_api/sim.rst +8 -1
  5. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/setup.cfg +11 -0
  6. nwp500_python-9.7.0/src/nwp500/cli/calibration.py +190 -0
  7. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/sim_adapters.py +299 -63
  8. nwp500_python-9.5.1/src/nwp500/cli/calibration.py → nwp500_python-9.7.0/src/nwp500/sim_calibrate.py +225 -196
  9. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/sim_home_assistant.py +288 -0
  10. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/status_history.py +12 -5
  11. {nwp500_python-9.5.1 → nwp500_python-9.7.0/src/nwp500_python.egg-info}/PKG-INFO +1 -1
  12. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500_python.egg-info/SOURCES.txt +3 -0
  13. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500_python.egg-info/scm_file_list.json +3 -0
  14. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500_python.egg-info/scm_version.json +2 -2
  15. nwp500_python-9.7.0/tests/test_cli_calibrate.py +217 -0
  16. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_sim_adapters.py +45 -3
  17. nwp500_python-9.5.1/tests/test_cli_calibrate.py → nwp500_python-9.7.0/tests/test_sim_calibrate.py +119 -157
  18. nwp500_python-9.7.0/tests/test_sim_change_history.py +527 -0
  19. nwp500_python-9.5.1/docs/how-to/calibrate-simulator.rst +0 -214
  20. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/.coveragerc +0 -0
  21. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/.github/RESOLVING_PR_COMMENTS.md +0 -0
  22. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/.github/copilot-instructions.md +0 -0
  23. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/.github/workflows/ci.yml +0 -0
  24. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/.github/workflows/release.yml +0 -0
  25. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/.gitignore +0 -0
  26. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/.pre-commit-config.yaml +0 -0
  27. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/.readthedocs.yml +0 -0
  28. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/AUTHORS.rst +0 -0
  29. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/CONTRIBUTING.rst +0 -0
  30. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/LICENSE.txt +0 -0
  31. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/Makefile +0 -0
  32. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/README.rst +0 -0
  33. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/RELEASE.md +0 -0
  34. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/Makefile +0 -0
  35. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/_static/.gitignore +0 -0
  36. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/conf.py +0 -0
  37. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/explanation/architecture.rst +0 -0
  38. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/explanation/draws.rst +0 -0
  39. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/explanation/heating-elements.rst +0 -0
  40. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/explanation/index.rst +0 -0
  41. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/explanation/recovery-speed.rst +0 -0
  42. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/explanation/simulator.rst +0 -0
  43. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/explanation/standby-loss.rst +0 -0
  44. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/explanation/tank-energy.rst +0 -0
  45. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/explanation/tou-recovery-cap.rst +0 -0
  46. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/explanation/unknown-values.rst +0 -0
  47. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/explanation/what-starts-a-recovery.rst +0 -0
  48. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/how-to/authenticate.rst +0 -0
  49. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/how-to/auto-recovery.rst +0 -0
  50. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/how-to/bluetooth.rst +0 -0
  51. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/how-to/diagnose-mqtt.rst +0 -0
  52. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/how-to/home-assistant.rst +0 -0
  53. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/how-to/index.rst +0 -0
  54. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/how-to/maintenance.rst +0 -0
  55. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/how-to/manage-units.rst +0 -0
  56. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/how-to/monitor-status.rst +0 -0
  57. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/how-to/optimize-tou.rst +0 -0
  58. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/how-to/queue-commands.rst +0 -0
  59. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/how-to/schedule-operation.rst +0 -0
  60. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/how-to/simulate-heater.rst +0 -0
  61. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/how-to/track-energy.rst +0 -0
  62. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/index.rst +0 -0
  63. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/openapi.yaml +0 -0
  64. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/project/authors.rst +0 -0
  65. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/project/changelog.rst +0 -0
  66. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/project/contributing.rst +0 -0
  67. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/project/history.rst +0 -0
  68. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/project/license.rst +0 -0
  69. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/reference/configuration.rst +0 -0
  70. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/reference/enumerations.rst +0 -0
  71. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/reference/index.rst +0 -0
  72. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/reference/installation.rst +0 -0
  73. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/reference/protocol/data_conversions.rst +0 -0
  74. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/reference/protocol/device_features.rst +0 -0
  75. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/reference/protocol/device_status.rst +0 -0
  76. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/reference/protocol/error_codes.rst +0 -0
  77. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/reference/protocol/mqtt_protocol.rst +0 -0
  78. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/reference/protocol/quick_reference.rst +0 -0
  79. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/reference/protocol/rest_api.rst +0 -0
  80. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/reference/python_api/api_client.rst +0 -0
  81. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/reference/python_api/auth_client.rst +0 -0
  82. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/reference/python_api/cli.rst +0 -0
  83. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/reference/python_api/events.rst +0 -0
  84. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/reference/python_api/exceptions.rst +0 -0
  85. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/reference/python_api/models.rst +0 -0
  86. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/reference/python_api/mqtt_client.rst +0 -0
  87. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/reference/simulator-inputs.rst +0 -0
  88. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/requirements.txt +0 -0
  89. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/docs/tutorials/getting-started.rst +0 -0
  90. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/.ruff.toml +0 -0
  91. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/README.md +0 -0
  92. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/advanced/air_filter_reset.py +0 -0
  93. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/advanced/anti_legionella.py +0 -0
  94. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/advanced/auto_recovery.py +0 -0
  95. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/advanced/combined_callbacks.py +0 -0
  96. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/advanced/demand_response.py +0 -0
  97. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/advanced/device_capabilities.py +0 -0
  98. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/advanced/device_status_debug.py +0 -0
  99. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/advanced/energy_analytics.py +0 -0
  100. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/advanced/error_code_demo.py +0 -0
  101. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/advanced/firmware_payload_capture.py +0 -0
  102. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/advanced/mqtt_diagnostics.py +0 -0
  103. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/advanced/power_control.py +0 -0
  104. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/advanced/recirculation_control.py +0 -0
  105. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/advanced/reconnection_demo.py +0 -0
  106. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/advanced/reservation_schedule.py +0 -0
  107. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/advanced/simple_auto_recovery.py +0 -0
  108. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/advanced/token_restoration.py +0 -0
  109. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/advanced/tou_openei.py +0 -0
  110. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/advanced/tou_schedule.py +0 -0
  111. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/advanced/water_reservation.py +0 -0
  112. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/beginner/01_authentication.py +0 -0
  113. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/beginner/02_list_devices.py +0 -0
  114. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/beginner/03_get_status.py +0 -0
  115. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/beginner/04_set_temperature.py +0 -0
  116. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/intermediate/advanced_auth_patterns.py +0 -0
  117. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/intermediate/command_queue.py +0 -0
  118. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/intermediate/device_status_callback.py +0 -0
  119. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/intermediate/error_handling.py +0 -0
  120. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/intermediate/event_driven_control.py +0 -0
  121. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/intermediate/improved_auth.py +0 -0
  122. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/intermediate/legacy_auth_constructor.py +0 -0
  123. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/intermediate/mqtt_realtime_monitoring.py +0 -0
  124. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/intermediate/periodic_requests.py +0 -0
  125. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/intermediate/set_mode.py +0 -0
  126. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/intermediate/vacation_mode.py +0 -0
  127. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/mask.py +0 -0
  128. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/simulator/lower-setpoint.json +0 -0
  129. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/simulator/plan.json +0 -0
  130. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/simulator/scenario.json +0 -0
  131. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/testing/periodic_device_info.py +0 -0
  132. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/testing/simple_periodic_info.py +0 -0
  133. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/testing/test_api_client.py +0 -0
  134. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/testing/test_mqtt_connection.py +0 -0
  135. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/testing/test_mqtt_messaging.py +0 -0
  136. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/examples/testing/test_periodic_minimal.py +0 -0
  137. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/pyproject.toml +0 -0
  138. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/scripts/README.md +0 -0
  139. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/scripts/bump_version.py +0 -0
  140. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/scripts/diagnose_mqtt_connection.py +0 -0
  141. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/scripts/extract_changelog.py +0 -0
  142. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/scripts/format.py +0 -0
  143. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/scripts/lint.py +0 -0
  144. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/scripts/setup-dev.py +0 -0
  145. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/scripts/validate_version.py +0 -0
  146. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/setup.py +0 -0
  147. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/__init__.py +0 -0
  148. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/_base.py +0 -0
  149. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/api_client.py +0 -0
  150. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/auth.py +0 -0
  151. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/ble/__init__.py +0 -0
  152. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/ble/__main__.py +0 -0
  153. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/ble/client.py +0 -0
  154. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/ble/mqtt_adapter.py +0 -0
  155. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/ble/protocol.py +0 -0
  156. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/cli/__init__.py +0 -0
  157. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/cli/__main__.py +0 -0
  158. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/cli/ble_backend.py +0 -0
  159. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/cli/handlers.py +0 -0
  160. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/cli/monitoring.py +0 -0
  161. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/cli/output_formatters.py +0 -0
  162. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/cli/presentation.py +0 -0
  163. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/cli/recording.py +0 -0
  164. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/cli/rich_output.py +0 -0
  165. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/cli/simulate.py +0 -0
  166. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/cli/token_storage.py +0 -0
  167. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/command_decorators.py +0 -0
  168. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/config.py +0 -0
  169. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/converters.py +0 -0
  170. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/device_capabilities.py +0 -0
  171. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/device_info_cache.py +0 -0
  172. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/encoding.py +0 -0
  173. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/enums.py +0 -0
  174. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/events.py +0 -0
  175. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/exceptions.py +0 -0
  176. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/factory.py +0 -0
  177. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/field_factory.py +0 -0
  178. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/models/__init__.py +0 -0
  179. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/models/_converters.py +0 -0
  180. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/models/account.py +0 -0
  181. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/models/device.py +0 -0
  182. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/models/diagnostics.py +0 -0
  183. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/models/energy.py +0 -0
  184. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/models/feature.py +0 -0
  185. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/models/firmware.py +0 -0
  186. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/models/maintenance.py +0 -0
  187. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/models/mqtt_models.py +0 -0
  188. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/models/schedule.py +0 -0
  189. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/models/status.py +0 -0
  190. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/models/tou.py +0 -0
  191. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/mqtt/__init__.py +0 -0
  192. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/mqtt/_control_commands.py +0 -0
  193. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/mqtt/_device_subscriptions.py +0 -0
  194. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/mqtt/client.py +0 -0
  195. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/mqtt/command_queue.py +0 -0
  196. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/mqtt/connection.py +0 -0
  197. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/mqtt/control.py +0 -0
  198. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/mqtt/diagnostics.py +0 -0
  199. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/mqtt/periodic.py +0 -0
  200. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/mqtt/reconnection.py +0 -0
  201. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/mqtt/state_tracker.py +0 -0
  202. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/mqtt/subscriptions.py +0 -0
  203. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/mqtt/types.py +0 -0
  204. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/mqtt/utils.py +0 -0
  205. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/mqtt_events.py +0 -0
  206. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/openei.py +0 -0
  207. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/py.typed +0 -0
  208. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/reservations.py +0 -0
  209. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/sim/__init__.py +0 -0
  210. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/sim/api.py +0 -0
  211. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/sim/attribution.py +0 -0
  212. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/sim/book.py +0 -0
  213. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/sim/calibration.py +0 -0
  214. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/sim/circulation.py +0 -0
  215. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/sim/codec.py +0 -0
  216. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/sim/cold_supply.py +0 -0
  217. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/sim/controller.py +0 -0
  218. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/sim/decay.py +0 -0
  219. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/sim/defaults.py +0 -0
  220. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/sim/delivery.py +0 -0
  221. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/sim/draw_forecast.py +0 -0
  222. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/sim/engine.py +0 -0
  223. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/sim/extensions.py +0 -0
  224. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/sim/heat_pump_recovery.py +0 -0
  225. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/sim/heating_trajectory.py +0 -0
  226. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/sim/inputs.py +0 -0
  227. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/sim/location_air.py +0 -0
  228. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/sim/meter.py +0 -0
  229. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/sim/observed.py +0 -0
  230. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/sim/params.py +0 -0
  231. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/sim/physics/__init__.py +0 -0
  232. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/sim/physics/compressor.py +0 -0
  233. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/sim/physics/draw.py +0 -0
  234. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/sim/physics/element.py +0 -0
  235. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/sim/physics/refrigerant.py +0 -0
  236. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/sim/physics/tank.py +0 -0
  237. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/sim/profile.py +0 -0
  238. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/sim/ready.py +0 -0
  239. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/sim/record.py +0 -0
  240. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/sim/result.py +0 -0
  241. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/sim/schedules.py +0 -0
  242. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/sim/tank_physics.py +0 -0
  243. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/sim/tou_triggers.py +0 -0
  244. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/sim/units.py +0 -0
  245. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/sim_live.py +0 -0
  246. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/temperature.py +0 -0
  247. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/topic_builder.py +0 -0
  248. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/tou_schedule.py +0 -0
  249. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/unit_system.py +0 -0
  250. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500/utils.py +0 -0
  251. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500_python.egg-info/dependency_links.txt +0 -0
  252. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500_python.egg-info/entry_points.txt +0 -0
  253. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500_python.egg-info/not-zip-safe +0 -0
  254. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500_python.egg-info/requires.txt +0 -0
  255. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/src/nwp500_python.egg-info/top_level.txt +0 -0
  256. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/_sim_site.py +0 -0
  257. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/conftest.py +0 -0
  258. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/fixtures/sim/engine_golden.json +0 -0
  259. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/fixtures/sim/heater_params.json +0 -0
  260. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/fixtures/sim/heater_params_fitted.json +0 -0
  261. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_api_endpoints_201.py +0 -0
  262. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_api_helpers.py +0 -0
  263. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_app_queries.py +0 -0
  264. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_auth.py +0 -0
  265. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_auth_session_lifecycle.py +0 -0
  266. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_auth_session_refresh.py +0 -0
  267. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_ble_mqtt_adapter.py +0 -0
  268. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_ble_protocol.py +0 -0
  269. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_bug_fixes.py +0 -0
  270. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_canonical_schedule.py +0 -0
  271. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_cli_basic.py +0 -0
  272. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_cli_commands.py +0 -0
  273. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_cli_simulate.py +0 -0
  274. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_cli_tristate_presentation.py +0 -0
  275. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_command_decorators.py +0 -0
  276. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_command_queue.py +0 -0
  277. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_device_capabilities.py +0 -0
  278. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_device_info_cache.py +0 -0
  279. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_device_rest_models.py +0 -0
  280. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_events.py +0 -0
  281. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_exceptions.py +0 -0
  282. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_model_converters.py +0 -0
  283. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_models.py +0 -0
  284. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_mqtt_clean_session_resume.py +0 -0
  285. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_mqtt_client_init.py +0 -0
  286. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_mqtt_control_fail.py +0 -0
  287. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_mqtt_events.py +0 -0
  288. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_mqtt_hypothesis.py +0 -0
  289. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_mqtt_last_will.py +0 -0
  290. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_mqtt_reconnection.py +0 -0
  291. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_mqtt_reconnection_storm.py +0 -0
  292. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_mqtt_reliability.py +0 -0
  293. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_multi_device.py +0 -0
  294. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_navilink_201_enums.py +0 -0
  295. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_openei.py +0 -0
  296. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_protocol_correctness.py +0 -0
  297. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_public_api.py +0 -0
  298. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_reservation_validation.py +0 -0
  299. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_reservations.py +0 -0
  300. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_sim_api.py +0 -0
  301. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_sim_book.py +0 -0
  302. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_sim_book_history.py +0 -0
  303. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_sim_calibration.py +0 -0
  304. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_sim_circulation.py +0 -0
  305. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_sim_codec.py +0 -0
  306. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_sim_cold_supply.py +0 -0
  307. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_sim_controller.py +0 -0
  308. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_sim_defaults.py +0 -0
  309. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_sim_draw_forecast.py +0 -0
  310. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_sim_draw_loss.py +0 -0
  311. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_sim_element.py +0 -0
  312. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_sim_element_fit.py +0 -0
  313. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_sim_forecast.py +0 -0
  314. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_sim_golden.py +0 -0
  315. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_sim_home_assistant.py +0 -0
  316. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_sim_hot_water.py +0 -0
  317. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_sim_import_contract.py +0 -0
  318. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_sim_live.py +0 -0
  319. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_sim_location_air.py +0 -0
  320. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_sim_meter.py +0 -0
  321. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_sim_no_clock_no_state.py +0 -0
  322. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_sim_observed.py +0 -0
  323. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_sim_profile.py +0 -0
  324. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_sim_readiness.py +0 -0
  325. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_sim_readiness_fit.py +0 -0
  326. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_sim_ready.py +0 -0
  327. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_sim_ready_api.py +0 -0
  328. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_sim_replay.py +0 -0
  329. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_sim_schedules.py +0 -0
  330. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_sim_tank_physics.py +0 -0
  331. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_status_history.py +0 -0
  332. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_temperature_converters.py +0 -0
  333. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_threading_model.py +0 -0
  334. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_token_storage.py +0 -0
  335. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_tou_api.py +0 -0
  336. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_tou_schedule.py +0 -0
  337. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_unit_switching.py +0 -0
  338. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_unit_system_process_wide.py +0 -0
  339. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_utility_modules.py +0 -0
  340. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tests/test_utils.py +0 -0
  341. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/tox.ini +0 -0
  342. {nwp500_python-9.5.1 → nwp500_python-9.7.0}/uv.lock +0 -0
@@ -2,6 +2,65 @@
2
2
  Changelog
3
3
  =========
4
4
 
5
+ Version 9.7.0 (2026-10-21)
6
+ ==========================
7
+
8
+ Added
9
+ -----
10
+ - **Calibrate from Home Assistant's history** (#179), for a heater with
11
+ no recording of its statuses.
12
+ ``sim_home_assistant.read_heater_history`` reads the heater's own
13
+ entities a day at a time: the probes, what is heating, the in-use
14
+ flag, the stop threshold, the operation mode, the error code, the
15
+ power, the energy to setpoint and the heater's own air, each number
16
+ from the unit it was recorded in. An entity that changes on every
17
+ response (``heard``) says when the heater was observed.
18
+ ``sim_calibrate.calibrate_changes`` fits on what it read, and gives
19
+ the same ``Calibrated`` and ``report()`` as ``calibrate``.
20
+ - ``sim_adapters.ChangeRows`` and ``sim_adapters.change_history``: the
21
+ calibration frame from each value's changes and the instants the
22
+ heater was heard from, for any store of that history.
23
+ - ``History.states``: every row's state as written, numbers or not.
24
+ ``HomeAssistantClient.read_history`` fills it (``parse_states``).
25
+ - ``sim_home_assistant.to_w`` and ``to_wh``.
26
+
27
+ Version 9.6.0 (2026-10-21)
28
+ ==========================
29
+
30
+ Added
31
+ -----
32
+ - **Calibration outside the CLI** (``nwp500.sim_calibrate``, #177).
33
+ ``calibrate``, ``Calibrated``, ``report``, ``Term`` and ``max_gap``
34
+ moved here from ``nwp500.cli.calibration`` so an application can
35
+ calibrate without ``click`` or ``rich``. ``calibrate`` takes
36
+ ``HeaterParams`` or a parameters document as ``params`` and raises
37
+ ``CalibrationInputError`` (a ``ValueError``). Its notes no longer name
38
+ the command's options; ``nwp-cli simulate calibrate`` adds them.
39
+ - ``status_history.iter_read`` yields a recording's statuses one at a
40
+ time.
41
+ - ``sim_adapters.add_inputs`` adds the air, its humidity, a meter's
42
+ draws, the circulation's runs and the unobserved gaps to a frame
43
+ ``sim_adapters.history`` built.
44
+
45
+ Changed
46
+ -------
47
+ - **A long history calibrates in bounded memory** (#177).
48
+ ``sim_adapters.history`` reads its statuses one at a time and keeps
49
+ only the frame's values, and ``sim_calibrate.calibrate`` takes the
50
+ record's span and gaps from the frame, so a generator over day files
51
+ is read once. 60 days of statuses (about 540,000) now take about
52
+ 150 MB instead of about 6 GB of parsed statuses. The drawing and
53
+ circulating columns are computed per span instead of per row and
54
+ span.
55
+
56
+ Documentation
57
+ -------------
58
+ - The calibration how-to says the recorder writes every status the
59
+ heater sends and what that costs on disk, shows calibrating from
60
+ day files with ``sim_calibrate``, adds calibrating in an
61
+ application, and says what Home Assistant's 10-day retention means
62
+ for a longer record.
63
+
5
64
  Version 9.5.1 (2026-10-21)
6
65
  ==========================
7
66
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nwp500-python
3
- Version: 9.5.1
3
+ Version: 9.7.0
4
4
  Summary: A library for controlling Navien NWP500 Water Heaters via NaviLink
5
5
  Home-page: https://github.com/eman/nwp500-python
6
6
  Author: Emmanuel Levijarvi
@@ -0,0 +1,337 @@
1
+ ======================================
2
+ Calibrate the Simulator to Your Heater
3
+ ======================================
4
+
5
+ The simulator's default parameters are one NWP500-65's, fitted at one
6
+ installation. Record what your own heater reports for a few weeks and
7
+ fit its standby loss, its compressor and its power to that record.
8
+
9
+ .. contents::
10
+ :local:
11
+ :depth: 1
12
+
13
+
14
+ Record the history
15
+ ==================
16
+
17
+ ``nwp-cli simulate record`` appends every status the heater sends to a
18
+ file until you stop it (Ctrl+C): the answers to the status requests it
19
+ makes every 30 seconds (``--every``), and the statuses the heater sends
20
+ between them. One heater sent about 370 an hour, about half of them
21
+ identical to the one before. A line is about 2.3 kB, so a day of
22
+ recording is about 20 MB, or 0.2 MB gzipped. The command needs the
23
+ ``cli`` extra and no other:
24
+
25
+ .. code-block:: bash
26
+
27
+ nwp-cli simulate record history.jsonl
28
+ nwp-cli simulate record history.jsonl --every 60
29
+
30
+ Each line is one status, as JSON: the instant it arrived and the
31
+ status payload as the heater sent it:
32
+
33
+ .. code-block:: json
34
+
35
+ {"t": "2026-10-18T23:09:10.298+00:00", "status": {"dhwUse": 1, "...": 0}}
36
+
37
+ Stopping and starting again appends to the same file, so the record
38
+ can have gaps. The heater may have heated or delivered water during a
39
+ gap, so ``calibrate`` takes any spacing between statuses of more than
40
+ 4 times the record's usual spacing (and at least 5 minutes) as
41
+ unobserved. No quiet window and no recovery spans such a gap, and the
42
+ report counts them. The identical statuses are what show the heater was
43
+ observed between changes, so keep them.
44
+
45
+ Write the file any other way you like (an MQTT callback, an export
46
+ from a home-automation recorder) as long as each line has this shape:
47
+ ``nwp500.status_history.line(instant, raw_status)`` writes one.
48
+ ``status_history.read(lines)`` reads them back as
49
+ ``(instant, DeviceStatus)`` pairs, and ``status_history.iter_read(lines)``
50
+ yields them one at a time, for a long recording.
51
+
52
+ Record through quiet nights and full recoveries. The standby fit needs
53
+ windows of 3 hours or more with no heating and no hot water in use. The
54
+ compressor fit needs at least 5 recoveries that reached their target,
55
+ and holds out the latest fifth once there are 10. The power fits need
56
+ 10 compressor cycles.
57
+
58
+
59
+ Record the air beside the heater
60
+ ================================
61
+
62
+ The standby loss is the heat the tank loses to the air it stands in,
63
+ so the fit needs that air over the whole record. The heater does not
64
+ read it while idle: its sensor sits at the heat pump's intake, and it
65
+ reads the air the heater stands in only while the evaporator fan runs.
66
+ Idle, it reads the cabinet, about 6 °F warm. So give one of these:
67
+
68
+ - **A sensor beside the heater:** a CSV file of its readings, an ISO
69
+ 8601 instant with its UTC offset and a temperature in your preferred
70
+ unit on each row (a header row is skipped):
71
+
72
+ .. code-block:: text
73
+
74
+ time,temperature
75
+ 2026-10-18T00:00:00-07:00,61.2
76
+ 2026-10-18T00:05:00-07:00,61.0
77
+
78
+ - **A constant,** for a heater in conditioned space that holds one
79
+ temperature.
80
+
81
+ - **Home Assistant:** ``--sources FILE``, the same sources file as
82
+ ``simulate run`` takes (:doc:`simulate-heater`). The command reads the
83
+ air beside the heater and its humidity over the record. It also reads
84
+ the meter's draws and the circulation's runs, which make the quiet
85
+ test exact: the heater's own "hot water in use" flag also fires on
86
+ circulation. The air is used only over the span its history covers,
87
+ and not while its sensor was ``unavailable``; the humidity takes the
88
+ 50 % default outside that span, and the report says how much that
89
+ is. Home Assistant keeps 10 days of history by default, so of a
90
+ 60-day record only the last 10 days have the air, and the standby
91
+ loss is fitted from those alone. For the whole record, raise the
92
+ recorder's ``purge_keep_days`` to cover it before you start
93
+ recording, or give the air another way (``--air``). Where the
94
+ meter's or the circulation's history doesn't cover the whole record,
95
+ the heater's own flag is used instead. ``--air``, ``--ambient`` and
96
+ ``--humidity`` override the sources.
97
+
98
+ Without the air, the standby loss keeps the base's value and the report
99
+ says so. When you give the air, the recoveries use it too; without it,
100
+ they use the heater's own reading while the compressor runs.
101
+
102
+ ``--humidity`` takes the same kind of file, in %RH, for the
103
+ evaporator; without it the command gives the fits 50 %.
104
+
105
+
106
+ Calibrate
107
+ =========
108
+
109
+ ``nwp-cli simulate calibrate`` needs the ``cli`` and ``sim`` extras. It
110
+ reads the history and prints what each fit found. With ``-o`` it also
111
+ writes the fitted parameters, as a ``HeaterParams`` document:
112
+
113
+ .. code-block:: bash
114
+
115
+ nwp-cli simulate calibrate history.jsonl --air air.csv -o params.json
116
+ nwp-cli simulate calibrate history.jsonl --ambient 68 -o params.json
117
+ nwp-cli simulate calibrate history.jsonl --sources sources.toml -o params.json
118
+ nwp-cli simulate run --params params.json
119
+
120
+ It refits the 65 gal defaults. For another tank size, start from that
121
+ size's parameters with ``--params``:
122
+
123
+ .. code-block:: python
124
+
125
+ import json
126
+ from nwp500 import sim
127
+ from nwp500.sim.params import params_to_document
128
+
129
+ with open("base.json", "w") as f:
130
+ json.dump(params_to_document(sim.default_params(3)), f) # 80 gal
131
+
132
+ The report has a row for each term. Each row gives the term's value,
133
+ the evidence it was fitted from and its error, and whether it was
134
+ fitted or kept the base's value, and why:
135
+
136
+ .. list-table::
137
+ :header-rows: 1
138
+ :widths: 30 70
139
+
140
+ * - Term
141
+ - Fitted from
142
+ * - Standby loss and its exponent
143
+ - Quiet windows of 3 h or more, starting 45 minutes after any
144
+ heating or hot water in use. A window counts only if its fit
145
+ tracks the probes within 2 °F. The exponent is fitted only when
146
+ the windows span two 20 °F bands of tank-to-air difference; it
147
+ is held at 0 (a constant loss) otherwise.
148
+ * - Zone gap time constant
149
+ - How fast the upper and lower probes' gap closes over quiet
150
+ windows; it needs 20 windows that open with a gap of 2 °F or
151
+ more.
152
+ * - Compressor constant, evaporator approach
153
+ - Heat-pump recoveries that reached their target, against the loss
154
+ just fitted. The error is the mean absolute error of the
155
+ recovery durations, in minutes, also on the held-out recoveries.
156
+ A fit pinned on a bound is reported and not applied.
157
+ * - Input power scale, COP scale, start-up energy, COP lift
158
+ - The heater's own power readings over each compressor cycle,
159
+ against the compressor just fitted. The COP uses only cycles
160
+ with no hot water in use from 30 minutes before them to their
161
+ end.
162
+
163
+ ``Unfitted`` lists every term that kept the base's value. The answers
164
+ those terms reach say so in their ``reasons`` (for example
165
+ ``standby_uncalibrated``). The ``book``, ``readiness`` and ``draw``
166
+ terms are always listed, because they are fitted only from a hot-water
167
+ meter's draws, which the command does not take (see below).
168
+
169
+
170
+ From Python
171
+ ===========
172
+
173
+ ``nwp500.sim_calibrate.calibrate`` does what the command does, and
174
+ ``report`` gives the command's rows as ``Term`` values. It takes the
175
+ statuses one at a time, so a recording kept as a file per day can be
176
+ read in order without holding every status:
177
+
178
+ .. code-block:: python
179
+
180
+ from pathlib import Path
181
+
182
+ from nwp500 import sim, sim_calibrate, status_history
183
+
184
+ def recorded(days):
185
+ for day in days:
186
+ with day.open(encoding="utf-8") as f:
187
+ yield from status_history.iter_read(f)
188
+
189
+ done = sim_calibrate.calibrate(
190
+ recorded(sorted(Path("history").glob("*.jsonl"))),
191
+ air=air_readings, # [(instant, temperature), ...]
192
+ humidity=humidity_readings, # [(instant, %RH), ...]
193
+ params=sim.default_params(), # or your own HeaterParams
194
+ )
195
+ params = done.calibration.params
196
+ for term in sim_calibrate.report(done):
197
+ print(term.name, term.value, "fitted" if term.fitted else term.why)
198
+
199
+ Give ``ambient`` instead of ``air`` for a constant, or neither: the
200
+ standby loss then keeps the base's value. Without ``humidity`` the fits
201
+ get 50 %. An input it cannot use raises ``CalibrationInputError``.
202
+
203
+ On a desktop computer, 60 days of statuses (about 540,000) took about
204
+ 40 seconds to read and 10 to fit, in about 150 MB.
205
+
206
+ Calibrate in an application
207
+ ---------------------------
208
+
209
+ An application that records the heater itself, such as a Home
210
+ Assistant integration, can calibrate the same way:
211
+
212
+ 1. Write each status it receives with ``status_history.line``, a file
213
+ per UTC day, and keep as many days as the fit should cover.
214
+ 2. Read the air beside the heater and its humidity over the same span
215
+ with ``sim_home_assistant.record_inputs(client, entities, start,
216
+ end)``, and pass the result as ``record=``. It also brings the
217
+ meter's draws and the circulation's runs. Any client with the same
218
+ ``read_history`` as ``HomeAssistantClient`` works. The recorder's
219
+ retention limits the air, as above.
220
+ 3. Call ``sim_calibrate.calibrate`` in a worker thread. It takes the
221
+ gaps from the statuses' own spacing: a span longer than 4 times
222
+ their usual spacing, and at least 5 minutes, is unobserved.
223
+ 4. Store ``params_to_document(done.calibration.params)``, and show the
224
+ ``report(done)`` rows and ``done.notes``.
225
+
226
+ ``sim.calibration.calibrate`` underneath takes the frame
227
+ ``sim_adapters.history`` builds; ``sim_adapters.add_inputs`` adds the
228
+ air, the humidity, a meter's draws and the circulation's runs to it.
229
+ From there you can also give the draws that price the heat each
230
+ cycle's draws took. The fits for the readiness terms, the heat book,
231
+ the draw response and the elements also run there: see "Improve
232
+ accuracy" and "Fit the elements" in :doc:`simulate-heater`.
233
+
234
+
235
+ Calibrate from Home Assistant's history
236
+ =======================================
237
+
238
+ A heater whose integration writes its values to Home Assistant can be
239
+ calibrated from that history, with no recording of its statuses. Home
240
+ Assistant keeps each entity's changes, and a value that holds writes
241
+ none, so the history also needs an entity that changes every time the
242
+ heater answers, such as the integration's last response time. Its
243
+ changes are when the heater was heard from; a silence between them
244
+ longer than 4 times their usual spacing (and at least 5 minutes) is
245
+ unobserved, as a gap between statuses is.
246
+
247
+ .. code-block:: python
248
+
249
+ from datetime import UTC, datetime, timedelta
250
+
251
+ from nwp500 import sim_calibrate
252
+ from nwp500 import sim_home_assistant as ha
253
+
254
+ entities = ha.HeaterEntities(
255
+ heard="sensor.water_heater_last_mqtt_response",
256
+ upper="sensor.water_heater_tank_upper_temperature",
257
+ lower="sensor.water_heater_tank_lower_temperature",
258
+ compressor="binary_sensor.water_heater_compressor_running",
259
+ upper_element="binary_sensor.water_heater_upper_element",
260
+ lower_element="binary_sensor.water_heater_lower_element",
261
+ heat_source="sensor.water_heater_current_heat_source",
262
+ dhw_in_use="binary_sensor.water_heater_dhw_in_use",
263
+ target="sensor.water_heater_hp_lower_off_temperature",
264
+ operation_mode="sensor.water_heater_current_operation_mode",
265
+ error_code="sensor.water_heater_error_code",
266
+ power="sensor.water_heater_current_power",
267
+ energy_to_setpoint="sensor.water_heater_energy_to_setpoint",
268
+ ambient="sensor.water_heater_ambient_temperature",
269
+ )
270
+ end = datetime.now(UTC)
271
+ start = end - timedelta(days=60)
272
+ async with ha.HomeAssistantClient(url, token) as client:
273
+ history = await ha.read_heater_history(client, entities, start, end)
274
+ record = await ha.record_inputs(client, ha.Entities(air=..., humidity=...),
275
+ start, end)
276
+ done = sim_calibrate.calibrate_changes(
277
+ history.rows, history.observed, record=record, notes=history.notes
278
+ )
279
+
280
+ Use your own entity ids. Only ``heard`` and the two probes are needed;
281
+ leave out what your installation does not record. ``read_heater_history``:
282
+
283
+ - reads a day at a time, so the heartbeat's rows are never all held as
284
+ Home Assistant gives them;
285
+ - takes each number from the unit it was recorded in (°F, °C or K; W or
286
+ kW; Wh or kWh), ``on`` and ``off`` from the binary sensors, and the
287
+ heat source, operation mode and error code by their enum names
288
+ (``HeatSource``, ``CurrentOperationMode``, ``ErrorCode``);
289
+ - takes ``unavailable`` or ``unknown`` on any of the entities as an
290
+ outage of the heater, unobserved (an enum's own ``UNKNOWN`` is a
291
+ value);
292
+ - takes the heat source as the compressor's state where no compressor
293
+ entity is given;
294
+ - notes a value it could not read, and an entity whose history starts
295
+ after ``start``.
296
+
297
+ Give ``anti_legionella`` where it is recorded. Without it an
298
+ anti-legionella cycle is not kept out of the recoveries, and the notes
299
+ say so.
300
+
301
+ Home Assistant keeps 10 days of history by default, so ``start`` reaches
302
+ back only that far unless the recorder's ``purge_keep_days`` is raised
303
+ to cover the record you want to fit. The same is true of the air.
304
+
305
+ Rows from another store of the same history (an export, say) go in as
306
+ ``sim_adapters.ChangeRows``, each value's changes as ``(instant,
307
+ value)`` pairs, with the instants heard from:
308
+ ``sim_calibrate.calibrate_changes(rows, observed, ...)``.
309
+
310
+ On a desktop computer, 60 days with a heartbeat every 10 seconds
311
+ (about 550,000 rows) took about 13 seconds to read, not counting Home
312
+ Assistant's own queries, and 11 to fit, in about 180 MB.
313
+
314
+
315
+ Limitations
316
+ ===========
317
+
318
+ - **The draws.** The heater reports no draw volume, and in recorded
319
+ statuses its inlet and flow readings read 0. The draw terms
320
+ (``book``, ``readiness``, ``draw``) need a hot-water meter and an
321
+ inlet probe.
322
+ - **Circulation the heater does not run.** The quiet test is the
323
+ heater's own "hot water in use" flag, which also fires on
324
+ circulation. A loop's heat that does not trip the flag is invisible
325
+ to the heater and lands in the standby loss. From Python, pass the
326
+ circulation's runs (``record=`` or ``sa.history(...,
327
+ circulating=...)``) to keep them out of the quiet windows.
328
+
329
+
330
+ See also
331
+ ========
332
+
333
+ * :doc:`simulate-heater`: run the simulator with your parameters
334
+ * :doc:`/reference/python_api/sim`: ``sim_calibrate``,
335
+ ``sim.calibration``, ``sim_adapters.history``,
336
+ ``sim_adapters.change_history``, ``status_history`` and
337
+ ``sim_home_assistant.read_heater_history``
@@ -297,6 +297,11 @@ Recorded history
297
297
  .. automodule:: nwp500.status_history
298
298
  :members:
299
299
 
300
+ .. automodule:: nwp500.sim_calibrate
301
+ :members: calibrate, calibrate_changes, Calibrated, report, Term, max_gap,
302
+ CalibrationInputError, DEFAULT_HUMIDITY_PCT, GAP_SAMPLES, MIN_GAP,
303
+ DEFAULT_BASE_NOTE, NO_AIR_NOTE
304
+
300
305
 
301
306
  Home Assistant sources
302
307
  ======================
@@ -304,6 +309,8 @@ Home Assistant sources
304
309
  .. automodule:: nwp500.sim_home_assistant
305
310
  :members: HomeAssistantEnvironment, HomeAssistantClient, record_inputs,
306
311
  RecordInputs, Sources, Entities, sources_from, State, parse_state,
307
- parse_history, parse_outages, History, history_for, parse_forecast,
312
+ parse_history, parse_outages, parse_states, History, history_for,
313
+ parse_forecast, read_heater_history, HeaterEntities, HeaterHistory,
314
+ HISTORY_WINDOW, to_w, to_wh,
308
315
  pump_runs, draw_spans, busy, covers_start, reports_after, percent,
309
316
  fresh, to_f, to_gpm, to_gal, to_rpm, HomeAssistantError
@@ -112,6 +112,16 @@ forbidden_modules =
112
112
  awsiot
113
113
  logging
114
114
 
115
+ [importlinter:contract:calibrate-no-cli]
116
+ name = An application calibrates without the CLI's dependencies
117
+ type = forbidden
118
+ source_modules =
119
+ nwp500.sim_calibrate
120
+ forbidden_modules =
121
+ nwp500.cli
122
+ click
123
+ rich
124
+
115
125
  [importlinter:contract:ble-optional]
116
126
  name = Only nwp500.ble imports bleak, and only nwp-cli's ble backend imports nwp500.ble
117
127
  type = forbidden
@@ -138,6 +148,7 @@ source_modules =
138
148
  nwp500.reservations
139
149
  nwp500.sim
140
150
  nwp500.sim_adapters
151
+ nwp500.sim_calibrate
141
152
  nwp500.sim_home_assistant
142
153
  nwp500.sim_live
143
154
  nwp500.status_history
@@ -0,0 +1,190 @@
1
+ """``nwp-cli simulate calibrate``: fit the heater's parameters to its
2
+ recorded history.
3
+
4
+ The history is the statuses the heater reported
5
+ (``nwp500.status_history``, as ``nwp-cli simulate record`` writes them).
6
+ The fits and the report's terms are ``nwp500.sim_calibrate``'s; this
7
+ module reads the command's files and prints the report.
8
+
9
+ This module needs the ``sim`` extra.
10
+ """
11
+
12
+ import csv
13
+ import json
14
+ import logging
15
+ import math
16
+ import sys
17
+ from collections.abc import Sequence
18
+ from dataclasses import replace
19
+ from datetime import datetime
20
+ from pathlib import Path
21
+ from typing import Any, TextIO
22
+
23
+ from rich.console import Console
24
+ from rich.table import Table
25
+
26
+ from nwp500 import sim_calibrate as sc
27
+ from nwp500 import status_history
28
+ from nwp500.sim.params import params_to_document
29
+ from nwp500.sim_calibrate import Calibrated, Term, report
30
+
31
+ from .simulate import InputError, read_params
32
+
33
+ __author__ = "Emmanuel Levijarvi"
34
+ __copyright__ = "Emmanuel Levijarvi"
35
+ __license__ = "MIT"
36
+
37
+ _logger = logging.getLogger(__name__)
38
+
39
+ #: A reading: an instant and a value.
40
+ Reading = tuple[datetime, float]
41
+
42
+
43
+ def read_history(path: str | Path) -> list[status_history.TimedStatus]:
44
+ """The statuses recorded at ``path``."""
45
+ try:
46
+ with Path(path).open(encoding="utf-8") as f:
47
+ readings = status_history.read(f)
48
+ except (OSError, ValueError) as e:
49
+ raise InputError(f"cannot read the history {path}: {e}") from None
50
+ if not readings:
51
+ raise InputError(f"the history {path} holds no statuses")
52
+ return readings
53
+
54
+
55
+ def read_readings(path: str | Path, what: str) -> list[Reading]:
56
+ """A CSV file's readings: an ISO 8601 instant with its offset, then
57
+ a value, on each row. A first row that is not one is a header."""
58
+ try:
59
+ with Path(path).open(encoding="utf-8", newline="") as f:
60
+ rows = [r for r in csv.reader(f) if r and any(c.strip() for c in r)]
61
+ except OSError as e:
62
+ raise InputError(f"cannot read the {what} {path}: {e}") from None
63
+ out: list[Reading] = []
64
+ for number, row in enumerate(rows, start=1):
65
+ try:
66
+ at = datetime.fromisoformat(row[0].strip())
67
+ value = float(row[1])
68
+ except IndexError, ValueError:
69
+ if number == 1:
70
+ continue
71
+ raise InputError(
72
+ f"row {number} of the {what} {path} is not an instant "
73
+ "and a value"
74
+ ) from None
75
+ if at.tzinfo is None:
76
+ raise InputError(
77
+ f"row {number} of the {what} {path}: give the instant's "
78
+ "UTC offset"
79
+ )
80
+ if math.isfinite(value):
81
+ out.append((at, value))
82
+ if not out:
83
+ raise InputError(f"the {what} {path} holds no readings")
84
+ return out
85
+
86
+
87
+ async def read_record(
88
+ sources: Any, readings: Sequence[status_history.TimedStatus]
89
+ ) -> Any:
90
+ """What ``sources`` recorded over ``readings``' span
91
+ (``sim_home_assistant.record_inputs``)."""
92
+ from nwp500 import sim_home_assistant as ha
93
+
94
+ first = min(t for t, _ in readings)
95
+ last = max(t for t, _ in readings)
96
+ async with ha.HomeAssistantClient(sources.url, sources.token) as client:
97
+ return await ha.record_inputs(client, sources.entities, first, last)
98
+
99
+
100
+ #: The library's notes, naming the command's options.
101
+ _OPTION_NOTES = {
102
+ sc.DEFAULT_BASE_NOTE: "refitting the 65 gal defaults (give --params "
103
+ "for another tank size)",
104
+ sc.NO_AIR_NOTE: "no air given (--air, --ambient or --sources): the "
105
+ "standby loss is not fitted",
106
+ }
107
+
108
+
109
+ def calibrate(
110
+ readings: Sequence[status_history.TimedStatus],
111
+ *,
112
+ air: Sequence[Reading] | None = None,
113
+ ambient: float | None = None,
114
+ humidity: Sequence[Reading] | None = None,
115
+ params: Any = None,
116
+ record: Any = None,
117
+ ) -> Calibrated:
118
+ """``sim_calibrate.calibrate`` with ``params`` a parameters
119
+ document, its errors as ``InputError`` and its notes naming the
120
+ command's options."""
121
+ base = None if params is None else read_params(params, "--params")
122
+ try:
123
+ done = sc.calibrate(
124
+ readings,
125
+ air=air,
126
+ ambient=ambient,
127
+ humidity=humidity,
128
+ params=base,
129
+ record=record,
130
+ )
131
+ except sc.CalibrationInputError as e:
132
+ raise InputError(str(e)) from None
133
+ return replace(
134
+ done, notes=tuple(_OPTION_NOTES.get(n, n) for n in done.notes)
135
+ )
136
+
137
+
138
+ def _kept(t: Term) -> str:
139
+ if t.fitted:
140
+ return f"fitted: {t.note}" if t.note else "fitted"
141
+ return f"base: {t.why}" if t.why else "base"
142
+
143
+
144
+ def print_report(done: Calibrated, console: Console) -> None:
145
+ """The report as tables on ``console``."""
146
+ c = done.calibration
147
+ console.print(
148
+ f"{done.n_statuses} statuses; quiet test: {c.standby.quiet_by}; "
149
+ f"{len(c.recoveries)} recoveries found"
150
+ )
151
+ if done.n_gaps:
152
+ minutes = done.max_gap.total_seconds() / 60
153
+ console.print(
154
+ f"{done.n_gaps} gap{'' if done.n_gaps == 1 else 's'} of over "
155
+ f"{minutes:g} min between statuses: no fit spans them"
156
+ )
157
+ console.print(
158
+ {
159
+ "readings": "Air: the readings given",
160
+ "constant": "Air: the constant given",
161
+ "source": "Air: the sources' record beside the heater",
162
+ None: "Air: none given; recoveries use the heater's own reading",
163
+ }[done.air]
164
+ )
165
+ table = Table(title="Calibration")
166
+ for column in ("Term", "Value", "From", "Error", "Kept"):
167
+ table.add_column(column)
168
+ for t in report(done):
169
+ table.add_row(
170
+ t.name,
171
+ t.value,
172
+ t.samples,
173
+ t.error,
174
+ _kept(t),
175
+ )
176
+ console.print(table)
177
+ unfitted = ", ".join(c.params.unfitted) or "none"
178
+ console.print(f"Unfitted: {unfitted}")
179
+
180
+
181
+ def run(
182
+ done: Calibrated, output: str | Path | None, out: TextIO | None = None
183
+ ) -> None:
184
+ """Print the report and write the parameters to ``output``."""
185
+ for note in done.notes:
186
+ _logger.warning(note)
187
+ print_report(done, Console(file=out or sys.stdout))
188
+ if output is not None:
189
+ text = json.dumps(params_to_document(done.calibration.params), indent=2)
190
+ Path(output).write_text(text + "\n", encoding="utf-8")