nwp500-python 9.6.0__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 (341) hide show
  1. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/CHANGELOG.rst +22 -0
  2. {nwp500_python-9.6.0/src/nwp500_python.egg-info → nwp500_python-9.7.0}/PKG-INFO +1 -1
  3. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/how-to/calibrate-simulator.rst +83 -1
  4. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/reference/python_api/sim.rst +4 -2
  5. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/sim_adapters.py +173 -1
  6. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/sim_calibrate.py +82 -11
  7. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/sim_home_assistant.py +288 -0
  8. {nwp500_python-9.6.0 → nwp500_python-9.7.0/src/nwp500_python.egg-info}/PKG-INFO +1 -1
  9. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500_python.egg-info/SOURCES.txt +1 -0
  10. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500_python.egg-info/scm_file_list.json +1 -0
  11. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500_python.egg-info/scm_version.json +2 -2
  12. nwp500_python-9.7.0/tests/test_sim_change_history.py +527 -0
  13. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/.coveragerc +0 -0
  14. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/.github/RESOLVING_PR_COMMENTS.md +0 -0
  15. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/.github/copilot-instructions.md +0 -0
  16. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/.github/workflows/ci.yml +0 -0
  17. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/.github/workflows/release.yml +0 -0
  18. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/.gitignore +0 -0
  19. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/.pre-commit-config.yaml +0 -0
  20. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/.readthedocs.yml +0 -0
  21. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/AUTHORS.rst +0 -0
  22. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/CONTRIBUTING.rst +0 -0
  23. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/LICENSE.txt +0 -0
  24. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/Makefile +0 -0
  25. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/README.rst +0 -0
  26. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/RELEASE.md +0 -0
  27. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/Makefile +0 -0
  28. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/_static/.gitignore +0 -0
  29. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/conf.py +0 -0
  30. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/explanation/architecture.rst +0 -0
  31. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/explanation/draws.rst +0 -0
  32. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/explanation/heating-elements.rst +0 -0
  33. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/explanation/index.rst +0 -0
  34. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/explanation/recovery-speed.rst +0 -0
  35. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/explanation/simulator.rst +0 -0
  36. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/explanation/standby-loss.rst +0 -0
  37. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/explanation/tank-energy.rst +0 -0
  38. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/explanation/tou-recovery-cap.rst +0 -0
  39. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/explanation/unknown-values.rst +0 -0
  40. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/explanation/what-starts-a-recovery.rst +0 -0
  41. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/how-to/authenticate.rst +0 -0
  42. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/how-to/auto-recovery.rst +0 -0
  43. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/how-to/bluetooth.rst +0 -0
  44. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/how-to/diagnose-mqtt.rst +0 -0
  45. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/how-to/home-assistant.rst +0 -0
  46. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/how-to/index.rst +0 -0
  47. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/how-to/maintenance.rst +0 -0
  48. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/how-to/manage-units.rst +0 -0
  49. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/how-to/monitor-status.rst +0 -0
  50. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/how-to/optimize-tou.rst +0 -0
  51. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/how-to/queue-commands.rst +0 -0
  52. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/how-to/schedule-operation.rst +0 -0
  53. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/how-to/simulate-heater.rst +0 -0
  54. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/how-to/track-energy.rst +0 -0
  55. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/index.rst +0 -0
  56. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/openapi.yaml +0 -0
  57. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/project/authors.rst +0 -0
  58. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/project/changelog.rst +0 -0
  59. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/project/contributing.rst +0 -0
  60. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/project/history.rst +0 -0
  61. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/project/license.rst +0 -0
  62. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/reference/configuration.rst +0 -0
  63. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/reference/enumerations.rst +0 -0
  64. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/reference/index.rst +0 -0
  65. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/reference/installation.rst +0 -0
  66. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/reference/protocol/data_conversions.rst +0 -0
  67. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/reference/protocol/device_features.rst +0 -0
  68. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/reference/protocol/device_status.rst +0 -0
  69. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/reference/protocol/error_codes.rst +0 -0
  70. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/reference/protocol/mqtt_protocol.rst +0 -0
  71. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/reference/protocol/quick_reference.rst +0 -0
  72. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/reference/protocol/rest_api.rst +0 -0
  73. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/reference/python_api/api_client.rst +0 -0
  74. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/reference/python_api/auth_client.rst +0 -0
  75. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/reference/python_api/cli.rst +0 -0
  76. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/reference/python_api/events.rst +0 -0
  77. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/reference/python_api/exceptions.rst +0 -0
  78. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/reference/python_api/models.rst +0 -0
  79. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/reference/python_api/mqtt_client.rst +0 -0
  80. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/reference/simulator-inputs.rst +0 -0
  81. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/requirements.txt +0 -0
  82. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/docs/tutorials/getting-started.rst +0 -0
  83. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/.ruff.toml +0 -0
  84. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/README.md +0 -0
  85. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/advanced/air_filter_reset.py +0 -0
  86. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/advanced/anti_legionella.py +0 -0
  87. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/advanced/auto_recovery.py +0 -0
  88. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/advanced/combined_callbacks.py +0 -0
  89. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/advanced/demand_response.py +0 -0
  90. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/advanced/device_capabilities.py +0 -0
  91. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/advanced/device_status_debug.py +0 -0
  92. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/advanced/energy_analytics.py +0 -0
  93. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/advanced/error_code_demo.py +0 -0
  94. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/advanced/firmware_payload_capture.py +0 -0
  95. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/advanced/mqtt_diagnostics.py +0 -0
  96. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/advanced/power_control.py +0 -0
  97. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/advanced/recirculation_control.py +0 -0
  98. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/advanced/reconnection_demo.py +0 -0
  99. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/advanced/reservation_schedule.py +0 -0
  100. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/advanced/simple_auto_recovery.py +0 -0
  101. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/advanced/token_restoration.py +0 -0
  102. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/advanced/tou_openei.py +0 -0
  103. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/advanced/tou_schedule.py +0 -0
  104. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/advanced/water_reservation.py +0 -0
  105. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/beginner/01_authentication.py +0 -0
  106. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/beginner/02_list_devices.py +0 -0
  107. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/beginner/03_get_status.py +0 -0
  108. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/beginner/04_set_temperature.py +0 -0
  109. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/intermediate/advanced_auth_patterns.py +0 -0
  110. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/intermediate/command_queue.py +0 -0
  111. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/intermediate/device_status_callback.py +0 -0
  112. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/intermediate/error_handling.py +0 -0
  113. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/intermediate/event_driven_control.py +0 -0
  114. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/intermediate/improved_auth.py +0 -0
  115. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/intermediate/legacy_auth_constructor.py +0 -0
  116. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/intermediate/mqtt_realtime_monitoring.py +0 -0
  117. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/intermediate/periodic_requests.py +0 -0
  118. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/intermediate/set_mode.py +0 -0
  119. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/intermediate/vacation_mode.py +0 -0
  120. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/mask.py +0 -0
  121. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/simulator/lower-setpoint.json +0 -0
  122. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/simulator/plan.json +0 -0
  123. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/simulator/scenario.json +0 -0
  124. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/testing/periodic_device_info.py +0 -0
  125. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/testing/simple_periodic_info.py +0 -0
  126. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/testing/test_api_client.py +0 -0
  127. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/testing/test_mqtt_connection.py +0 -0
  128. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/testing/test_mqtt_messaging.py +0 -0
  129. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/examples/testing/test_periodic_minimal.py +0 -0
  130. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/pyproject.toml +0 -0
  131. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/scripts/README.md +0 -0
  132. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/scripts/bump_version.py +0 -0
  133. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/scripts/diagnose_mqtt_connection.py +0 -0
  134. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/scripts/extract_changelog.py +0 -0
  135. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/scripts/format.py +0 -0
  136. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/scripts/lint.py +0 -0
  137. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/scripts/setup-dev.py +0 -0
  138. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/scripts/validate_version.py +0 -0
  139. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/setup.cfg +0 -0
  140. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/setup.py +0 -0
  141. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/__init__.py +0 -0
  142. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/_base.py +0 -0
  143. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/api_client.py +0 -0
  144. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/auth.py +0 -0
  145. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/ble/__init__.py +0 -0
  146. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/ble/__main__.py +0 -0
  147. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/ble/client.py +0 -0
  148. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/ble/mqtt_adapter.py +0 -0
  149. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/ble/protocol.py +0 -0
  150. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/cli/__init__.py +0 -0
  151. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/cli/__main__.py +0 -0
  152. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/cli/ble_backend.py +0 -0
  153. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/cli/calibration.py +0 -0
  154. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/cli/handlers.py +0 -0
  155. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/cli/monitoring.py +0 -0
  156. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/cli/output_formatters.py +0 -0
  157. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/cli/presentation.py +0 -0
  158. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/cli/recording.py +0 -0
  159. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/cli/rich_output.py +0 -0
  160. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/cli/simulate.py +0 -0
  161. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/cli/token_storage.py +0 -0
  162. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/command_decorators.py +0 -0
  163. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/config.py +0 -0
  164. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/converters.py +0 -0
  165. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/device_capabilities.py +0 -0
  166. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/device_info_cache.py +0 -0
  167. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/encoding.py +0 -0
  168. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/enums.py +0 -0
  169. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/events.py +0 -0
  170. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/exceptions.py +0 -0
  171. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/factory.py +0 -0
  172. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/field_factory.py +0 -0
  173. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/models/__init__.py +0 -0
  174. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/models/_converters.py +0 -0
  175. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/models/account.py +0 -0
  176. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/models/device.py +0 -0
  177. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/models/diagnostics.py +0 -0
  178. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/models/energy.py +0 -0
  179. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/models/feature.py +0 -0
  180. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/models/firmware.py +0 -0
  181. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/models/maintenance.py +0 -0
  182. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/models/mqtt_models.py +0 -0
  183. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/models/schedule.py +0 -0
  184. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/models/status.py +0 -0
  185. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/models/tou.py +0 -0
  186. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/mqtt/__init__.py +0 -0
  187. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/mqtt/_control_commands.py +0 -0
  188. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/mqtt/_device_subscriptions.py +0 -0
  189. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/mqtt/client.py +0 -0
  190. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/mqtt/command_queue.py +0 -0
  191. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/mqtt/connection.py +0 -0
  192. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/mqtt/control.py +0 -0
  193. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/mqtt/diagnostics.py +0 -0
  194. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/mqtt/periodic.py +0 -0
  195. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/mqtt/reconnection.py +0 -0
  196. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/mqtt/state_tracker.py +0 -0
  197. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/mqtt/subscriptions.py +0 -0
  198. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/mqtt/types.py +0 -0
  199. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/mqtt/utils.py +0 -0
  200. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/mqtt_events.py +0 -0
  201. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/openei.py +0 -0
  202. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/py.typed +0 -0
  203. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/reservations.py +0 -0
  204. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/sim/__init__.py +0 -0
  205. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/sim/api.py +0 -0
  206. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/sim/attribution.py +0 -0
  207. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/sim/book.py +0 -0
  208. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/sim/calibration.py +0 -0
  209. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/sim/circulation.py +0 -0
  210. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/sim/codec.py +0 -0
  211. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/sim/cold_supply.py +0 -0
  212. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/sim/controller.py +0 -0
  213. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/sim/decay.py +0 -0
  214. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/sim/defaults.py +0 -0
  215. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/sim/delivery.py +0 -0
  216. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/sim/draw_forecast.py +0 -0
  217. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/sim/engine.py +0 -0
  218. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/sim/extensions.py +0 -0
  219. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/sim/heat_pump_recovery.py +0 -0
  220. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/sim/heating_trajectory.py +0 -0
  221. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/sim/inputs.py +0 -0
  222. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/sim/location_air.py +0 -0
  223. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/sim/meter.py +0 -0
  224. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/sim/observed.py +0 -0
  225. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/sim/params.py +0 -0
  226. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/sim/physics/__init__.py +0 -0
  227. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/sim/physics/compressor.py +0 -0
  228. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/sim/physics/draw.py +0 -0
  229. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/sim/physics/element.py +0 -0
  230. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/sim/physics/refrigerant.py +0 -0
  231. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/sim/physics/tank.py +0 -0
  232. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/sim/profile.py +0 -0
  233. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/sim/ready.py +0 -0
  234. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/sim/record.py +0 -0
  235. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/sim/result.py +0 -0
  236. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/sim/schedules.py +0 -0
  237. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/sim/tank_physics.py +0 -0
  238. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/sim/tou_triggers.py +0 -0
  239. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/sim/units.py +0 -0
  240. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/sim_live.py +0 -0
  241. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/status_history.py +0 -0
  242. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/temperature.py +0 -0
  243. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/topic_builder.py +0 -0
  244. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/tou_schedule.py +0 -0
  245. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/unit_system.py +0 -0
  246. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500/utils.py +0 -0
  247. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500_python.egg-info/dependency_links.txt +0 -0
  248. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500_python.egg-info/entry_points.txt +0 -0
  249. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500_python.egg-info/not-zip-safe +0 -0
  250. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500_python.egg-info/requires.txt +0 -0
  251. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/src/nwp500_python.egg-info/top_level.txt +0 -0
  252. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/_sim_site.py +0 -0
  253. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/conftest.py +0 -0
  254. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/fixtures/sim/engine_golden.json +0 -0
  255. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/fixtures/sim/heater_params.json +0 -0
  256. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/fixtures/sim/heater_params_fitted.json +0 -0
  257. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_api_endpoints_201.py +0 -0
  258. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_api_helpers.py +0 -0
  259. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_app_queries.py +0 -0
  260. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_auth.py +0 -0
  261. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_auth_session_lifecycle.py +0 -0
  262. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_auth_session_refresh.py +0 -0
  263. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_ble_mqtt_adapter.py +0 -0
  264. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_ble_protocol.py +0 -0
  265. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_bug_fixes.py +0 -0
  266. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_canonical_schedule.py +0 -0
  267. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_cli_basic.py +0 -0
  268. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_cli_calibrate.py +0 -0
  269. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_cli_commands.py +0 -0
  270. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_cli_simulate.py +0 -0
  271. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_cli_tristate_presentation.py +0 -0
  272. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_command_decorators.py +0 -0
  273. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_command_queue.py +0 -0
  274. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_device_capabilities.py +0 -0
  275. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_device_info_cache.py +0 -0
  276. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_device_rest_models.py +0 -0
  277. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_events.py +0 -0
  278. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_exceptions.py +0 -0
  279. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_model_converters.py +0 -0
  280. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_models.py +0 -0
  281. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_mqtt_clean_session_resume.py +0 -0
  282. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_mqtt_client_init.py +0 -0
  283. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_mqtt_control_fail.py +0 -0
  284. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_mqtt_events.py +0 -0
  285. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_mqtt_hypothesis.py +0 -0
  286. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_mqtt_last_will.py +0 -0
  287. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_mqtt_reconnection.py +0 -0
  288. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_mqtt_reconnection_storm.py +0 -0
  289. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_mqtt_reliability.py +0 -0
  290. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_multi_device.py +0 -0
  291. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_navilink_201_enums.py +0 -0
  292. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_openei.py +0 -0
  293. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_protocol_correctness.py +0 -0
  294. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_public_api.py +0 -0
  295. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_reservation_validation.py +0 -0
  296. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_reservations.py +0 -0
  297. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_sim_adapters.py +0 -0
  298. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_sim_api.py +0 -0
  299. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_sim_book.py +0 -0
  300. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_sim_book_history.py +0 -0
  301. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_sim_calibrate.py +0 -0
  302. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_sim_calibration.py +0 -0
  303. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_sim_circulation.py +0 -0
  304. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_sim_codec.py +0 -0
  305. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_sim_cold_supply.py +0 -0
  306. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_sim_controller.py +0 -0
  307. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_sim_defaults.py +0 -0
  308. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_sim_draw_forecast.py +0 -0
  309. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_sim_draw_loss.py +0 -0
  310. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_sim_element.py +0 -0
  311. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_sim_element_fit.py +0 -0
  312. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_sim_forecast.py +0 -0
  313. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_sim_golden.py +0 -0
  314. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_sim_home_assistant.py +0 -0
  315. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_sim_hot_water.py +0 -0
  316. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_sim_import_contract.py +0 -0
  317. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_sim_live.py +0 -0
  318. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_sim_location_air.py +0 -0
  319. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_sim_meter.py +0 -0
  320. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_sim_no_clock_no_state.py +0 -0
  321. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_sim_observed.py +0 -0
  322. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_sim_profile.py +0 -0
  323. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_sim_readiness.py +0 -0
  324. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_sim_readiness_fit.py +0 -0
  325. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_sim_ready.py +0 -0
  326. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_sim_ready_api.py +0 -0
  327. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_sim_replay.py +0 -0
  328. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_sim_schedules.py +0 -0
  329. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_sim_tank_physics.py +0 -0
  330. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_status_history.py +0 -0
  331. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_temperature_converters.py +0 -0
  332. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_threading_model.py +0 -0
  333. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_token_storage.py +0 -0
  334. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_tou_api.py +0 -0
  335. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_tou_schedule.py +0 -0
  336. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_unit_switching.py +0 -0
  337. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_unit_system_process_wide.py +0 -0
  338. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_utility_modules.py +0 -0
  339. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tests/test_utils.py +0 -0
  340. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/tox.ini +0 -0
  341. {nwp500_python-9.6.0 → nwp500_python-9.7.0}/uv.lock +0 -0
