nwp500-python 9.6.0__tar.gz → 9.8.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 (344) hide show
  1. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/CHANGELOG.rst +60 -0
  2. {nwp500_python-9.6.0/src/nwp500_python.egg-info → nwp500_python-9.8.0}/PKG-INFO +1 -1
  3. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/how-to/calibrate-simulator.rst +127 -1
  4. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/reference/python_api/sim.rst +8 -4
  5. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/sim/calibration.py +205 -111
  6. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/sim_adapters.py +173 -1
  7. nwp500_python-9.8.0/src/nwp500/sim_calibrate.py +1202 -0
  8. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/sim_home_assistant.py +288 -0
  9. {nwp500_python-9.6.0 → nwp500_python-9.8.0/src/nwp500_python.egg-info}/PKG-INFO +1 -1
  10. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500_python.egg-info/SOURCES.txt +1 -0
  11. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500_python.egg-info/scm_file_list.json +1 -0
  12. nwp500_python-9.8.0/src/nwp500_python.egg-info/scm_version.json +8 -0
  13. nwp500_python-9.8.0/tests/test_sim_calibrate.py +1380 -0
  14. nwp500_python-9.8.0/tests/test_sim_change_history.py +572 -0
  15. nwp500_python-9.6.0/src/nwp500/sim_calibrate.py +0 -446
  16. nwp500_python-9.6.0/src/nwp500_python.egg-info/scm_version.json +0 -8
  17. nwp500_python-9.6.0/tests/test_sim_calibrate.py +0 -608
  18. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/.coveragerc +0 -0
  19. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/.github/RESOLVING_PR_COMMENTS.md +0 -0
  20. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/.github/copilot-instructions.md +0 -0
  21. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/.github/workflows/ci.yml +0 -0
  22. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/.github/workflows/release.yml +0 -0
  23. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/.gitignore +0 -0
  24. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/.pre-commit-config.yaml +0 -0
  25. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/.readthedocs.yml +0 -0
  26. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/AUTHORS.rst +0 -0
  27. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/CONTRIBUTING.rst +0 -0
  28. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/LICENSE.txt +0 -0
  29. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/Makefile +0 -0
  30. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/README.rst +0 -0
  31. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/RELEASE.md +0 -0
  32. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/Makefile +0 -0
  33. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/_static/.gitignore +0 -0
  34. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/conf.py +0 -0
  35. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/explanation/architecture.rst +0 -0
  36. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/explanation/draws.rst +0 -0
  37. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/explanation/heating-elements.rst +0 -0
  38. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/explanation/index.rst +0 -0
  39. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/explanation/recovery-speed.rst +0 -0
  40. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/explanation/simulator.rst +0 -0
  41. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/explanation/standby-loss.rst +0 -0
  42. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/explanation/tank-energy.rst +0 -0
  43. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/explanation/tou-recovery-cap.rst +0 -0
  44. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/explanation/unknown-values.rst +0 -0
  45. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/explanation/what-starts-a-recovery.rst +0 -0
  46. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/how-to/authenticate.rst +0 -0
  47. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/how-to/auto-recovery.rst +0 -0
  48. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/how-to/bluetooth.rst +0 -0
  49. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/how-to/diagnose-mqtt.rst +0 -0
  50. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/how-to/home-assistant.rst +0 -0
  51. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/how-to/index.rst +0 -0
  52. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/how-to/maintenance.rst +0 -0
  53. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/how-to/manage-units.rst +0 -0
  54. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/how-to/monitor-status.rst +0 -0
  55. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/how-to/optimize-tou.rst +0 -0
  56. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/how-to/queue-commands.rst +0 -0
  57. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/how-to/schedule-operation.rst +0 -0
  58. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/how-to/simulate-heater.rst +0 -0
  59. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/how-to/track-energy.rst +0 -0
  60. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/index.rst +0 -0
  61. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/openapi.yaml +0 -0
  62. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/project/authors.rst +0 -0
  63. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/project/changelog.rst +0 -0
  64. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/project/contributing.rst +0 -0
  65. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/project/history.rst +0 -0
  66. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/project/license.rst +0 -0
  67. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/reference/configuration.rst +0 -0
  68. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/reference/enumerations.rst +0 -0
  69. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/reference/index.rst +0 -0
  70. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/reference/installation.rst +0 -0
  71. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/reference/protocol/data_conversions.rst +0 -0
  72. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/reference/protocol/device_features.rst +0 -0
  73. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/reference/protocol/device_status.rst +0 -0
  74. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/reference/protocol/error_codes.rst +0 -0
  75. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/reference/protocol/mqtt_protocol.rst +0 -0
  76. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/reference/protocol/quick_reference.rst +0 -0
  77. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/reference/protocol/rest_api.rst +0 -0
  78. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/reference/python_api/api_client.rst +0 -0
  79. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/reference/python_api/auth_client.rst +0 -0
  80. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/reference/python_api/cli.rst +0 -0
  81. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/reference/python_api/events.rst +0 -0
  82. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/reference/python_api/exceptions.rst +0 -0
  83. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/reference/python_api/models.rst +0 -0
  84. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/reference/python_api/mqtt_client.rst +0 -0
  85. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/reference/simulator-inputs.rst +0 -0
  86. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/requirements.txt +0 -0
  87. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/docs/tutorials/getting-started.rst +0 -0
  88. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/.ruff.toml +0 -0
  89. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/README.md +0 -0
  90. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/advanced/air_filter_reset.py +0 -0
  91. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/advanced/anti_legionella.py +0 -0
  92. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/advanced/auto_recovery.py +0 -0
  93. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/advanced/combined_callbacks.py +0 -0
  94. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/advanced/demand_response.py +0 -0
  95. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/advanced/device_capabilities.py +0 -0
  96. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/advanced/device_status_debug.py +0 -0
  97. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/advanced/energy_analytics.py +0 -0
  98. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/advanced/error_code_demo.py +0 -0
  99. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/advanced/firmware_payload_capture.py +0 -0
  100. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/advanced/mqtt_diagnostics.py +0 -0
  101. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/advanced/power_control.py +0 -0
  102. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/advanced/recirculation_control.py +0 -0
  103. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/advanced/reconnection_demo.py +0 -0
  104. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/advanced/reservation_schedule.py +0 -0
  105. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/advanced/simple_auto_recovery.py +0 -0
  106. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/advanced/token_restoration.py +0 -0
  107. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/advanced/tou_openei.py +0 -0
  108. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/advanced/tou_schedule.py +0 -0
  109. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/advanced/water_reservation.py +0 -0
  110. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/beginner/01_authentication.py +0 -0
  111. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/beginner/02_list_devices.py +0 -0
  112. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/beginner/03_get_status.py +0 -0
  113. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/beginner/04_set_temperature.py +0 -0
  114. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/intermediate/advanced_auth_patterns.py +0 -0
  115. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/intermediate/command_queue.py +0 -0
  116. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/intermediate/device_status_callback.py +0 -0
  117. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/intermediate/error_handling.py +0 -0
  118. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/intermediate/event_driven_control.py +0 -0
  119. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/intermediate/improved_auth.py +0 -0
  120. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/intermediate/legacy_auth_constructor.py +0 -0
  121. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/intermediate/mqtt_realtime_monitoring.py +0 -0
  122. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/intermediate/periodic_requests.py +0 -0
  123. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/intermediate/set_mode.py +0 -0
  124. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/intermediate/vacation_mode.py +0 -0
  125. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/mask.py +0 -0
  126. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/simulator/lower-setpoint.json +0 -0
  127. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/simulator/plan.json +0 -0
  128. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/simulator/scenario.json +0 -0
  129. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/testing/periodic_device_info.py +0 -0
  130. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/testing/simple_periodic_info.py +0 -0
  131. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/testing/test_api_client.py +0 -0
  132. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/testing/test_mqtt_connection.py +0 -0
  133. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/testing/test_mqtt_messaging.py +0 -0
  134. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/examples/testing/test_periodic_minimal.py +0 -0
  135. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/pyproject.toml +0 -0
  136. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/scripts/README.md +0 -0
  137. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/scripts/bump_version.py +0 -0
  138. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/scripts/diagnose_mqtt_connection.py +0 -0
  139. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/scripts/extract_changelog.py +0 -0
  140. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/scripts/format.py +0 -0
  141. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/scripts/lint.py +0 -0
  142. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/scripts/setup-dev.py +0 -0
  143. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/scripts/validate_version.py +0 -0
  144. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/setup.cfg +0 -0
  145. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/setup.py +0 -0
  146. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/__init__.py +0 -0
  147. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/_base.py +0 -0
  148. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/api_client.py +0 -0
  149. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/auth.py +0 -0
  150. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/ble/__init__.py +0 -0
  151. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/ble/__main__.py +0 -0
  152. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/ble/client.py +0 -0
  153. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/ble/mqtt_adapter.py +0 -0
  154. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/ble/protocol.py +0 -0
  155. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/cli/__init__.py +0 -0
  156. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/cli/__main__.py +0 -0
  157. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/cli/ble_backend.py +0 -0
  158. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/cli/calibration.py +0 -0
  159. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/cli/handlers.py +0 -0
  160. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/cli/monitoring.py +0 -0
  161. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/cli/output_formatters.py +0 -0
  162. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/cli/presentation.py +0 -0
  163. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/cli/recording.py +0 -0
  164. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/cli/rich_output.py +0 -0
  165. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/cli/simulate.py +0 -0
  166. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/cli/token_storage.py +0 -0
  167. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/command_decorators.py +0 -0
  168. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/config.py +0 -0
  169. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/converters.py +0 -0
  170. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/device_capabilities.py +0 -0
  171. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/device_info_cache.py +0 -0
  172. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/encoding.py +0 -0
  173. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/enums.py +0 -0
  174. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/events.py +0 -0
  175. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/exceptions.py +0 -0
  176. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/factory.py +0 -0
  177. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/field_factory.py +0 -0
  178. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/models/__init__.py +0 -0
  179. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/models/_converters.py +0 -0
  180. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/models/account.py +0 -0
  181. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/models/device.py +0 -0
  182. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/models/diagnostics.py +0 -0
  183. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/models/energy.py +0 -0
  184. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/models/feature.py +0 -0
  185. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/models/firmware.py +0 -0
  186. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/models/maintenance.py +0 -0
  187. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/models/mqtt_models.py +0 -0
  188. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/models/schedule.py +0 -0
  189. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/models/status.py +0 -0
  190. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/models/tou.py +0 -0
  191. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/mqtt/__init__.py +0 -0
  192. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/mqtt/_control_commands.py +0 -0
  193. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/mqtt/_device_subscriptions.py +0 -0
  194. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/mqtt/client.py +0 -0
  195. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/mqtt/command_queue.py +0 -0
  196. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/mqtt/connection.py +0 -0
  197. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/mqtt/control.py +0 -0
  198. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/mqtt/diagnostics.py +0 -0
  199. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/mqtt/periodic.py +0 -0
  200. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/mqtt/reconnection.py +0 -0
  201. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/mqtt/state_tracker.py +0 -0
  202. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/mqtt/subscriptions.py +0 -0
  203. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/mqtt/types.py +0 -0
  204. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/mqtt/utils.py +0 -0
  205. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/mqtt_events.py +0 -0
  206. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/openei.py +0 -0
  207. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/py.typed +0 -0
  208. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/reservations.py +0 -0
  209. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/sim/__init__.py +0 -0
  210. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/sim/api.py +0 -0
  211. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/sim/attribution.py +0 -0
  212. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/sim/book.py +0 -0
  213. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/sim/circulation.py +0 -0
  214. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/sim/codec.py +0 -0
  215. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/sim/cold_supply.py +0 -0
  216. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/sim/controller.py +0 -0
  217. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/sim/decay.py +0 -0
  218. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/sim/defaults.py +0 -0
  219. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/sim/delivery.py +0 -0
  220. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/sim/draw_forecast.py +0 -0
  221. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/sim/engine.py +0 -0
  222. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/sim/extensions.py +0 -0
  223. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/sim/heat_pump_recovery.py +0 -0
  224. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/sim/heating_trajectory.py +0 -0
  225. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/sim/inputs.py +0 -0
  226. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/sim/location_air.py +0 -0
  227. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/sim/meter.py +0 -0
  228. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/sim/observed.py +0 -0
  229. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/sim/params.py +0 -0
  230. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/sim/physics/__init__.py +0 -0
  231. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/sim/physics/compressor.py +0 -0
  232. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/sim/physics/draw.py +0 -0
  233. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/sim/physics/element.py +0 -0
  234. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/sim/physics/refrigerant.py +0 -0
  235. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/sim/physics/tank.py +0 -0
  236. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/sim/profile.py +0 -0
  237. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/sim/ready.py +0 -0
  238. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/sim/record.py +0 -0
  239. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/sim/result.py +0 -0
  240. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/sim/schedules.py +0 -0
  241. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/sim/tank_physics.py +0 -0
  242. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/sim/tou_triggers.py +0 -0
  243. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/sim/units.py +0 -0
  244. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/sim_live.py +0 -0
  245. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/status_history.py +0 -0
  246. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/temperature.py +0 -0
  247. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/topic_builder.py +0 -0
  248. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/tou_schedule.py +0 -0
  249. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/unit_system.py +0 -0
  250. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500/utils.py +0 -0
  251. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500_python.egg-info/dependency_links.txt +0 -0
  252. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500_python.egg-info/entry_points.txt +0 -0
  253. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500_python.egg-info/not-zip-safe +0 -0
  254. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500_python.egg-info/requires.txt +0 -0
  255. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/src/nwp500_python.egg-info/top_level.txt +0 -0
  256. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/_sim_site.py +0 -0
  257. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/conftest.py +0 -0
  258. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/fixtures/sim/engine_golden.json +0 -0
  259. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/fixtures/sim/heater_params.json +0 -0
  260. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/fixtures/sim/heater_params_fitted.json +0 -0
  261. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_api_endpoints_201.py +0 -0
  262. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_api_helpers.py +0 -0
  263. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_app_queries.py +0 -0
  264. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_auth.py +0 -0
  265. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_auth_session_lifecycle.py +0 -0
  266. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_auth_session_refresh.py +0 -0
  267. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_ble_mqtt_adapter.py +0 -0
  268. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_ble_protocol.py +0 -0
  269. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_bug_fixes.py +0 -0
  270. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_canonical_schedule.py +0 -0
  271. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_cli_basic.py +0 -0
  272. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_cli_calibrate.py +0 -0
  273. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_cli_commands.py +0 -0
  274. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_cli_simulate.py +0 -0
  275. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_cli_tristate_presentation.py +0 -0
  276. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_command_decorators.py +0 -0
  277. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_command_queue.py +0 -0
  278. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_device_capabilities.py +0 -0
  279. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_device_info_cache.py +0 -0
  280. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_device_rest_models.py +0 -0
  281. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_events.py +0 -0
  282. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_exceptions.py +0 -0
  283. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_model_converters.py +0 -0
  284. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_models.py +0 -0
  285. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_mqtt_clean_session_resume.py +0 -0
  286. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_mqtt_client_init.py +0 -0
  287. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_mqtt_control_fail.py +0 -0
  288. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_mqtt_events.py +0 -0
  289. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_mqtt_hypothesis.py +0 -0
  290. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_mqtt_last_will.py +0 -0
  291. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_mqtt_reconnection.py +0 -0
  292. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_mqtt_reconnection_storm.py +0 -0
  293. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_mqtt_reliability.py +0 -0
  294. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_multi_device.py +0 -0
  295. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_navilink_201_enums.py +0 -0
  296. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_openei.py +0 -0
  297. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_protocol_correctness.py +0 -0
  298. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_public_api.py +0 -0
  299. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_reservation_validation.py +0 -0
  300. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_reservations.py +0 -0
  301. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_sim_adapters.py +0 -0
  302. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_sim_api.py +0 -0
  303. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_sim_book.py +0 -0
  304. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_sim_book_history.py +0 -0
  305. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_sim_calibration.py +0 -0
  306. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_sim_circulation.py +0 -0
  307. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_sim_codec.py +0 -0
  308. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_sim_cold_supply.py +0 -0
  309. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_sim_controller.py +0 -0
  310. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_sim_defaults.py +0 -0
  311. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_sim_draw_forecast.py +0 -0
  312. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_sim_draw_loss.py +0 -0
  313. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_sim_element.py +0 -0
  314. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_sim_element_fit.py +0 -0
  315. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_sim_forecast.py +0 -0
  316. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_sim_golden.py +0 -0
  317. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_sim_home_assistant.py +0 -0
  318. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_sim_hot_water.py +0 -0
  319. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_sim_import_contract.py +0 -0
  320. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_sim_live.py +0 -0
  321. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_sim_location_air.py +0 -0
  322. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_sim_meter.py +0 -0
  323. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_sim_no_clock_no_state.py +0 -0
  324. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_sim_observed.py +0 -0
  325. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_sim_profile.py +0 -0
  326. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_sim_readiness.py +0 -0
  327. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_sim_readiness_fit.py +0 -0
  328. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_sim_ready.py +0 -0
  329. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_sim_ready_api.py +0 -0
  330. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_sim_replay.py +0 -0
  331. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_sim_schedules.py +0 -0
  332. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_sim_tank_physics.py +0 -0
  333. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_status_history.py +0 -0
  334. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_temperature_converters.py +0 -0
  335. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_threading_model.py +0 -0
  336. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_token_storage.py +0 -0
  337. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_tou_api.py +0 -0
  338. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_tou_schedule.py +0 -0
  339. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_unit_switching.py +0 -0
  340. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_unit_system_process_wide.py +0 -0
  341. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_utility_modules.py +0 -0
  342. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tests/test_utils.py +0 -0
  343. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/tox.ini +0 -0
  344. {nwp500_python-9.6.0 → nwp500_python-9.8.0}/uv.lock +0 -0
