nwp500-python 9.5.1__tar.gz → 9.6.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 (340) hide show
  1. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/CHANGELOG.rst +37 -0
  2. {nwp500_python-9.5.1/src/nwp500_python.egg-info → nwp500_python-9.6.0}/PKG-INFO +1 -1
  3. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/how-to/calibrate-simulator.rst +80 -39
  4. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/reference/python_api/sim.rst +5 -0
  5. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/setup.cfg +11 -0
  6. nwp500_python-9.6.0/src/nwp500/cli/calibration.py +190 -0
  7. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/sim_adapters.py +126 -62
  8. nwp500_python-9.5.1/src/nwp500/cli/calibration.py → nwp500_python-9.6.0/src/nwp500/sim_calibrate.py +149 -191
  9. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/status_history.py +12 -5
  10. {nwp500_python-9.5.1 → nwp500_python-9.6.0/src/nwp500_python.egg-info}/PKG-INFO +1 -1
  11. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500_python.egg-info/SOURCES.txt +2 -0
  12. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500_python.egg-info/scm_file_list.json +2 -0
  13. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500_python.egg-info/scm_version.json +2 -2
  14. nwp500_python-9.6.0/tests/test_cli_calibrate.py +217 -0
  15. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_sim_adapters.py +45 -3
  16. nwp500_python-9.5.1/tests/test_cli_calibrate.py → nwp500_python-9.6.0/tests/test_sim_calibrate.py +119 -157
  17. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/.coveragerc +0 -0
  18. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/.github/RESOLVING_PR_COMMENTS.md +0 -0
  19. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/.github/copilot-instructions.md +0 -0
  20. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/.github/workflows/ci.yml +0 -0
  21. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/.github/workflows/release.yml +0 -0
  22. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/.gitignore +0 -0
  23. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/.pre-commit-config.yaml +0 -0
  24. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/.readthedocs.yml +0 -0
  25. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/AUTHORS.rst +0 -0
  26. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/CONTRIBUTING.rst +0 -0
  27. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/LICENSE.txt +0 -0
  28. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/Makefile +0 -0
  29. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/README.rst +0 -0
  30. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/RELEASE.md +0 -0
  31. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/Makefile +0 -0
  32. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/_static/.gitignore +0 -0
  33. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/conf.py +0 -0
  34. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/explanation/architecture.rst +0 -0
  35. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/explanation/draws.rst +0 -0
  36. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/explanation/heating-elements.rst +0 -0
  37. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/explanation/index.rst +0 -0
  38. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/explanation/recovery-speed.rst +0 -0
  39. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/explanation/simulator.rst +0 -0
  40. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/explanation/standby-loss.rst +0 -0
  41. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/explanation/tank-energy.rst +0 -0
  42. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/explanation/tou-recovery-cap.rst +0 -0
  43. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/explanation/unknown-values.rst +0 -0
  44. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/explanation/what-starts-a-recovery.rst +0 -0
  45. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/how-to/authenticate.rst +0 -0
  46. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/how-to/auto-recovery.rst +0 -0
  47. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/how-to/bluetooth.rst +0 -0
  48. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/how-to/diagnose-mqtt.rst +0 -0
  49. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/how-to/home-assistant.rst +0 -0
  50. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/how-to/index.rst +0 -0
  51. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/how-to/maintenance.rst +0 -0
  52. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/how-to/manage-units.rst +0 -0
  53. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/how-to/monitor-status.rst +0 -0
  54. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/how-to/optimize-tou.rst +0 -0
  55. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/how-to/queue-commands.rst +0 -0
  56. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/how-to/schedule-operation.rst +0 -0
  57. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/how-to/simulate-heater.rst +0 -0
  58. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/how-to/track-energy.rst +0 -0
  59. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/index.rst +0 -0
  60. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/openapi.yaml +0 -0
  61. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/project/authors.rst +0 -0
  62. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/project/changelog.rst +0 -0
  63. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/project/contributing.rst +0 -0
  64. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/project/history.rst +0 -0
  65. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/project/license.rst +0 -0
  66. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/reference/configuration.rst +0 -0
  67. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/reference/enumerations.rst +0 -0
  68. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/reference/index.rst +0 -0
  69. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/reference/installation.rst +0 -0
  70. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/reference/protocol/data_conversions.rst +0 -0
  71. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/reference/protocol/device_features.rst +0 -0
  72. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/reference/protocol/device_status.rst +0 -0
  73. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/reference/protocol/error_codes.rst +0 -0
  74. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/reference/protocol/mqtt_protocol.rst +0 -0
  75. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/reference/protocol/quick_reference.rst +0 -0
  76. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/reference/protocol/rest_api.rst +0 -0
  77. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/reference/python_api/api_client.rst +0 -0
  78. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/reference/python_api/auth_client.rst +0 -0
  79. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/reference/python_api/cli.rst +0 -0
  80. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/reference/python_api/events.rst +0 -0
  81. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/reference/python_api/exceptions.rst +0 -0
  82. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/reference/python_api/models.rst +0 -0
  83. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/reference/python_api/mqtt_client.rst +0 -0
  84. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/reference/simulator-inputs.rst +0 -0
  85. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/requirements.txt +0 -0
  86. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/docs/tutorials/getting-started.rst +0 -0
  87. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/.ruff.toml +0 -0
  88. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/README.md +0 -0
  89. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/advanced/air_filter_reset.py +0 -0
  90. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/advanced/anti_legionella.py +0 -0
  91. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/advanced/auto_recovery.py +0 -0
  92. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/advanced/combined_callbacks.py +0 -0
  93. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/advanced/demand_response.py +0 -0
  94. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/advanced/device_capabilities.py +0 -0
  95. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/advanced/device_status_debug.py +0 -0
  96. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/advanced/energy_analytics.py +0 -0
  97. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/advanced/error_code_demo.py +0 -0
  98. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/advanced/firmware_payload_capture.py +0 -0
  99. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/advanced/mqtt_diagnostics.py +0 -0
  100. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/advanced/power_control.py +0 -0
  101. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/advanced/recirculation_control.py +0 -0
  102. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/advanced/reconnection_demo.py +0 -0
  103. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/advanced/reservation_schedule.py +0 -0
  104. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/advanced/simple_auto_recovery.py +0 -0
  105. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/advanced/token_restoration.py +0 -0
  106. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/advanced/tou_openei.py +0 -0
  107. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/advanced/tou_schedule.py +0 -0
  108. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/advanced/water_reservation.py +0 -0
  109. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/beginner/01_authentication.py +0 -0
  110. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/beginner/02_list_devices.py +0 -0
  111. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/beginner/03_get_status.py +0 -0
  112. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/beginner/04_set_temperature.py +0 -0
  113. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/intermediate/advanced_auth_patterns.py +0 -0
  114. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/intermediate/command_queue.py +0 -0
  115. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/intermediate/device_status_callback.py +0 -0
  116. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/intermediate/error_handling.py +0 -0
  117. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/intermediate/event_driven_control.py +0 -0
  118. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/intermediate/improved_auth.py +0 -0
  119. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/intermediate/legacy_auth_constructor.py +0 -0
  120. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/intermediate/mqtt_realtime_monitoring.py +0 -0
  121. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/intermediate/periodic_requests.py +0 -0
  122. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/intermediate/set_mode.py +0 -0
  123. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/intermediate/vacation_mode.py +0 -0
  124. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/mask.py +0 -0
  125. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/simulator/lower-setpoint.json +0 -0
  126. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/simulator/plan.json +0 -0
  127. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/simulator/scenario.json +0 -0
  128. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/testing/periodic_device_info.py +0 -0
  129. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/testing/simple_periodic_info.py +0 -0
  130. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/testing/test_api_client.py +0 -0
  131. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/testing/test_mqtt_connection.py +0 -0
  132. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/testing/test_mqtt_messaging.py +0 -0
  133. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/examples/testing/test_periodic_minimal.py +0 -0
  134. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/pyproject.toml +0 -0
  135. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/scripts/README.md +0 -0
  136. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/scripts/bump_version.py +0 -0
  137. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/scripts/diagnose_mqtt_connection.py +0 -0
  138. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/scripts/extract_changelog.py +0 -0
  139. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/scripts/format.py +0 -0
  140. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/scripts/lint.py +0 -0
  141. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/scripts/setup-dev.py +0 -0
  142. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/scripts/validate_version.py +0 -0
  143. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/setup.py +0 -0
  144. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/__init__.py +0 -0
  145. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/_base.py +0 -0
  146. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/api_client.py +0 -0
  147. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/auth.py +0 -0
  148. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/ble/__init__.py +0 -0
  149. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/ble/__main__.py +0 -0
  150. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/ble/client.py +0 -0
  151. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/ble/mqtt_adapter.py +0 -0
  152. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/ble/protocol.py +0 -0
  153. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/cli/__init__.py +0 -0
  154. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/cli/__main__.py +0 -0
  155. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/cli/ble_backend.py +0 -0
  156. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/cli/handlers.py +0 -0
  157. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/cli/monitoring.py +0 -0
  158. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/cli/output_formatters.py +0 -0
  159. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/cli/presentation.py +0 -0
  160. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/cli/recording.py +0 -0
  161. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/cli/rich_output.py +0 -0
  162. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/cli/simulate.py +0 -0
  163. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/cli/token_storage.py +0 -0
  164. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/command_decorators.py +0 -0
  165. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/config.py +0 -0
  166. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/converters.py +0 -0
  167. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/device_capabilities.py +0 -0
  168. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/device_info_cache.py +0 -0
  169. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/encoding.py +0 -0
  170. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/enums.py +0 -0
  171. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/events.py +0 -0
  172. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/exceptions.py +0 -0
  173. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/factory.py +0 -0
  174. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/field_factory.py +0 -0
  175. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/models/__init__.py +0 -0
  176. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/models/_converters.py +0 -0
  177. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/models/account.py +0 -0
  178. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/models/device.py +0 -0
  179. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/models/diagnostics.py +0 -0
  180. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/models/energy.py +0 -0
  181. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/models/feature.py +0 -0
  182. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/models/firmware.py +0 -0
  183. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/models/maintenance.py +0 -0
  184. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/models/mqtt_models.py +0 -0
  185. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/models/schedule.py +0 -0
  186. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/models/status.py +0 -0
  187. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/models/tou.py +0 -0
  188. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/mqtt/__init__.py +0 -0
  189. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/mqtt/_control_commands.py +0 -0
  190. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/mqtt/_device_subscriptions.py +0 -0
  191. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/mqtt/client.py +0 -0
  192. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/mqtt/command_queue.py +0 -0
  193. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/mqtt/connection.py +0 -0
  194. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/mqtt/control.py +0 -0
  195. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/mqtt/diagnostics.py +0 -0
  196. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/mqtt/periodic.py +0 -0
  197. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/mqtt/reconnection.py +0 -0
  198. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/mqtt/state_tracker.py +0 -0
  199. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/mqtt/subscriptions.py +0 -0
  200. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/mqtt/types.py +0 -0
  201. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/mqtt/utils.py +0 -0
  202. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/mqtt_events.py +0 -0
  203. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/openei.py +0 -0
  204. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/py.typed +0 -0
  205. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/reservations.py +0 -0
  206. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/sim/__init__.py +0 -0
  207. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/sim/api.py +0 -0
  208. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/sim/attribution.py +0 -0
  209. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/sim/book.py +0 -0
  210. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/sim/calibration.py +0 -0
  211. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/sim/circulation.py +0 -0
  212. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/sim/codec.py +0 -0
  213. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/sim/cold_supply.py +0 -0
  214. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/sim/controller.py +0 -0
  215. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/sim/decay.py +0 -0
  216. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/sim/defaults.py +0 -0
  217. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/sim/delivery.py +0 -0
  218. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/sim/draw_forecast.py +0 -0
  219. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/sim/engine.py +0 -0
  220. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/sim/extensions.py +0 -0
  221. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/sim/heat_pump_recovery.py +0 -0
  222. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/sim/heating_trajectory.py +0 -0
  223. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/sim/inputs.py +0 -0
  224. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/sim/location_air.py +0 -0
  225. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/sim/meter.py +0 -0
  226. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/sim/observed.py +0 -0
  227. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/sim/params.py +0 -0
  228. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/sim/physics/__init__.py +0 -0
  229. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/sim/physics/compressor.py +0 -0
  230. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/sim/physics/draw.py +0 -0
  231. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/sim/physics/element.py +0 -0
  232. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/sim/physics/refrigerant.py +0 -0
  233. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/sim/physics/tank.py +0 -0
  234. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/sim/profile.py +0 -0
  235. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/sim/ready.py +0 -0
  236. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/sim/record.py +0 -0
  237. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/sim/result.py +0 -0
  238. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/sim/schedules.py +0 -0
  239. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/sim/tank_physics.py +0 -0
  240. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/sim/tou_triggers.py +0 -0
  241. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/sim/units.py +0 -0
  242. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/sim_home_assistant.py +0 -0
  243. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/sim_live.py +0 -0
  244. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/temperature.py +0 -0
  245. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/topic_builder.py +0 -0
  246. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/tou_schedule.py +0 -0
  247. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/unit_system.py +0 -0
  248. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500/utils.py +0 -0
  249. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500_python.egg-info/dependency_links.txt +0 -0
  250. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500_python.egg-info/entry_points.txt +0 -0
  251. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500_python.egg-info/not-zip-safe +0 -0
  252. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500_python.egg-info/requires.txt +0 -0
  253. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/src/nwp500_python.egg-info/top_level.txt +0 -0
  254. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/_sim_site.py +0 -0
  255. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/conftest.py +0 -0
  256. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/fixtures/sim/engine_golden.json +0 -0
  257. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/fixtures/sim/heater_params.json +0 -0
  258. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/fixtures/sim/heater_params_fitted.json +0 -0
  259. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_api_endpoints_201.py +0 -0
  260. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_api_helpers.py +0 -0
  261. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_app_queries.py +0 -0
  262. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_auth.py +0 -0
  263. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_auth_session_lifecycle.py +0 -0
  264. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_auth_session_refresh.py +0 -0
  265. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_ble_mqtt_adapter.py +0 -0
  266. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_ble_protocol.py +0 -0
  267. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_bug_fixes.py +0 -0
  268. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_canonical_schedule.py +0 -0
  269. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_cli_basic.py +0 -0
  270. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_cli_commands.py +0 -0
  271. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_cli_simulate.py +0 -0
  272. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_cli_tristate_presentation.py +0 -0
  273. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_command_decorators.py +0 -0
  274. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_command_queue.py +0 -0
  275. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_device_capabilities.py +0 -0
  276. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_device_info_cache.py +0 -0
  277. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_device_rest_models.py +0 -0
  278. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_events.py +0 -0
  279. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_exceptions.py +0 -0
  280. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_model_converters.py +0 -0
  281. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_models.py +0 -0
  282. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_mqtt_clean_session_resume.py +0 -0
  283. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_mqtt_client_init.py +0 -0
  284. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_mqtt_control_fail.py +0 -0
  285. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_mqtt_events.py +0 -0
  286. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_mqtt_hypothesis.py +0 -0
  287. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_mqtt_last_will.py +0 -0
  288. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_mqtt_reconnection.py +0 -0
  289. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_mqtt_reconnection_storm.py +0 -0
  290. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_mqtt_reliability.py +0 -0
  291. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_multi_device.py +0 -0
  292. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_navilink_201_enums.py +0 -0
  293. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_openei.py +0 -0
  294. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_protocol_correctness.py +0 -0
  295. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_public_api.py +0 -0
  296. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_reservation_validation.py +0 -0
  297. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_reservations.py +0 -0
  298. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_sim_api.py +0 -0
  299. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_sim_book.py +0 -0
  300. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_sim_book_history.py +0 -0
  301. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_sim_calibration.py +0 -0
  302. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_sim_circulation.py +0 -0
  303. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_sim_codec.py +0 -0
  304. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_sim_cold_supply.py +0 -0
  305. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_sim_controller.py +0 -0
  306. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_sim_defaults.py +0 -0
  307. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_sim_draw_forecast.py +0 -0
  308. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_sim_draw_loss.py +0 -0
  309. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_sim_element.py +0 -0
  310. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_sim_element_fit.py +0 -0
  311. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_sim_forecast.py +0 -0
  312. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_sim_golden.py +0 -0
  313. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_sim_home_assistant.py +0 -0
  314. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_sim_hot_water.py +0 -0
  315. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_sim_import_contract.py +0 -0
  316. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_sim_live.py +0 -0
  317. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_sim_location_air.py +0 -0
  318. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_sim_meter.py +0 -0
  319. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_sim_no_clock_no_state.py +0 -0
  320. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_sim_observed.py +0 -0
  321. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_sim_profile.py +0 -0
  322. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_sim_readiness.py +0 -0
  323. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_sim_readiness_fit.py +0 -0
  324. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_sim_ready.py +0 -0
  325. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_sim_ready_api.py +0 -0
  326. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_sim_replay.py +0 -0
  327. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_sim_schedules.py +0 -0
  328. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_sim_tank_physics.py +0 -0
  329. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_status_history.py +0 -0
  330. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_temperature_converters.py +0 -0
  331. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_threading_model.py +0 -0
  332. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_token_storage.py +0 -0
  333. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_tou_api.py +0 -0
  334. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_tou_schedule.py +0 -0
  335. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_unit_switching.py +0 -0
  336. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_unit_system_process_wide.py +0 -0
  337. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_utility_modules.py +0 -0
  338. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tests/test_utils.py +0 -0
  339. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/tox.ini +0 -0
  340. {nwp500_python-9.5.1 → nwp500_python-9.6.0}/uv.lock +0 -0