@@ -2,6 +2,28 @@
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
+
5
27
  Version 9.6.0 (2026-10-21)
6
28
  ==========================
7
29
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nwp500-python
3
- Version: 9.6.0
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
@@ -232,6 +232,86 @@ the draw response and the elements also run there: see "Improve
232
232
  accuracy" and "Fit the elements" in :doc:`simulate-heater`.
233
233
 
234
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
+
235
315
  Limitations
236
316
  ===========
237
317
 
@@ -252,4 +332,6 @@ See also
252
332
 
253
333
  * :doc:`simulate-heater`: run the simulator with your parameters
254
334
  * :doc:`/reference/python_api/sim`: ``sim_calibrate``,
255
- ``sim.calibration``, ``sim_adapters.history`` and ``status_history``
335
+ ``sim.calibration``, ``sim_adapters.history``,
336
+ ``sim_adapters.change_history``, ``status_history`` and
337
+ ``sim_home_assistant.read_heater_history``
@@ -298,7 +298,7 @@ Recorded history
298
298
  :members:
299
299
 
300
300
  .. automodule:: nwp500.sim_calibrate
301
- :members: calibrate, Calibrated, report, Term, max_gap,
301
+ :members: calibrate, calibrate_changes, Calibrated, report, Term, max_gap,
302
302
  CalibrationInputError, DEFAULT_HUMIDITY_PCT, GAP_SAMPLES, MIN_GAP,