@@ -2,6 +2,66 @@
2
2
  Changelog
3
3
  =========
4
4
 
5
+ Version 9.8.0 (2026-10-21)
6
+ ==========================
7
+
8
+ Added
9
+ -----
10
+ - **Refine a fit without making it worse** (``keep_better=True`` on
11
+ ``sim_calibrate.calibrate`` and ``calibrate_changes``, #184). Each
12
+ group of terms holds out its latest evidence from its refit (the last
13
+ fifth, once it has 10 items) and keeps the existing values unless the
14
+ refit scores better there, by the error its own fit minimizes. The
15
+ terms in a group are kept or replaced together. ``Calibrated.judged``
16
+ records each judgement, and the report's reasons give both scores.
17
+ ``sim_calibrate.score(done, params)`` scores any parameters on that
18
+ held-out evidence.
19
+ - ``sim.calibration.split_recoveries``, ``recovery_minutes``,
20
+ ``power_cycles`` and ``cop_cycles``: the evidence the compressor and
21
+ power fits use, and their predictions, which ``fit_compressor``,
22
+ ``fit_power_scale`` and ``fit_cop_scale`` now take from.
23
+
24
+ Fixed
25
+ -----
26
+ - **The report names compressor runs without an air temperature.**
27
+ A recovery is timed against the air, so a run with none (no air
28
+ given, and no heater ambient reading while the compressor runs) is
29
+ left out of the recoveries. The report blamed the heater instead:
30
+ "0 recoveries reached their target". ``Calibrated`` now counts the
31
+ runs (``n_runs``, ``n_runs_without_air``), and the compressor term's
32
+ reason and the notes say how many had no air
33
+ (``sim_calibrate.air_unread_note``).
34
+ - ``sim.calibration.with_standby`` quotes the fitted UA at the ΔT the
35
+ law was fitted at (50 °F). It kept the ``ua_ref_delta_t_f`` of the
36
+ parameters it was given, so a refit applied over parameters quoted at
37
+ another ΔT ran as a different loss law.
38
+ - ``sim.calibration.fit_cop_scale`` takes the integrator's COP on the
39
+ heater's own tank (``HeaterParams.tank``), as the simulation does. It
40
+ used the default tank's mass, so a heater with another tank was fitted
41
+ against a COP the simulation does not run.
42
+
43
+ Version 9.7.0 (2026-10-21)
44
+ ==========================
45
+
46
+ Added
47
+ -----
48
+ - **Calibrate from Home Assistant's history** (#179), for a heater with
49
+ no recording of its statuses.
50
+ ``sim_home_assistant.read_heater_history`` reads the heater's own
51
+ entities a day at a time: the probes, what is heating, the in-use
52
+ flag, the stop threshold, the operation mode, the error code, the
53
+ power, the energy to setpoint and the heater's own air, each number
54
+ from the unit it was recorded in. An entity that changes on every
55
+ response (``heard``) says when the heater was observed.
56
+ ``sim_calibrate.calibrate_changes`` fits on what it read, and gives
57
+ the same ``Calibrated`` and ``report()`` as ``calibrate``.
58
+ - ``sim_adapters.ChangeRows`` and ``sim_adapters.change_history``: the
59
+ calibration frame from each value's changes and the instants the
60
+ heater was heard from, for any store of that history.
61
+ - ``History.states``: every row's state as written, numbers or not.
62
+ ``HomeAssistantClient.read_history`` fills it (``parse_states``).
63
+ - ``sim_home_assistant.to_w`` and ``to_wh``.
64
+
5
65
  Version 9.6.0 (2026-10-21)
6
66
  ==========================
7
67
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nwp500-python
3
- Version: 9.6.0
3
+ Version: 9.8.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
@@ -223,6 +223,44 @@ Assistant integration, can calibrate the same way:
223
223
  4. Store ``params_to_document(done.calibration.params)``, and show the
224
224
  ``report(done)`` rows and ``done.notes``.
225
225
 
226
+ Refine an existing fit
227
+ ----------------------
228
+
229
+ A fit made on months of history should not give way to one made on the
230
+ last ten days unless the new one predicts better. Pass the existing
231
+ fit as ``params`` with ``keep_better=True``:
232
+
233
+ .. code-block:: python
234
+
235
+ done = sim_calibrate.calibrate(
236
+ recorded(days), ambient=65.0, params=existing, keep_better=True
237
+ )
238
+ for j in done.judged:
239
+ print(j.group, j.kept, j.base_error, j.refit_error, j.why)
240
+
241
+ Each group of terms holds out its latest evidence from its refit:
242
+
243
+ - the standby loss and its exponent: its quiet windows;
244
+ - the zone gap time constant: its windows that open a gap;
245
+ - the compressor constant and the evaporator approach: its reached
246
+ recoveries;
247
+ - the power scale: the metered compressor cycles it can use;
248
+ - the COP terms: the cycles the COP fit can use (without a meter of
249
+ draws, those no water was drawn around).
250
+
251
+ Once a group has 10 items, the latest fifth (at least 2) is held out.
252
+ The refit replaces the existing values only where it scores better on
253
+ them, by the error its own fit minimizes. With fewer items the existing
254
+ values stay. The terms in a group are kept or replaced together, and the
255
+ report's reasons say which, with both scores.
256
+ ``sim_calibrate.score(done, params)`` gives those errors for any
257
+ parameters.
258
+
259
+ Use it over a fit of this heater. The defaults are another heater's fit,
260
+ and ``keep_better`` keeps them wherever there is too little evidence to
261
+ judge by. A group the existing fit lists as unfitted takes its refit, as
262
+ without ``keep_better``.
263
+
226
264
  ``sim.calibration.calibrate`` underneath takes the frame
227
265
  ``sim_adapters.history`` builds; ``sim_adapters.add_inputs`` adds the
228
266
  air, the humidity, a meter's draws and the circulation's runs to it.
@@ -232,6 +270,92 @@ the draw response and the elements also run there: see "Improve
232
270
  accuracy" and "Fit the elements" in :doc:`simulate-heater`.
233
271
 
234
272
 
273
+ Calibrate from Home Assistant's history
274
+ =======================================
275
+
276
+ A heater whose integration writes its values to Home Assistant can be
277
+ calibrated from that history, with no recording of its statuses. Home
278
+ Assistant keeps each entity's changes, and a value that holds writes
279
+ none, so the history also needs an entity that changes every time the
280
+ heater answers, such as the integration's last response time. Its
281
+ changes are when the heater was heard from; a silence between them
282
+ longer than 4 times their usual spacing (and at least 5 minutes) is
283
+ unobserved, as a gap between statuses is.
284
+
285
+ .. code-block:: python
286
+
287
+ from datetime import UTC, datetime, timedelta
288
+
289
+ from nwp500 import sim_calibrate
290
+ from nwp500 import sim_home_assistant as ha
291
+
292
+ entities = ha.HeaterEntities(
293
+ heard="sensor.water_heater_last_mqtt_response",
294
+ upper="sensor.water_heater_tank_upper_temperature",
295
+ lower="sensor.water_heater_tank_lower_temperature",
296
+ compressor="binary_sensor.water_heater_compressor_running",
297
+ upper_element="binary_sensor.water_heater_upper_element",
298
+ lower_element="binary_sensor.water_heater_lower_element",
299
+ heat_source="sensor.water_heater_current_heat_source",
300
+ dhw_in_use="binary_sensor.water_heater_dhw_in_use",
301
+ target="sensor.water_heater_hp_lower_off_temperature",
302
+ operation_mode="sensor.water_heater_current_operation_mode",
303
+ error_code="sensor.water_heater_error_code",
304
+ power="sensor.water_heater_current_power",
305
+ energy_to_setpoint="sensor.water_heater_energy_to_setpoint",
306
+ ambient="sensor.water_heater_ambient_temperature",
307
+ )
308
+ end = datetime.now(UTC)
309
+ start = end - timedelta(days=60)
310
+ async with ha.HomeAssistantClient(url, token) as client:
311
+ history = await ha.read_heater_history(client, entities, start, end)
312
+ record = await ha.record_inputs(client, ha.Entities(air=..., humidity=...),
313
+ start, end)
314
+ done = sim_calibrate.calibrate_changes(
315
+ history.rows, history.observed, record=record, notes=history.notes
316
+ )
317
+
318
+ Use your own entity ids. Only ``heard`` and the two probes are needed;
319
+ leave out what your installation does not record. ``read_heater_history``:
320
+
321
+ - reads a day at a time, so the heartbeat's rows are never all held as
322
+ Home Assistant gives them;
323
+ - takes each number from the unit it was recorded in (°F, °C or K; W or
324
+ kW; Wh or kWh), ``on`` and ``off`` from the binary sensors, and the
325
+ heat source, operation mode and error code by their enum names
326
+ (``HeatSource``, ``CurrentOperationMode``, ``ErrorCode``);
327
+ - takes ``unavailable`` or ``unknown`` on any of the entities as an
328
+ outage of the heater, unobserved (an enum's own ``UNKNOWN`` is a
329
+ value);
330
+ - takes the heat source as the compressor's state where no compressor
331
+ entity is given;
332
+ - notes a value it could not read, and an entity whose history starts
333
+ after ``start``.
334
+
335
+ A recovery is timed against the air, so the compressor and power fits
336
+ need an air temperature over each compressor run: the air beside the
337
+ heater (``record=``, ``air`` or ``ambient``), or the heater's own reading
338
+ (``ambient`` in ``HeaterEntities``). Runs without one are no recovery,
339
+ and the report says how many there were.
340
+
341
+ Give ``anti_legionella`` where it is recorded. Without it an
342
+ anti-legionella cycle is not kept out of the recoveries, and the notes
343
+ say so.
344
+
345
+ Home Assistant keeps 10 days of history by default, so ``start`` reaches
346
+ back only that far unless the recorder's ``purge_keep_days`` is raised
347
+ to cover the record you want to fit. The same is true of the air.
348
+
349
+ Rows from another store of the same history (an export, say) go in as
350
+ ``sim_adapters.ChangeRows``, each value's changes as ``(instant,
351
+ value)`` pairs, with the instants heard from:
352
+ ``sim_calibrate.calibrate_changes(rows, observed, ...)``.
353
+
354
+ On a desktop computer, 60 days with a heartbeat every 10 seconds
355
+ (about 550,000 rows) took about 13 seconds to read, not counting Home
356
+ Assistant's own queries, and 11 to fit, in about 180 MB.
357
+
358
+
235
359
  Limitations