@@ -2,6 +2,43 @@
2
2
  Changelog
3
3
  =========
4
4
 
5
+ Version 9.6.0 (2026-10-21)
6
+ ==========================
7
+
8
+ Added
9
+ -----
10
+ - **Calibration outside the CLI** (``nwp500.sim_calibrate``, #177).
11
+ ``calibrate``, ``Calibrated``, ``report``, ``Term`` and ``max_gap``
12
+ moved here from ``nwp500.cli.calibration`` so an application can
13
+ calibrate without ``click`` or ``rich``. ``calibrate`` takes
14
+ ``HeaterParams`` or a parameters document as ``params`` and raises
15
+ ``CalibrationInputError`` (a ``ValueError``). Its notes no longer name
16
+ the command's options; ``nwp-cli simulate calibrate`` adds them.
17
+ - ``status_history.iter_read`` yields a recording's statuses one at a
18
+ time.
19
+ - ``sim_adapters.add_inputs`` adds the air, its humidity, a meter's
20
+ draws, the circulation's runs and the unobserved gaps to a frame
21
+ ``sim_adapters.history`` built.
22
+
23
+ Changed
24
+ -------
25
+ - **A long history calibrates in bounded memory** (#177).
26
+ ``sim_adapters.history`` reads its statuses one at a time and keeps
27
+ only the frame's values, and ``sim_calibrate.calibrate`` takes the
28
+ record's span and gaps from the frame, so a generator over day files
29
+ is read once. 60 days of statuses (about 540,000) now take about
30
+ 150 MB instead of about 6 GB of parsed statuses. The drawing and
31
+ circulating columns are computed per span instead of per row and
32
+ span.
33
+
34
+ Documentation
35
+ -------------
36
+ - The calibration how-to says the recorder writes every status the
37
+ heater sends and what that costs on disk, shows calibrating from
38
+ day files with ``sim_calibrate``, adds calibrating in an
39
+ application, and says what Home Assistant's 10-day retention means
40
+ for a longer record.
41
+
5
42
  Version 9.5.1 (2026-10-21)
6
43
  ==========================
7
44
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nwp500-python
3
- Version: 9.5.1
3
+ Version: 9.6.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
@@ -14,9 +14,13 @@ fit its standby loss, its compressor and its power to that record.
14
14
  Record the history
15
15
  ==================
16
16
 
17
- ``nwp-cli simulate record`` asks the heater for its status every 30
18
- seconds and appends each one to a file until you stop it (Ctrl+C). It
19
- needs the ``cli`` extra and no other:
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:
20
24
 
21
25
  .. code-block:: bash
22
26
 
@@ -35,11 +39,15 @@ can have gaps. The heater may have heated or delivered water during a
35
39
  gap, so ``calibrate`` takes any spacing between statuses of more than
36
40
  4 times the record's usual spacing (and at least 5 minutes) as
37
41
  unobserved. No quiet window and no recovery spans such a gap, and the
38
- report counts them. Write the file any other way you like (an MQTT callback, an
39
- export from a home-automation recorder) as long as each line has this
40
- shape: ``nwp500.status_history.line(instant, raw_status)`` writes one,
41
- and ``status_history.read(lines)`` reads them back as
42
- ``(instant, DeviceStatus)`` pairs.
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.
43
51
 
44
52
  Record through quiet nights and full recoveries. The standby fit needs
45
53
  windows of 3 hours or more with no heating and no hot water in use. The
@@ -75,10 +83,14 @@ Idle, it reads the cabinet, about 6 °F warm. So give one of these:
75
83
  air beside the heater and its humidity over the record. It also reads
76
84
  the meter's draws and the circulation's runs, which make the quiet
77
85
  test exact: the heater's own "hot water in use" flag also fires on
78
- circulation. Home Assistant keeps 10 days of history by default. The
79
- air is used only over the span its history covers, and not while its
80
- sensor was ``unavailable``; the humidity takes the 50 % default
81
- outside that span, and the report says how much that is. Where the
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
82
94
  meter's or the circulation's history doesn't cover the whole record,
83
95
  the heater's own flag is used instead. ``--air``, ``--ambient`` and
84
96
  ``--humidity`` override the sources.
@@ -158,37 +170,66 @@ meter's draws, which the command does not take (see below).
158
170
  From Python
159
171
  ===========
160
172
 
161
- The command runs ``sim.calibration.calibrate``:
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:
162
177
 
163
178
  .. code-block:: python
164
179
 
165
- from datetime import timedelta
180
+ from pathlib import Path
181
+
182
+ from nwp500 import sim, sim_calibrate, status_history
166
183
 
167
- from nwp500 import sim, sim_adapters as sa, status_history
168
- from nwp500.sim import calibration
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)
169
188
 
170
- with open("history.jsonl") as f:
171
- readings = status_history.read(f)
172
- history = sa.history(
173
- readings,
189
+ done = sim_calibrate.calibrate(
190
+ recorded(sorted(Path("history").glob("*.jsonl"))),
174
191
  air=air_readings, # [(instant, temperature), ...]
175
192
  humidity=humidity_readings, # [(instant, %RH), ...]
176
- max_gap=timedelta(minutes=5),
193
+ params=sim.default_params(), # or your own HeaterParams
177
194
  )
178
- fitted = calibration.calibrate(history, sim.default_params(),
179
- recovery_air="air_f")
180
- params = fitted.params
181
-
182
- Give the humidity; if you have none, pass a constant 50 %, as the
183
- command does. ``max_gap`` is what
184
- marks a recording's gaps as unobserved; without it every span between
185
- two statuses is taken as observed.
186
-
187
- From Python you can also give a meter's draws and the circulation's
188
- runs, which make the quiet test exact and price the heat each cycle's
189
- draws took. The fits for the readiness terms, the heat book, the draw
190
- response and the elements also run there: see "Improve accuracy"
191
- and "Fit the elements" in :doc:`simulate-heater`.
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`.
192
233
 
193
234
 
194
235
  Limitations
@@ -202,13 +243,13 @@ Limitations
202
243
  heater's own "hot water in use" flag, which also fires on
203
244
  circulation. A loop's heat that does not trip the flag is invisible
204
245
  to the heater and lands in the standby loss. From Python, pass the
205
- circulation's runs (``sa.history(..., circulating=...)``) to keep
206
- them out of the quiet windows.
246
+ circulation's runs (``record=`` or ``sa.history(...,
247
+ circulating=...)``) to keep them out of the quiet windows.
207
248
 
208
249
 
209
250
  See also
210
251
  ========
211
252
 
212
253
  * :doc:`simulate-heater`: run the simulator with your parameters
213
- * :doc:`/reference/python_api/sim`: ``sim.calibration``,
214
- ``sim_adapters.history`` and ``status_history``
254
+ * :doc:`/reference/python_api/sim`: ``sim_calibrate``,
255
+ ``sim.calibration``, ``sim_adapters.history`` and ``status_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, 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
  ======================
@@ -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")
@@ -44,10 +44,11 @@ Example::
44
44
 
45
45
  import itertools
46
46
  import math
47
+ from array import array
47
48
  from collections.abc import Callable, Iterable, Mapping, Sequence
48
49
  from dataclasses import dataclass, replace
49
50
  from datetime import UTC, datetime, time, timedelta
50
- from typing import TYPE_CHECKING, Any
51
+ from typing import TYPE_CHECKING, Any, cast
51
52
  from zoneinfo import ZoneInfo
52
53
 
53
54
  import numpy as np
@@ -581,21 +582,80 @@ def _hybrid(status: DeviceStatus) -> bool:
581
582
 
582
583
  def _interval_column(
583
584
  index: pd.DatetimeIndex, spans: Iterable[tuple[datetime, datetime]]
584
- ) -> list[bool]:
585
+ ) -> np.ndarray:
585
586
  """Each row's interval (its instant to the next row's) that overlaps
586
- a span: a span wholly between two samples marks the first."""
587
+ a span: a span wholly between two samples marks the first. The
588
+ index is in time order."""
589
+ import pandas as pd
590
+
591
+ t = index.as_unit("ns").asi8
592
+ n = len(t)
593
+ out = np.zeros(n, dtype=bool)
587
594
  spans = list(spans)
588
- times = list(index)
589
- out: list[bool] = []
590
- for i, t in enumerate(times):
591
- if i + 1 < len(times):
592
- nxt = times[i + 1]
593
- out.append(any(a < nxt and b > t for a, b in spans))
594
- else:
595
- out.append(any(a <= t < b for a, b in spans))
595
+ if not spans or not n:
596
+ return out
597
+ # utc=True takes instants with differing offsets (across a change
598
+ # of clocks).
599
+ starts = pd.to_datetime([a for a, _ in spans], utc=True)
600
+ ends = pd.to_datetime([b for _, b in spans], utc=True)
601
+ a = starts.as_unit("ns").asi8
602
+ b = ends.as_unit("ns").asi8
603
+ # Row i < n - 1 is marked when a < t[i + 1] and b > t[i]: from the
604
+ # last row at or before a to the last row before b.
605
+ lo = np.maximum(np.searchsorted(t, a, side="right") - 1, 0)
606
+ hi = np.minimum(np.searchsorted(t, b, side="left"), n - 1)
607
+ keep = hi > lo
608
+ marks = np.zeros(n + 1, dtype=np.int64)
609
+ np.add.at(marks, lo[keep], 1)
610
+ np.add.at(marks, hi[keep], -1)
611
+ out[:] = np.cumsum(marks[:n]) > 0
612
+ # The last row has no next: a span holding its instant marks it.
613
+ out[-1] = bool(((a <= t[-1]) & (t[-1] < b)).any())
596
614
  return out
597
615
 
598
616
 
617
+ #: The epoch, for an instant as whole microseconds.
618
+ _EPOCH = datetime(1970, 1, 1, tzinfo=UTC)
619
+ _US = timedelta(microseconds=1)
620
+
621
+ #: The history's columns from a status, in order, and their type code.
622
+ _COLUMNS = (
623
+ ("upper_f", "d"),
624
+ ("lower_f", "d"),
625
+ ("compressor_on", "b"),
626
+ ("element_on", "b"),
627
+ ("dhw_in_use", "b"),
628
+ ("target_f", "d"),
629
+ ("excluded", "b"),
630
+ ("power_w", "d"),
631
+ ("energy_to_setpoint_wh", "d"),
632
+ ("heater_air_f", "d"),
633
+ )
634
+
635
+
636
+ def _row(s: DeviceStatus) -> tuple[float | bool, ...]:
637
+ """A status's values, in ``_COLUMNS``' order."""
638
+ return (
639
+ _from_device(DeciCelsius(s.tank_upper_temperature_raw)),
640
+ _from_device(DeciCelsius(s.tank_lower_temperature_raw)),
641
+ bool(s.comp_use),
642
+ bool(s.heat_upper_use)
643
+ or bool(s.heat_lower_use)
644
+ or s.current_heat_use
645
+ in (HeatSource.HEATELEMENT, HeatSource.HEATPUMP_HEATELEMENT),
646
+ bool(s.dhw_use),
647
+ _from_device(HalfCelsius(s.hp_lower_off_temp_setting_raw)),
648
+ _hybrid(s)
649
+ or bool(s.anti_legionella_operation_busy)
650
+ or s.error_code != ErrorCode.NO_ERROR,
651
+ float(s.current_inst_power),
652
+ float(s.energy_to_setpoint),
653
+ _from_device(DeciCelsius(s.ambient_temperature_raw))
654
+ if s.comp_use
655
+ else float("nan"),
656
+ )
657
+
658
+
599
659
  def history(
600
660
  readings: Iterable[tuple[datetime, DeviceStatus]],
601
661
  *,
@@ -611,10 +671,59 @@ def history(
611
671
  Everything a status carries is read from it: the probes, what is
612
672
  heating, ``dhw_use``, the lower probe's stop threshold, what rules a
613
673
  sample out of a heat-pump recovery, the power it draws, the energy
614
- still to heat, and
615
- the heater's own air
616
- reading while its compressor runs. What it does not carry is the
617
- caller's, in the preferred unit:
674
+ still to heat, and the heater's own air reading while its compressor
675
+ runs. ``readings`` is taken one at a time and only those values are
676
+ kept, so a generator over a long recording
677
+ (``status_history.iter_read``) needs memory for the history, not
678
+ for its statuses.
679
+
680
+ What a status does not carry is the caller's, as
681
+ ``add_inputs`` takes it.
682
+ """
683
+ import pandas as pd
684
+
685
+ times: array[int] = array("q")
686
+ values: list[array[Any]] = [array(code) for _, code in _COLUMNS]
687
+ for at, status in readings:
688
+ times.append((at - _EPOCH) // _US)
689
+ for column, value in zip(values, _row(status), strict=True):
690
+ column.append(value)
691
+ stamps = (
692
+ np.frombuffer(times, dtype=np.int64) if times else np.empty(0, np.int64)
693
+ )
694
+ order = np.argsort(stamps, kind="stable")
695
+ ordered = bool(np.all(order == np.arange(len(order))))
696
+ data: dict[str, np.ndarray] = {}
697
+ for (name, code), column in zip(_COLUMNS, values, strict=True):
698
+ a = np.array(column, dtype=float if code == "d" else bool)
699
+ data[name] = a if ordered else a[order]
700
+ index = pd.DatetimeIndex(
701
+ pd.to_datetime(
702
+ stamps if ordered else stamps[order], unit="us", utc=True
703
+ )
704
+ ).as_unit("ns")
705
+ frame = pd.DataFrame(data, index=index)
706
+ return add_inputs(
707
+ frame,
708
+ air=air,
709
+ humidity=humidity,
710
+ drawing=drawing,
711
+ circulating=circulating,
712
+ max_gap=max_gap,
713
+ )
714
+
715
+
716
+ def add_inputs(
717
+ frame: pd.DataFrame,
718
+ *,
719
+ air: Iterable[tuple[datetime, float]] | None = None,
720
+ humidity: Iterable[tuple[datetime, float]] | None = None,
721
+ drawing: Iterable[tuple[datetime, datetime]] | None = None,
722
+ circulating: Iterable[tuple[datetime, datetime]] | None = None,
723
+ max_gap: timedelta | None = None,
724
+ ) -> pd.DataFrame:
725
+ """``frame`` (``history``'s) with what its statuses do not carry,
726
+ added in place, in the preferred unit:
618
727
 
619
728
  - ``air``: the air where the heater stands, interpolated in time onto
620
729
  the readings (a sensor's, or ``location_air``'s projection); the
@@ -634,53 +743,8 @@ def history(
634
743
  window and no recovery spans it. Without it, every span between two
635
744
  readings is taken as observed.
636
745
  """
637
- import pandas as pd
638
-
639
- rows = sorted(readings, key=lambda r: r[0])
640
- index = pd.DatetimeIndex([at for at, _ in rows]).tz_convert("UTC")
641
- frame = pd.DataFrame(
642
- {
643
- "upper_f": [
644
- _from_device(DeciCelsius(s.tank_upper_temperature_raw))
645
- for _, s in rows
646
- ],
647
- "lower_f": [
648
- _from_device(DeciCelsius(s.tank_lower_temperature_raw))
649
- for _, s in rows
650
- ],
651
- "compressor_on": [bool(s.comp_use) for _, s in rows],
652
- "element_on": [
653
- bool(s.heat_upper_use)
654
- or bool(s.heat_lower_use)
655
- or s.current_heat_use
656
- in (HeatSource.HEATELEMENT, HeatSource.HEATPUMP_HEATELEMENT)
657
- for _, s in rows
658
- ],
659
- "dhw_in_use": [bool(s.dhw_use) for _, s in rows],
660
- "target_f": [
661
- _from_device(HalfCelsius(s.hp_lower_off_temp_setting_raw))
662
- for _, s in rows
663
- ],
664
- "excluded": [
665
- _hybrid(s)
666
- or bool(s.anti_legionella_operation_busy)
667
- or s.error_code != ErrorCode.NO_ERROR
668
- for _, s in rows
669
- ],
670
- "power_w": [float(s.current_inst_power) for _, s in rows],
671
- "energy_to_setpoint_wh": [
672
- float(s.energy_to_setpoint) for _, s in rows
673
- ],
674
- "heater_air_f": [
675
- _from_device(DeciCelsius(s.ambient_temperature_raw))
676
- if s.comp_use
677
- else float("nan")
678
- for _, s in rows
679
- ],
680
- },
681
- index=index,
682
- )
683
- seconds = [t.timestamp() for t in index]
746
+ index = cast("pd.DatetimeIndex", frame.index)
747
+ seconds = index.as_unit("ns").asi8 / 1e9
684
748
  for name, series in (("air_f", air), ("humidity_pct", humidity)):
685
749
  if series is None:
686
750
  continue