303
303
  DEFAULT_BASE_NOTE, NO_AIR_NOTE
304
304
 
@@ -309,6 +309,8 @@ Home Assistant sources
309
309
  .. automodule:: nwp500.sim_home_assistant
310
310
  :members: HomeAssistantEnvironment, HomeAssistantClient, record_inputs,
311
311
  RecordInputs, Sources, Entities, sources_from, State, parse_state,
312
- 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,
313
315
  pump_runs, draw_spans, busy, covers_start, reports_after, percent,
314
316
  fresh, to_f, to_gpm, to_gal, to_rpm, HomeAssistantError
@@ -760,15 +760,187 @@ def add_inputs(
760
760
  for name, spans in (("drawing", drawing), ("circulating", circulating)):
761
761
  if spans is not None:
762
762
  frame[name] = _interval_column(index, spans)
763
+ unobserved = np.zeros(len(frame), dtype=bool)
763
764
  if max_gap is not None and len(frame) > 1:
764
- unobserved = np.zeros(len(frame), dtype=bool)
765
765
  unobserved[:-1] = np.diff(seconds) > max_gap.total_seconds()
766
+ if "heater_unobserved" in frame.columns:
767
+ # ``change_history``'s outages, into the draws added here too.
768
+ unobserved |= frame["heater_unobserved"].to_numpy(dtype=bool)
769
+ if unobserved.any():
766
770
  for name in ("dhw_in_use", "drawing", "excluded"):
767
771
  if name in frame.columns:
768
772
  frame[name] = frame[name].to_numpy(dtype=bool) | unobserved
769
773
  return frame
770
774
 
771
775
 
776
+ # ── a heater's history from its values' changes ───────────────────
777
+
778
+ #: A value's changes: each instant it took a new value, and the value.
779
+ Changes = Sequence[tuple[datetime, Any]]
780
+
781
+
782
+ @dataclass(frozen=True)
783
+ class ChangeRows:
784
+ """A heater's history as the changes of each value a status carries
785
+ (Home Assistant's entity history, say): each series the instants a
786
+ value changed and what it changed to, in time order. A value holds
787
+ until its next change. Temperatures are °F, power W, energy Wh.
788
+
789
+ Only the probes are needed. Without a value, its column keeps
790
+ ``history``'s default: the flags off, a number unknown.
791
+ """
792
+
793
+ upper_f: Changes
794
+ lower_f: Changes
795
+ compressor_on: Changes = ()
796
+ upper_element_on: Changes = ()
797
+ lower_element_on: Changes = ()
798
+ #: ``HeatSource`` values.
799
+ heat_source: Changes = ()
800
+ dhw_in_use: Changes = ()
801
+ #: The lower probe's heat-pump stop threshold.
802
+ target_f: Changes = ()
803
+ #: ``CurrentOperationMode`` values.
804
+ operation_mode: Changes = ()
805
+ anti_legionella: Changes = ()
806
+ #: ``ErrorCode`` values.
807
+ error_code: Changes = ()
808
+ power_w: Changes = ()
809
+ energy_to_setpoint_wh: Changes = ()
810
+ #: The heater's own air reading, used only while its compressor runs.
811
+ heater_air_f: Changes = ()
812
+ #: The spans a value was unknown (Home Assistant's ``unavailable``
813
+ #: or ``unknown``): unobserved, like a gap between statuses.
814
+ outages: Sequence[tuple[datetime, datetime]] = ()
815
+
816
+
817
+ def _ns(instants: Iterable[datetime]) -> np.ndarray:
818
+ """``instants`` as UTC nanoseconds."""
819
+ import pandas as pd
820
+
821
+ return np.asarray(
822
+ pd.to_datetime(list(instants), utc=True).as_unit("ns").asi8,
823
+ dtype=np.int64,
824
+ )
825
+
826
+
827
+ def _held_at(
828
+ changes: Changes, at: np.ndarray, missing: Any, dtype: Any
829
+ ) -> np.ndarray:
830
+ """``changes`` held from each change to the next, read at ``at``
831
+ (ns); ``missing`` before the first."""
832
+ out = np.full(len(at), missing, dtype=dtype)
833
+ if not changes:
834
+ return out
835
+ rows = sorted(changes, key=lambda r: r[0])
836
+ times = _ns(t for t, _ in rows)
837
+ values = np.array([v for _, v in rows], dtype=dtype)
838
+ i = np.searchsorted(times, at, side="right") - 1
839
+ seen = i >= 0
840
+ out[seen] = values[i[seen]]
841
+ return out
842
+
843
+
844
+ def change_history(
845
+ rows: ChangeRows, observed: Iterable[datetime]
846
+ ) -> pd.DataFrame:
847
+ """``history``'s frame from a heater's values' changes (``rows``).
848
+
849
+ A value that holds writes no change, so the changes cannot tell an
850
+ observed span from one nobody saw: ``observed`` is the instants the
851
+ heater was heard from (an entity that changes on every status
852
+ response, say). The frame has a row at each of them and at each
853
+ change between the first and the last, each value as it stood
854
+ then. Rows over an outage are marked water in use and excluded, as
855
+ ``add_inputs`` marks a gap, and ``heater_unobserved``. Without a
856
+ compressor flag, the heat source says when the compressor runs.
857
+ """
858
+ import pandas as pd
859
+
860
+ heard = _ns(observed)
861
+ if not len(heard):
862
+ at = heard
863
+ else:
864
+ first, last = heard.min(), heard.max()
865
+ changed = [
866
+ _ns(t for t, _ in series)
867
+ for series in (
868
+ rows.upper_f,
869
+ rows.lower_f,
870
+ rows.compressor_on,
871
+ rows.upper_element_on,
872
+ rows.lower_element_on,
873
+ rows.heat_source,
874
+ rows.dhw_in_use,
875
+ rows.target_f,
876
+ rows.operation_mode,
877
+ rows.anti_legionella,
878
+ rows.error_code,
879
+ rows.power_w,
880
+ rows.energy_to_setpoint_wh,
881
+ rows.heater_air_f,
882
+ )
883
+ if series
884
+ ]
885
+ at = np.unique(np.concatenate([heard, *changed]))
886
+ at = at[(at >= first) & (at <= last)]
887
+ nan = float("nan")
888
+ source = _held_at(rows.heat_source, at, -1, np.int64)
889
+ compressor = (
890
+ _held_at(rows.compressor_on, at, False, bool)
891
+ if rows.compressor_on
892
+ # Without its own flag, the heat source says when it runs.
893
+ else np.isin(
894
+ source,
895
+ [int(HeatSource.HEATPUMP), int(HeatSource.HEATPUMP_HEATELEMENT)],
896
+ )
897
+ )
898
+ mode = _held_at(rows.operation_mode, at, -1, np.int64)
899
+ error = _held_at(rows.error_code, at, int(ErrorCode.NO_ERROR), np.int64)
900
+
901
+ def optional(series: Changes) -> np.ndarray | None:
902
+ return _held_at(series, at, nan, float) if series else None
903
+
904
+ columns: dict[str, np.ndarray | None] = {
905
+ "upper_f": _held_at(rows.upper_f, at, nan, float),
906
+ "lower_f": _held_at(rows.lower_f, at, nan, float),
907
+ "compressor_on": compressor,
908
+ "element_on": _held_at(rows.upper_element_on, at, False, bool)
909
+ | _held_at(rows.lower_element_on, at, False, bool)
910
+ | np.isin(
911
+ source,
912
+ [int(HeatSource.HEATELEMENT), int(HeatSource.HEATPUMP_HEATELEMENT)],
913
+ ),
914
+ "dhw_in_use": _held_at(rows.dhw_in_use, at, False, bool),
915
+ "target_f": optional(rows.target_f),
916
+ "excluded": np.isin(
917
+ mode,
918
+ [
919
+ int(CurrentOperationMode.HYBRID_EFFICIENCY_MODE),
920
+ int(CurrentOperationMode.HYBRID_BOOST_MODE),
921
+ ],
922
+ )
923
+ | _held_at(rows.anti_legionella, at, False, bool)
924
+ | (error != int(ErrorCode.NO_ERROR)),
925
+ "power_w": optional(rows.power_w),
926
+ "energy_to_setpoint_wh": optional(rows.energy_to_setpoint_wh),
927
+ "heater_air_f": np.where(
928
+ compressor, _held_at(rows.heater_air_f, at, nan, float), nan
929
+ ),
930
+ }
931
+ # ``history``'s columns, in its order, less the values not given.
932
+ data = {k: v for k, v in columns.items() if v is not None}
933
+ index = pd.DatetimeIndex(pd.to_datetime(at, unit="ns", utc=True))
934
+ frame = pd.DataFrame(data, index=index)
935
+ if rows.outages and len(frame):
936
+ down = np.asarray(_interval_column(index, rows.outages))
937
+ for name in ("dhw_in_use", "excluded"):
938
+ frame[name] = frame[name].to_numpy(dtype=bool) | down
939
+ # Kept, so ``add_inputs`` marks a meter's draws over them too.
940
+ frame["heater_unobserved"] = down
941
+ return frame
942
+
943
+
772
944
  # ── a past scenario, from the statuses the heater reported ─────────
773
945
 
774
946
  #: A tank reading older than this at the instant is no state to start
@@ -34,8 +34,9 @@ statuses::
34
34
  This module needs the ``sim`` extra.
35
35
  """
36
36
 
37
+ import itertools
37
38
  from collections.abc import Iterable, Mapping, Sequence
38
- from dataclasses import dataclass
39
+ from dataclasses import dataclass, replace
39
40
  from datetime import datetime, timedelta
40
41
  from typing import TYPE_CHECKING, Any, cast
41
42
 
@@ -66,6 +67,7 @@ __all__ = (
66
67
  "Calibrated",
67
68
  "Term",
68
69
  "calibrate",
70
+ "calibrate_changes",
69
71
  "max_gap",
70
72
  "report",
71
73
  )
@@ -176,6 +178,20 @@ def _base(params: HeaterParams | Any) -> HeaterParams:
176
178
  ) from None
177
179
 
178
180
 
181
+ def _begin(
182
+ air: Sequence[Reading] | None,
183
+ ambient: float | None,
184
+ params: HeaterParams | Any,
185
+ ) -> tuple[HeaterParams, list[str]]:
186
+ """The base to refit and the notes so far; the inputs checked before
187
+ a history is read."""
188
+ if air is not None and ambient is not None:
189
+ raise CalibrationInputError("give air or ambient, not both")
190
+ if params is None:
191
+ return sim.default_params(), [DEFAULT_BASE_NOTE]
192
+ return _base(params), []
193
+
194
+
179
195
  def calibrate(
180
196
  readings: Iterable[tuple[datetime, DeviceStatus]],
181
197
  *,
@@ -204,17 +220,69 @@ def calibrate(
204
220
  for both ``air`` and ``ambient``, a ``params`` that is not a
205
221
  parameters document, or no readings.
206
222
  """
207
- if air is not None and ambient is not None:
208
- raise CalibrationInputError("give air or ambient, not both")
209
- notes: list[str] = []
210
- if params is None:
211
- base = sim.default_params()
212
- notes.append(DEFAULT_BASE_NOTE)
213
- else:
214
- base = _base(params)
223
+ base, notes = _begin(air, ambient, params)
215
224
  frame = sa.history(readings)
216
225
  if frame.empty:
217
226
  raise CalibrationInputError("the history holds no statuses")
227
+ return _fit(frame, base, notes, air, ambient, humidity, record)
228
+
229
+
230
+ def calibrate_changes(
231
+ rows: sa.ChangeRows,
232
+ observed: Iterable[datetime],
233
+ *,
234
+ air: Sequence[Reading] | None = None,
235
+ ambient: float | None = None,
236
+ humidity: Sequence[Reading] | None = None,
237
+ params: HeaterParams | Any = None,
238
+ record: RecordInputs | None = None,
239
+ notes: Iterable[str] = (),
240
+ ) -> Calibrated:
241
+ """``calibrate`` on a heater's values' changes (``rows``) instead of
242
+ its statuses, with ``observed`` the instants it was heard from
243
+ (``sim_adapters.change_history``; ``sim_home_assistant.
244
+ read_heater_history`` reads both from Home Assistant). ``notes``
245
+ open the result's (a reader's). The rows are counted in
246
+ ``n_statuses``. Raises ``CalibrationInputError`` as ``calibrate``
247
+ does, or for no ``observed`` instants.
248
+ """
249
+ base, more = _begin(air, ambient, params)
250
+ heard = sorted(observed)
251
+ # The gaps are the silences between the instants heard from: a
252
+ # change inside one says nothing of the time around it.
253
+ gap = max_gap(heard)
254
+ silences = [(a, b) for a, b in itertools.pairwise(heard) if b - a > gap]
255
+ frame = sa.change_history(
256
+ replace(rows, outages=(*rows.outages, *silences)), heard
257
+ )
258
+ if frame.empty:
259
+ raise CalibrationInputError("the history holds no observed instants")
260
+ return _fit(
261
+ frame,
262
+ base,
263
+ [*notes, *more],
264
+ air,
265
+ ambient,
266
+ humidity,
267
+ record,
268
+ gaps=(gap, len(silences)),
269
+ )
270
+
271
+
272
+ def _fit(
273
+ frame: pd.DataFrame,
274
+ base: HeaterParams,
275
+ notes: list[str],
276
+ air: Sequence[Reading] | None,
277
+ ambient: float | None,
278
+ humidity: Sequence[Reading] | None,
279
+ record: RecordInputs | None,
280
+ gaps: tuple[timedelta, int] | None = None,
281
+ ) -> Calibrated:
282
+ """The fits on ``frame`` (``history``'s or ``change_history``'s),
283
+ with the air, the humidity, the record's spans and the gaps added:
284
+ ``gaps``' longest spacing taken as observed and how many spans are
285
+ longer, else the frame's own."""
218
286
  index = cast("pd.DatetimeIndex", frame.index)
219
287
  # Timestamps, which are datetimes.
220
288
  first, last = cast(datetime, index[0]), cast(datetime, index[-1])
@@ -254,8 +322,11 @@ def calibrate(
254
322
  # The fits' own default, given explicitly: an absent humidity
255
323
  # reaches the evaporator's dew point as NaN, which reads as 100 %.
256
324
  humidity = [(first, DEFAULT_HUMIDITY_PCT), (last, DEFAULT_HUMIDITY_PCT)]
257
- gap = max_gap(index)
258
- n_gaps = int((_steps(index) > gap.total_seconds() * 1e9).sum())
325
+ if gaps is None:
326
+ gap = max_gap(index)
327
+ n_gaps = int((_steps(index) > gap.total_seconds() * 1e9).sum())
328
+ else:
329
+ gap, n_gaps = gaps
259
330
  sa.add_inputs(
260
331
  frame,
261
332
  air=series,