236
360
  ===========
237
361
 
@@ -252,4 +376,6 @@ See also
252
376
 
253
377
  * :doc:`simulate-heater`: run the simulator with your parameters
254
378
  * :doc:`/reference/python_api/sim`: ``sim_calibrate``,
255
- ``sim.calibration``, ``sim_adapters.history`` and ``status_history``
379
+ ``sim.calibration``, ``sim_adapters.history``,
380
+ ``sim_adapters.change_history``, ``status_history`` and
381
+ ``sim_home_assistant.read_heater_history``
@@ -150,7 +150,8 @@ Hot water and the heat book
150
150
 
151
151
  .. automodule:: nwp500.sim.calibration
152
152
  :members: calibrate, Calibration, fit_standby, StandbyFit, StandbyWindow,
153
- fit_compressor, CompressorFit, clean_draws, CleanDraw, crossing,
153
+ fit_compressor, CompressorFit, split_recoveries, recovery_minutes,
154
+ clean_draws, CleanDraw, crossing,
154
155
  demonstrated, frontier, capacity, held_out, fit_evidence,
155
156
  EvidenceFit, cold_lower_cap, RecordedBook, fit_book_terms, BookFit,
156
157
  book_error_bands, book_error_btu, empty_point,
@@ -163,7 +164,7 @@ Hot water and the heat book
163
164
  element_off_points, element_power, element_grid, element_runs,
164
165
  fit_element_rates, ElementRateFit, day_ci,
165
166
  energy_saver_off_deficit, upper_start_edge, fit_power,
166
- PowerFit, fit_power_scale,
167
+ PowerFit, fit_power_scale, power_cycles, cop_cycles, CopCycle,
167
168
  fit_cop_scale, solve_cop_fit, solve_cop_scale, draw_free_withdrawn,
168
169
  metered_withdrawn, metered_lead,
169
170
  path_to_end, with_power, snapshots, recoveries, quiet_windows,
@@ -298,7 +299,8 @@ Recorded history
298
299
  :members:
299
300
 
300
301
  .. automodule:: nwp500.sim_calibrate
301
- :members: calibrate, Calibrated, report, Term, max_gap,
302
+ :members: calibrate, calibrate_changes, Calibrated, report, Term, max_gap,
303
+ score, Judged, HeldOut, GROUPS, air_unread_note,
302
304
  CalibrationInputError, DEFAULT_HUMIDITY_PCT, GAP_SAMPLES, MIN_GAP,
303
305
  DEFAULT_BASE_NOTE, NO_AIR_NOTE
304
306
 
@@ -309,6 +311,8 @@ Home Assistant sources
309
311
  .. automodule:: nwp500.sim_home_assistant
310
312
  :members: HomeAssistantEnvironment, HomeAssistantClient, record_inputs,
311
313
  RecordInputs, Sources, Entities, sources_from, State, parse_state,
312
- parse_history, parse_outages, History, history_for, parse_forecast,
314
+ parse_history, parse_outages, parse_states, History, history_for,
315
+ parse_forecast, read_heater_history, HeaterEntities, HeaterHistory,
316
+ HISTORY_WINDOW, to_w, to_wh,
313
317
  pump_runs, draw_spans, busy, covers_start, reports_after, percent,
314
318
  fresh, to_f, to_gpm, to_gal, to_rpm, HomeAssistantError
@@ -449,6 +449,92 @@ class CompressorFit:
449
449
  two_zone: bool
450
450
 
451
451
 
452
+ _RECOVERY_NEED = (
453
+ "t_start_lower",
454
+ "delta_t_lower",
455
+ "t_amb_mean",
456
+ "duration_minutes",
457
+ )
458
+
459
+
460
+ def split_recoveries(
461
+ recoveries: pd.DataFrame,
462
+ ) -> tuple[pd.DataFrame, pd.DataFrame]:
463
+ """``(fitted, held_out)``: the recoveries a compressor fit uses, in
464
+ time order where ``start_time`` says, the latest ``HELDOUT_FRACTION``
465
+ (at least ``MIN_HELDOUT``) held out once there are
466
+ ``MIN_RECOVERIES_FOR_SPLIT``. Usable: the figures known and a
467
+ duration from ``MIN_RECOVERY_MINUTES`` to ``MAX_RECOVERY_MINUTES``."""
468
+ if any(c not in recoveries.columns for c in _RECOVERY_NEED):
469
+ empty = recoveries.iloc[0:0]
470
+ return empty, empty
471
+ df = recoveries.dropna(subset=list(_RECOVERY_NEED))
472
+ minutes = df["duration_minutes"]
473
+ df = df.loc[
474
+ (minutes >= MIN_RECOVERY_MINUTES) & (minutes <= MAX_RECOVERY_MINUTES)
475
+ ].copy()
476
+ if "start_time" in df.columns:
477
+ df = df.sort_values("start_time").reset_index(drop=True)
478
+ n = len(df)
479
+ n_held = (
480
+ max(MIN_HELDOUT, round(n * HELDOUT_FRACTION))
481
+ if n >= MIN_RECOVERIES_FOR_SPLIT
482
+ else 0
483
+ )
484
+ return df.iloc[: n - n_held], df.iloc[n - n_held :]
485
+
486
+
487
+ def recovery_minutes(
488
+ recoveries: pd.DataFrame,
489
+ compressor_constant: float,
490
+ evap_approach_f: float,
491
+ *,
492
+ compressor: CompressorParams,
493
+ loss: LossParams,
494
+ tank: TankParams,
495
+ two_zone: bool,
496
+ gaps: bool,
497
+ ) -> np.ndarray:
498
+ """Each recovery's duration as the physics predicts it, minutes, on
499
+ ``fit_compressor``'s columns; ``gaps`` uses its
500
+ ``stratification_gap``, else none."""
501
+ rows = len(recoveries)
502
+ start = np.asarray(recoveries["t_start_lower"], dtype=float)
503
+ target = start + np.asarray(recoveries["delta_t_lower"], dtype=float)
504
+ air = np.asarray(recoveries["t_amb_mean"], dtype=float)
505
+ humidity = np.asarray(
506
+ recoveries["h_amb_start"]
507
+ if "h_amb_start" in recoveries.columns
508
+ else np.full(rows, 50.0),
509
+ dtype=float,
510
+ )
511
+ gap = np.asarray(
512
+ recoveries["stratification_gap"] if gaps else np.zeros(rows),
513
+ dtype=float,
514
+ )
515
+ return np.array(
516
+ [
517
+ predict_physics_detailed(
518
+ t_start_lower=float(start[i]),
519
+ hp_lower_off=float(target[i]),
520
+ t_amb_mean=float(air[i]),
521
+ compressor_constant=compressor_constant,
522
+ evap_approach_f=evap_approach_f,
523
+ compressor=compressor,
524
+ loss=loss,
525
+ h_amb_start=float(humidity[i]),
526
+ ua_btu_hr_f=loss.ua_btu_hr_f,
527
+ ua_exponent=loss.ua_exponent,
528
+ ua_ref_delta_t=loss.ua_ref_delta_t_f,
529
+ stratification_gap=float(gap[i]),
530
+ two_zone=two_zone,
531
+ tank=tank,
532
+ ).minutes
533
+ for i in range(rows)
534
+ ]
535
+ )
536
+
537
+
452
538
  def fit_compressor(
453
539
  recoveries: pd.DataFrame,
454
540
  compressor: CompressorParams,
@@ -470,27 +556,13 @@ def fit_compressor(
470
556
  ``stratification_gap`` (upper minus lower at the start; without it
471
557
  the fit falls back to the one-zone integrator, so the constant stays
472
558
  the one the simulation will use) and ``start_time`` (to hold out the
473
- latest). None with fewer than ``MIN_RECOVERIES`` usable.
559
+ latest, ``split_recoveries``). None with fewer than
560
+ ``MIN_RECOVERIES`` usable.
474
561
  """
475
- need = ["t_start_lower", "delta_t_lower", "t_amb_mean", "duration_minutes"]
476
- if any(c not in recoveries.columns for c in need):
477
- return None
478
- df = recoveries.dropna(subset=need)
479
- minutes = df["duration_minutes"]
480
- df = df.loc[
481
- (minutes >= MIN_RECOVERY_MINUTES) & (minutes <= MAX_RECOVERY_MINUTES)
482
- ].copy()
483
- if len(df) < MIN_RECOVERIES:
562
+ fit_df, held_df = split_recoveries(recoveries)
563
+ if len(fit_df) + len(held_df) < MIN_RECOVERIES:
484
564
  return None
485
- if "start_time" in df.columns:
486
- df = df.sort_values("start_time").reset_index(drop=True)
487
- n = len(df)
488
- n_held = (
489
- max(MIN_HELDOUT, round(n * HELDOUT_FRACTION))
490
- if n >= MIN_RECOVERIES_FOR_SPLIT
491
- else 0
492
- )
493
- fit_df, held_df = df.iloc[: n - n_held], df.iloc[n - n_held :]
565
+ n_held = len(held_df)
494
566
  # Usable gaps are judged on the rows fitted, as the model's fit did:
495
567
  # a gap the fit has not got cannot be the mass model's.
496
568
  has_gaps = "stratification_gap" in fit_df.columns and bool(
@@ -498,56 +570,21 @@ def fit_compressor(
498
570
  )
499
571
  two_zone = two_zone and has_gaps
500
572
 
501
- def columns(
502
- frame: pd.DataFrame, gaps: bool = has_gaps
503
- ) -> tuple[np.ndarray, ...]:
504
- rows = len(frame)
505
- return (
506
- np.asarray(frame["t_start_lower"], dtype=float),
507
- np.asarray(
508
- frame["t_start_lower"] + frame["delta_t_lower"], dtype=float
509
- ),
510
- np.asarray(frame["t_amb_mean"], dtype=float),
511
- np.asarray(
512
- frame["h_amb_start"]
513
- if "h_amb_start" in frame.columns
514
- else np.full(rows, 50.0),
515
- dtype=float,
516
- ),
517
- np.asarray(
518
- frame["stratification_gap"] if gaps else np.zeros(rows),
519
- dtype=float,
520
- ),
521
- np.asarray(frame["duration_minutes"], dtype=float),
522
- )
523
-
524
573
  def predicted(
525
- cols: tuple[np.ndarray, ...], k: float, approach: float
574
+ frame: pd.DataFrame, k: float, approach: float, gaps: bool = has_gaps
526
575
  ) -> np.ndarray:
527
- start, target, air, humidity, gap, _ = cols
528
- return np.array(
529
- [
530
- predict_physics_detailed(
531
- t_start_lower=float(start[i]),
532
- hp_lower_off=float(target[i]),
533
- t_amb_mean=float(air[i]),
534
- compressor_constant=k,
535
- evap_approach_f=approach,
536
- compressor=compressor,
537
- loss=loss,
538
- h_amb_start=float(humidity[i]),
539
- ua_btu_hr_f=loss.ua_btu_hr_f,
540
- ua_exponent=loss.ua_exponent,
541
- ua_ref_delta_t=loss.ua_ref_delta_t_f,
542
- stratification_gap=float(gap[i]),
543
- two_zone=two_zone,
544
- tank=tank,
545
- ).minutes
546
- for i in range(len(start))
547
- ]
576
+ return recovery_minutes(
577
+ frame,
578
+ k,
579
+ approach,
580
+ compressor=compressor,
581
+ loss=loss,
582
+ tank=tank,
583
+ two_zone=two_zone,
584
+ gaps=gaps,
548
585
  )
549
586
 
550
- fit_cols = columns(fit_df)
587
+ actual = np.asarray(fit_df["duration_minutes"], dtype=float)
551
588
 
552
589
  def objective(x: np.ndarray) -> float:
553
590
  k, approach = float(x[0]), float(x[1])
@@ -556,9 +593,7 @@ def fit_compressor(
556
593
  and APPROACH_BOUNDS[0] <= approach <= APPROACH_BOUNDS[1]
557
594
  ):
558
595
  return 1e12
559
- return float(
560
- np.sum((predicted(fit_cols, k, approach) - fit_cols[5]) ** 2)
561
- )
596
+ return float(np.sum((predicted(fit_df, k, approach) - actual) ** 2))
562
597
 
563
598
  found = minimize(
564
599
  objective,
@@ -573,15 +608,22 @@ def fit_compressor(
573
608
  for value, bounds in ((k, K_BOUNDS), (approach, APPROACH_BOUNDS))
574
609
  for bound in bounds
575
610
  )
576
- mae = float(np.mean(np.abs(predicted(fit_cols, k, approach) - fit_cols[5])))
611
+ mae = float(np.mean(np.abs(predicted(fit_df, k, approach) - actual)))
577
612
  held_mae = None
578
613
  if n_held:
579
614
  # The held-out rows' gaps as recorded, wherever the column is.
580
- held_cols = columns(
581
- held_df, gaps="stratification_gap" in held_df.columns
582
- )
583
615
  held_mae = float(
584
- np.mean(np.abs(predicted(held_cols, k, approach) - held_cols[5]))
616
+ np.mean(
617
+ np.abs(
618
+ predicted(
619
+ held_df,
620
+ k,
621
+ approach,
622
+ gaps="stratification_gap" in held_df.columns,
623
+ )
624
+ - np.asarray(held_df["duration_minutes"], dtype=float)
625
+ )
626
+ )
585
627
  )
586
628
  return CompressorFit(
587
629
  compressor_constant=k,
@@ -954,8 +996,13 @@ def with_standby(params: HeaterParams, fit: StandbyFit) -> HeaterParams:
954
996
  """``params`` with the standby fit's values where it had them."""
955
997
  loss, tank = params.loss, params.tank
956
998
  if fit.ua_ref is not None and fit.ua_exponent is not None:
999
+ # Quoted at the ΔT ``fit_standby``'s law was fitted at, not
1000
+ # whatever ``params`` held.
957
1001
  loss = replace(
958
- loss, ua_btu_hr_f=fit.ua_ref, ua_exponent=fit.ua_exponent
1002
+ loss,
1003
+ ua_btu_hr_f=fit.ua_ref,
1004
+ ua_exponent=fit.ua_exponent,
1005
+ ua_ref_delta_t_f=UA_REF_DELTA_T_F,
959
1006
  )
960
1007
  if fit.tau_gap_hours is not None:
961
1008
  tank = replace(tank, stratification_gap_tau_h=fit.tau_gap_hours)
@@ -1060,15 +1107,14 @@ class PowerFit:
1060
1107
  n_cop: int
1061
1108
 
1062
1109
 
1063
- def fit_power_scale(
1110
+ def power_cycles(
1064
1111
  rows: pd.DataFrame, params: HeaterParams
1065
- ) -> tuple[float | None, int]:
1066
- """``(metered / modelled, cycles)``: each cycle charged at the
1067
- integrator's own mean power for its real duration, so both sides
1068
- are energy over the same clock. ``(None, n)`` with fewer than
1069
- ``MIN_POWER_EVENTS``."""
1070
- metered_total = modelled_total = 0.0
1071
- n = 0
1112
+ ) -> list[tuple[datetime | None, float, float]]:
1113
+ """Each cycle's ``(start, metered, modelled)`` Wh for the power scale
1114
+ (``start`` None where the rows carry no ``start_time``):
1115
+ the integrator's own mean power over the cycle's real duration, so
1116
+ both sides are energy over the same clock."""
1117
+ out: list[tuple[datetime | None, float, float]] = []
1072
1118
  for row in _records(_starts(rows)):
1073
1119
  raw = row["metered_input_wh"]
1074
1120
  if raw is None or pd.isna(raw):
@@ -1085,9 +1131,19 @@ def fit_power_scale(
1085
1131
  modelled = power_w * real_minutes / 60
1086
1132
  if not math.isfinite(modelled) or modelled <= 0.0:
1087
1133
  continue
1088
- metered_total += metered
1089
- modelled_total += modelled
1090
- n += 1
1134
+ out.append((row.get("start_time"), metered, modelled))
1135
+ return out
1136
+
1137
+
1138
+ def fit_power_scale(
1139
+ rows: pd.DataFrame, params: HeaterParams
1140
+ ) -> tuple[float | None, int]:
1141
+ """``(metered / modelled, cycles)`` over ``power_cycles``, a ratio
1142
+ of sums. ``(None, n)`` with fewer than ``MIN_POWER_EVENTS``."""
1143
+ cycles = power_cycles(rows, params)
1144
+ metered_total = sum(m for _, m, _ in cycles)
1145
+ modelled_total = sum(e for _, _, e in cycles)
1146
+ n = len(cycles)
1091
1147
  if n < MIN_POWER_EVENTS or modelled_total <= 0.0:
1092
1148
  return None, n
1093
1149
  return metered_total / modelled_total, n
@@ -1278,24 +1334,29 @@ def draw_free_withdrawn(row: Mapping[str, Any]) -> float | None:
1278
1334
  return 0.0 if bool(row.get("draw_free")) else None
1279
1335
 
1280
1336
 
1281
- def fit_cop_scale(
1337
+ @dataclass(frozen=True)
1338
+ class CopCycle:
1339
+ """One cycle's figures for the apparent COP: when it started, the
1340
+ heat it stored plus what draws took, the lead draws' heat, the
1341
+ integrator's COP, the metered energy (Wh) and the lift (°F).
1342
+ ``start`` is None where the rows carry no ``start_time``."""
1343
+
1344
+ start: datetime | None
1345
+ heat_wh: float
1346
+ lead_wh: float
1347
+ modelled_cop: float
1348
+ metered_wh: float
1349
+ lift_f: float
1350
+
1351
+
1352
+ def cop_cycles(
1282
1353
  rows: pd.DataFrame,
1283
1354
  params: HeaterParams,
1284
1355
  *,
1285
1356
  withdrawn: Withdrawn = draw_free_withdrawn,
1286
1357
  lead: Withdrawn | None = None,
1287
- ) -> tuple[tuple[float, float, float, float] | None, int]:
1288
- """``((scale, startup_wh, lead_share, lift_coeff), cycles)`` for the
1289
- apparent COP, the heat a cycle stored (the full tank's mass times
1290
- the rise of the probes' mean) plus what ``withdrawn`` says draws
1291
- took during it, and ``lead`` those just before it, whose cold water
1292
- the start reading had not yet seen. ``(None, n)`` with fewer than
1293
- ``MIN_POWER_EVENTS``.
1294
-
1295
- Without a meter the default keeps only the cycles no water was
1296
- drawn around: the heat drawn during a cycle is otherwise missing
1297
- from its heat and pulls the scale low.
1298
- """
1358
+ ) -> list[CopCycle]:
1359
+ """The cycles ``fit_cop_scale`` fits on, and their figures."""
1299
1360
  needed = [
1300
1361
  "metered_input_wh",
1301
1362
  "t_start_lower",
@@ -1304,13 +1365,9 @@ def fit_cop_scale(
1304
1365
  "t_upper_end",
1305
1366
  ]
1306
1367
  if not set(needed) <= set(rows.columns):
1307
- return None, 0
1368
+ return []
1308
1369
  wh_per_f = params.tank.thermal_mass_btu_f / BTU_PER_WH
1309
- heat: list[float] = []
1310
- before: list[float] = []
1311
- cop: list[float] = []
1312
- metered_wh: list[float] = []
1313
- lift: list[float] = []
1370
+ out: list[CopCycle] = []
1314
1371
  for row in _records(_starts(rows)):
1315
1372
  if any(row[c] is None or pd.isna(row[c]) for c in needed):
1316
1373
  continue
@@ -1338,6 +1395,7 @@ def fit_cop_scale(
1338
1395
  path.elapsed_s[-1],
1339
1396
  params.compressor.two_zone,
1340
1397
  float(row["t_start_upper"]),
1398
+ tank=params.tank,
1341
1399
  )
1342
1400
  if modelled is None or not math.isfinite(modelled) or modelled <= 0:
1343
1401
  continue
@@ -1350,15 +1408,51 @@ def fit_cop_scale(
1350
1408
  + float(row["t_start_lower"])
1351
1409
  + float(row["t_start_upper"])
1352
1410
  ) / 4
1353
- heat.append(stored + took)
1354
- before.append(lead_wh)
1355
- cop.append(modelled)
1356
- metered_wh.append(metered)
1357
- lift.append(mean - float(air))
1358
- n = len(heat)
1411
+ out.append(
1412
+ CopCycle(
1413
+ start=row.get("start_time"),
1414
+ heat_wh=stored + took,
1415
+ lead_wh=lead_wh,
1416
+ modelled_cop=modelled,
1417
+ metered_wh=metered,
1418
+ lift_f=mean - float(air),
1419
+ )
1420
+ )
1421
+ return out
1422
+
1423
+
1424
+ def fit_cop_scale(
1425
+ rows: pd.DataFrame,
1426
+ params: HeaterParams,
1427
+ *,
1428
+ withdrawn: Withdrawn = draw_free_withdrawn,
1429
+ lead: Withdrawn | None = None,
1430
+ ) -> tuple[tuple[float, float, float, float] | None, int]:
1431
+ """``((scale, startup_wh, lead_share, lift_coeff), cycles)`` for the
1432
+ apparent COP, the heat a cycle stored (the full tank's mass times
1433
+ the rise of the probes' mean) plus what ``withdrawn`` says draws
1434
+ took during it, and ``lead`` those just before it, whose cold water
1435
+ the start reading had not yet seen (``cop_cycles``). ``(None, n)``
1436
+ with fewer than ``MIN_POWER_EVENTS``.
1437
+
1438
+ Without a meter the default keeps only the cycles no water was
1439
+ drawn around: the heat drawn during a cycle is otherwise missing
1440
+ from its heat and pulls the scale low.
1441
+ """
1442
+ cycles = cop_cycles(rows, params, withdrawn=withdrawn, lead=lead)
1443
+ n = len(cycles)
1359
1444
  if n < MIN_POWER_EVENTS:
1360
1445
  return None, n
1361
- return solve_cop_fit(heat, cop, metered_wh, before, lift), n
1446
+ return (
1447
+ solve_cop_fit(
1448
+ [c.heat_wh for c in cycles],
1449
+ [c.modelled_cop for c in cycles],
1450
+ [c.metered_wh for c in cycles],
1451
+ [c.lead_wh for c in cycles],
1452
+ [c.lift_f for c in cycles],
1453
+ ),
1454
+ n,
1455
+ )
1362
1456
 
1363
1457
 
1364
1458
  def fit_power(