nwp500-python 9.8.0__tar.gz → 9.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (342) hide show
  1. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/CHANGELOG.rst +46 -0
  2. {nwp500_python-9.8.0/src/nwp500_python.egg-info → nwp500_python-9.9.0}/PKG-INFO +1 -1
  3. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/explanation/simulator.rst +5 -1
  4. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/how-to/calibrate-simulator.rst +19 -8
  5. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/reference/python_api/sim.rst +3 -1
  6. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/sim/api.py +1 -5
  7. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/sim/calibration.py +366 -22
  8. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/sim/decay.py +29 -1
  9. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/sim/params.py +17 -0
  10. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/sim/physics/compressor.py +12 -2
  11. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/sim/physics/tank.py +68 -0
  12. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/sim/tank_physics.py +2 -0
  13. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/sim_calibrate.py +171 -128
  14. {nwp500_python-9.8.0 → nwp500_python-9.9.0/src/nwp500_python.egg-info}/PKG-INFO +1 -1
  15. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500_python.egg-info/SOURCES.txt +1 -0
  16. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500_python.egg-info/scm_file_list.json +1 -0
  17. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500_python.egg-info/scm_version.json +2 -2
  18. nwp500_python-9.9.0/tests/test_sim_bottom_loss.py +665 -0
  19. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_sim_calibrate.py +20 -12
  20. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/.coveragerc +0 -0
  21. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/.github/RESOLVING_PR_COMMENTS.md +0 -0
  22. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/.github/copilot-instructions.md +0 -0
  23. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/.github/workflows/ci.yml +0 -0
  24. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/.github/workflows/release.yml +0 -0
  25. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/.gitignore +0 -0
  26. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/.pre-commit-config.yaml +0 -0
  27. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/.readthedocs.yml +0 -0
  28. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/AUTHORS.rst +0 -0
  29. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/CONTRIBUTING.rst +0 -0
  30. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/LICENSE.txt +0 -0
  31. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/Makefile +0 -0
  32. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/README.rst +0 -0
  33. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/RELEASE.md +0 -0
  34. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/Makefile +0 -0
  35. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/_static/.gitignore +0 -0
  36. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/conf.py +0 -0
  37. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/explanation/architecture.rst +0 -0
  38. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/explanation/draws.rst +0 -0
  39. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/explanation/heating-elements.rst +0 -0
  40. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/explanation/index.rst +0 -0
  41. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/explanation/recovery-speed.rst +0 -0
  42. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/explanation/standby-loss.rst +0 -0
  43. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/explanation/tank-energy.rst +0 -0
  44. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/explanation/tou-recovery-cap.rst +0 -0
  45. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/explanation/unknown-values.rst +0 -0
  46. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/explanation/what-starts-a-recovery.rst +0 -0
  47. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/how-to/authenticate.rst +0 -0
  48. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/how-to/auto-recovery.rst +0 -0
  49. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/how-to/bluetooth.rst +0 -0
  50. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/how-to/diagnose-mqtt.rst +0 -0
  51. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/how-to/home-assistant.rst +0 -0
  52. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/how-to/index.rst +0 -0
  53. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/how-to/maintenance.rst +0 -0
  54. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/how-to/manage-units.rst +0 -0
  55. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/how-to/monitor-status.rst +0 -0
  56. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/how-to/optimize-tou.rst +0 -0
  57. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/how-to/queue-commands.rst +0 -0
  58. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/how-to/schedule-operation.rst +0 -0
  59. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/how-to/simulate-heater.rst +0 -0
  60. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/how-to/track-energy.rst +0 -0
  61. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/index.rst +0 -0
  62. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/openapi.yaml +0 -0
  63. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/project/authors.rst +0 -0
  64. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/project/changelog.rst +0 -0
  65. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/project/contributing.rst +0 -0
  66. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/project/history.rst +0 -0
  67. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/project/license.rst +0 -0
  68. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/reference/configuration.rst +0 -0
  69. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/reference/enumerations.rst +0 -0
  70. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/reference/index.rst +0 -0
  71. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/reference/installation.rst +0 -0
  72. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/reference/protocol/data_conversions.rst +0 -0
  73. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/reference/protocol/device_features.rst +0 -0
  74. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/reference/protocol/device_status.rst +0 -0
  75. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/reference/protocol/error_codes.rst +0 -0
  76. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/reference/protocol/mqtt_protocol.rst +0 -0
  77. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/reference/protocol/quick_reference.rst +0 -0
  78. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/reference/protocol/rest_api.rst +0 -0
  79. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/reference/python_api/api_client.rst +0 -0
  80. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/reference/python_api/auth_client.rst +0 -0
  81. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/reference/python_api/cli.rst +0 -0
  82. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/reference/python_api/events.rst +0 -0
  83. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/reference/python_api/exceptions.rst +0 -0
  84. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/reference/python_api/models.rst +0 -0
  85. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/reference/python_api/mqtt_client.rst +0 -0
  86. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/reference/simulator-inputs.rst +0 -0
  87. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/requirements.txt +0 -0
  88. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/docs/tutorials/getting-started.rst +0 -0
  89. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/.ruff.toml +0 -0
  90. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/README.md +0 -0
  91. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/advanced/air_filter_reset.py +0 -0
  92. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/advanced/anti_legionella.py +0 -0
  93. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/advanced/auto_recovery.py +0 -0
  94. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/advanced/combined_callbacks.py +0 -0
  95. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/advanced/demand_response.py +0 -0
  96. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/advanced/device_capabilities.py +0 -0
  97. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/advanced/device_status_debug.py +0 -0
  98. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/advanced/energy_analytics.py +0 -0
  99. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/advanced/error_code_demo.py +0 -0
  100. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/advanced/firmware_payload_capture.py +0 -0
  101. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/advanced/mqtt_diagnostics.py +0 -0
  102. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/advanced/power_control.py +0 -0
  103. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/advanced/recirculation_control.py +0 -0
  104. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/advanced/reconnection_demo.py +0 -0
  105. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/advanced/reservation_schedule.py +0 -0
  106. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/advanced/simple_auto_recovery.py +0 -0
  107. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/advanced/token_restoration.py +0 -0
  108. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/advanced/tou_openei.py +0 -0
  109. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/advanced/tou_schedule.py +0 -0
  110. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/advanced/water_reservation.py +0 -0
  111. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/beginner/01_authentication.py +0 -0
  112. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/beginner/02_list_devices.py +0 -0
  113. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/beginner/03_get_status.py +0 -0
  114. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/beginner/04_set_temperature.py +0 -0
  115. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/intermediate/advanced_auth_patterns.py +0 -0
  116. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/intermediate/command_queue.py +0 -0
  117. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/intermediate/device_status_callback.py +0 -0
  118. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/intermediate/error_handling.py +0 -0
  119. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/intermediate/event_driven_control.py +0 -0
  120. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/intermediate/improved_auth.py +0 -0
  121. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/intermediate/legacy_auth_constructor.py +0 -0
  122. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/intermediate/mqtt_realtime_monitoring.py +0 -0
  123. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/intermediate/periodic_requests.py +0 -0
  124. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/intermediate/set_mode.py +0 -0
  125. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/intermediate/vacation_mode.py +0 -0
  126. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/mask.py +0 -0
  127. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/simulator/lower-setpoint.json +0 -0
  128. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/simulator/plan.json +0 -0
  129. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/simulator/scenario.json +0 -0
  130. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/testing/periodic_device_info.py +0 -0
  131. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/testing/simple_periodic_info.py +0 -0
  132. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/testing/test_api_client.py +0 -0
  133. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/testing/test_mqtt_connection.py +0 -0
  134. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/testing/test_mqtt_messaging.py +0 -0
  135. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/examples/testing/test_periodic_minimal.py +0 -0
  136. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/pyproject.toml +0 -0
  137. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/scripts/README.md +0 -0
  138. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/scripts/bump_version.py +0 -0
  139. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/scripts/diagnose_mqtt_connection.py +0 -0
  140. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/scripts/extract_changelog.py +0 -0
  141. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/scripts/format.py +0 -0
  142. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/scripts/lint.py +0 -0
  143. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/scripts/setup-dev.py +0 -0
  144. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/scripts/validate_version.py +0 -0
  145. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/setup.cfg +0 -0
  146. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/setup.py +0 -0
  147. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/__init__.py +0 -0
  148. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/_base.py +0 -0
  149. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/api_client.py +0 -0
  150. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/auth.py +0 -0
  151. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/ble/__init__.py +0 -0
  152. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/ble/__main__.py +0 -0
  153. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/ble/client.py +0 -0
  154. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/ble/mqtt_adapter.py +0 -0
  155. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/ble/protocol.py +0 -0
  156. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/cli/__init__.py +0 -0
  157. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/cli/__main__.py +0 -0
  158. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/cli/ble_backend.py +0 -0
  159. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/cli/calibration.py +0 -0
  160. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/cli/handlers.py +0 -0
  161. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/cli/monitoring.py +0 -0
  162. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/cli/output_formatters.py +0 -0
  163. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/cli/presentation.py +0 -0
  164. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/cli/recording.py +0 -0
  165. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/cli/rich_output.py +0 -0
  166. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/cli/simulate.py +0 -0
  167. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/cli/token_storage.py +0 -0
  168. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/command_decorators.py +0 -0
  169. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/config.py +0 -0
  170. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/converters.py +0 -0
  171. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/device_capabilities.py +0 -0
  172. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/device_info_cache.py +0 -0
  173. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/encoding.py +0 -0
  174. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/enums.py +0 -0
  175. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/events.py +0 -0
  176. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/exceptions.py +0 -0
  177. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/factory.py +0 -0
  178. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/field_factory.py +0 -0
  179. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/models/__init__.py +0 -0
  180. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/models/_converters.py +0 -0
  181. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/models/account.py +0 -0
  182. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/models/device.py +0 -0
  183. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/models/diagnostics.py +0 -0
  184. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/models/energy.py +0 -0
  185. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/models/feature.py +0 -0
  186. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/models/firmware.py +0 -0
  187. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/models/maintenance.py +0 -0
  188. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/models/mqtt_models.py +0 -0
  189. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/models/schedule.py +0 -0
  190. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/models/status.py +0 -0
  191. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/models/tou.py +0 -0
  192. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/mqtt/__init__.py +0 -0
  193. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/mqtt/_control_commands.py +0 -0
  194. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/mqtt/_device_subscriptions.py +0 -0
  195. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/mqtt/client.py +0 -0
  196. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/mqtt/command_queue.py +0 -0
  197. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/mqtt/connection.py +0 -0
  198. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/mqtt/control.py +0 -0
  199. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/mqtt/diagnostics.py +0 -0
  200. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/mqtt/periodic.py +0 -0
  201. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/mqtt/reconnection.py +0 -0
  202. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/mqtt/state_tracker.py +0 -0
  203. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/mqtt/subscriptions.py +0 -0
  204. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/mqtt/types.py +0 -0
  205. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/mqtt/utils.py +0 -0
  206. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/mqtt_events.py +0 -0
  207. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/openei.py +0 -0
  208. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/py.typed +0 -0
  209. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/reservations.py +0 -0
  210. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/sim/__init__.py +0 -0
  211. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/sim/attribution.py +0 -0
  212. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/sim/book.py +0 -0
  213. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/sim/circulation.py +0 -0
  214. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/sim/codec.py +0 -0
  215. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/sim/cold_supply.py +0 -0
  216. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/sim/controller.py +0 -0
  217. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/sim/defaults.py +0 -0
  218. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/sim/delivery.py +0 -0
  219. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/sim/draw_forecast.py +0 -0
  220. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/sim/engine.py +0 -0
  221. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/sim/extensions.py +0 -0
  222. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/sim/heat_pump_recovery.py +0 -0
  223. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/sim/heating_trajectory.py +0 -0
  224. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/sim/inputs.py +0 -0
  225. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/sim/location_air.py +0 -0
  226. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/sim/meter.py +0 -0
  227. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/sim/observed.py +0 -0
  228. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/sim/physics/__init__.py +0 -0
  229. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/sim/physics/draw.py +0 -0
  230. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/sim/physics/element.py +0 -0
  231. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/sim/physics/refrigerant.py +0 -0
  232. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/sim/profile.py +0 -0
  233. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/sim/ready.py +0 -0
  234. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/sim/record.py +0 -0
  235. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/sim/result.py +0 -0
  236. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/sim/schedules.py +0 -0
  237. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/sim/tou_triggers.py +0 -0
  238. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/sim/units.py +0 -0
  239. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/sim_adapters.py +0 -0
  240. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/sim_home_assistant.py +0 -0
  241. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/sim_live.py +0 -0
  242. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/status_history.py +0 -0
  243. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/temperature.py +0 -0
  244. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/topic_builder.py +0 -0
  245. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/tou_schedule.py +0 -0
  246. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/unit_system.py +0 -0
  247. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500/utils.py +0 -0
  248. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500_python.egg-info/dependency_links.txt +0 -0
  249. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500_python.egg-info/entry_points.txt +0 -0
  250. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500_python.egg-info/not-zip-safe +0 -0
  251. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500_python.egg-info/requires.txt +0 -0
  252. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/src/nwp500_python.egg-info/top_level.txt +0 -0
  253. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/_sim_site.py +0 -0
  254. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/conftest.py +0 -0
  255. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/fixtures/sim/engine_golden.json +0 -0
  256. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/fixtures/sim/heater_params.json +0 -0
  257. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/fixtures/sim/heater_params_fitted.json +0 -0
  258. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_api_endpoints_201.py +0 -0
  259. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_api_helpers.py +0 -0
  260. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_app_queries.py +0 -0
  261. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_auth.py +0 -0
  262. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_auth_session_lifecycle.py +0 -0
  263. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_auth_session_refresh.py +0 -0
  264. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_ble_mqtt_adapter.py +0 -0
  265. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_ble_protocol.py +0 -0
  266. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_bug_fixes.py +0 -0
  267. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_canonical_schedule.py +0 -0
  268. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_cli_basic.py +0 -0
  269. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_cli_calibrate.py +0 -0
  270. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_cli_commands.py +0 -0
  271. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_cli_simulate.py +0 -0
  272. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_cli_tristate_presentation.py +0 -0
  273. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_command_decorators.py +0 -0
  274. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_command_queue.py +0 -0
  275. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_device_capabilities.py +0 -0
  276. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_device_info_cache.py +0 -0
  277. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_device_rest_models.py +0 -0
  278. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_events.py +0 -0
  279. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_exceptions.py +0 -0
  280. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_model_converters.py +0 -0
  281. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_models.py +0 -0
  282. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_mqtt_clean_session_resume.py +0 -0
  283. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_mqtt_client_init.py +0 -0
  284. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_mqtt_control_fail.py +0 -0
  285. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_mqtt_events.py +0 -0
  286. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_mqtt_hypothesis.py +0 -0
  287. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_mqtt_last_will.py +0 -0
  288. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_mqtt_reconnection.py +0 -0
  289. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_mqtt_reconnection_storm.py +0 -0
  290. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_mqtt_reliability.py +0 -0
  291. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_multi_device.py +0 -0
  292. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_navilink_201_enums.py +0 -0
  293. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_openei.py +0 -0
  294. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_protocol_correctness.py +0 -0
  295. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_public_api.py +0 -0
  296. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_reservation_validation.py +0 -0
  297. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_reservations.py +0 -0
  298. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_sim_adapters.py +0 -0
  299. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_sim_api.py +0 -0
  300. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_sim_book.py +0 -0
  301. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_sim_book_history.py +0 -0
  302. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_sim_calibration.py +0 -0
  303. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_sim_change_history.py +0 -0
  304. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_sim_circulation.py +0 -0
  305. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_sim_codec.py +0 -0
  306. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_sim_cold_supply.py +0 -0
  307. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_sim_controller.py +0 -0
  308. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_sim_defaults.py +0 -0
  309. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_sim_draw_forecast.py +0 -0
  310. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_sim_draw_loss.py +0 -0
  311. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_sim_element.py +0 -0
  312. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_sim_element_fit.py +0 -0
  313. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_sim_forecast.py +0 -0
  314. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_sim_golden.py +0 -0
  315. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_sim_home_assistant.py +0 -0
  316. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_sim_hot_water.py +0 -0
  317. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_sim_import_contract.py +0 -0
  318. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_sim_live.py +0 -0
  319. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_sim_location_air.py +0 -0
  320. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_sim_meter.py +0 -0
  321. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_sim_no_clock_no_state.py +0 -0
  322. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_sim_observed.py +0 -0
  323. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_sim_profile.py +0 -0
  324. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_sim_readiness.py +0 -0
  325. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_sim_readiness_fit.py +0 -0
  326. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_sim_ready.py +0 -0
  327. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_sim_ready_api.py +0 -0
  328. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_sim_replay.py +0 -0
  329. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_sim_schedules.py +0 -0
  330. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_sim_tank_physics.py +0 -0
  331. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_status_history.py +0 -0
  332. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_temperature_converters.py +0 -0
  333. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_threading_model.py +0 -0
  334. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_token_storage.py +0 -0
  335. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_tou_api.py +0 -0
  336. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_tou_schedule.py +0 -0
  337. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_unit_switching.py +0 -0
  338. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_unit_system_process_wide.py +0 -0
  339. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_utility_modules.py +0 -0
  340. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tests/test_utils.py +0 -0
  341. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/tox.ini +0 -0
  342. {nwp500_python-9.8.0 → nwp500_python-9.9.0}/uv.lock +0 -0
@@ -2,6 +2,52 @@
2
2
  Changelog
3
3
  =========
4
4
 
5
+ Version 9.9.0 (2026-10-22)
6
+ ==========================
7
+
8
+ Added
9
+ -----
10
+ - **A bottom loss in the standby model** (#187). Over one installation's 407
11
+ quiet windows across ten months, a window that opens stratified loses
12
+ heat at the bottom: its gap widens, its lower probe falls about 40 %
13
+ faster than a mixed tank's, and its upper probe hardly moves. One UA
14
+ on the whole tank could not represent it, and the fitted UA was a
15
+ blend that cooled a mixed tank too fast, as after a cold season's full
16
+ recoveries. ``LossParams`` gains ``bottom_loss_btu_hr`` and
17
+ ``bottom_gate_f``: heat leaving the lower zone, of which the lower
18
+ probe sees ``1 - exp(-gap / gate)`` (``sim.physics.tank.
19
+ bottom_loss_reach``). The standby walk (``decay.project_decay``) and
20
+ the recovery integrator charge it to the lower zone. A bottom loss of
21
+ 0.0, the default and what stored parameter sets carry, reproduces the
22
+ model before it.
23
+ - ``sim.calibration.fit_loss`` fits the UA law and the bottom loss
24
+ together, through the standby walk, on the quiet windows opening with
25
+ a gap of 10 °F or less (``loss_windows``); ``fit_standby`` uses it.
26
+ On the installation above, the lower probe's median error over ten
27
+ hours fell from 1.25 to 0.77 °F from March to October and from 1.67
28
+ to 1.05 °F over the winter (1.15 °F trained without the winter).
29
+ ``StandbyWindow`` carries each window's probes and air, and
30
+ ``standby_ends`` and ``standby_error`` walk a loss over windows.
31
+ - The report has the bottom loss and its gate, and ``keep_better``
32
+ judges them with the standby loss, on the probes' error in each
33
+ held-out window's fall, °F/h.
34
+
35
+ Changed
36
+ -------
37
+ - The standby loss is fitted only on quiet windows opening with a gap of
38
+ 10 °F or less: a wider gap is a draw's front closing, which the gap's
39
+ time constant describes.
40
+ - ``keep_better`` scores the standby group on the probes' error in each
41
+ held-out window's fall, walked as the simulation walks it, in place of
42
+ the error in log UA, so a loss with a bottom term and one without are
43
+ scored alike.
44
+ - The zone gap time constant is fitted with the bottom loss opening the
45
+ gaps it relaxes (``fit_gap_tau``'s ``loss``, ``gap_paths``), in turn
46
+ with the loss until it settles: a gap the bottom loss widens read as a
47
+ pure relaxation pushed the constant long. ``keep_better`` judges the
48
+ constant before the standby loss and fits and scores the loss on the
49
+ constant kept.
50
+
5
51
  Version 9.8.0 (2026-10-21)
6
52
  ==========================
7
53
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nwp500-python
3
- Version: 9.8.0
3
+ Version: 9.9.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
@@ -78,7 +78,11 @@ zone (about half the tank's mass). Once the gap between the zones
78
78
  closes, the tank heats as one body. In standby the gap relaxes on a
79
79
  fitted time constant. Standby loss follows a jacket law,
80
80
  ``UA(dT) = ua_ref * (max(dT, 5) / 50) ^ n``, with the ambient as the
81
- other side.
81
+ other side, on the whole tank. A bottom loss ``Q_b`` leaves the lower
82
+ zone alone: the lower probe sees the share ``1 - exp(-gap / gate)`` of
83
+ it, so a stratified tank's lower zone falls faster and its gap opens,
84
+ while a mixed tank hides it below the probe. With ``Q_b = 0`` the loss
85
+ is the jacket law alone.
82
86
 
83
87
  **The elements.** They follow the device's own rules
84
88
  (:doc:`heating-elements`): per-mode entry thresholds, a dead time,
@@ -139,16 +139,23 @@ fitted or kept the base's value, and why:
139
139
 
140
140
  * - Term
141
141
  - Fitted from
142
- * - Standby loss and its exponent
142
+ * - Standby loss, its exponent, the bottom loss and its gate
143
143
  - Quiet windows of 3 h or more, starting 45 minutes after any
144
- heating or hot water in use. A window counts only if its fit
145
- tracks the probes within 2 °F. The exponent is fitted only when
146
- the windows span two 20 °F bands of tank-to-air difference; it
147
- is held at 0 (a constant loss) otherwise.
144
+ heating or hot water in use, that open with a gap of 10 °F or
145
+ less between the probes. A window counts only if its fit tracks
146
+ the probes within 2 °F. Each window's upper and lower probe are
147
+ walked from its start as the simulation walks them, and the
148
+ terms are fitted to where the probes ended. The exponent is
149
+ fitted only when the windows span two 20 °F bands of tank-to-air
150
+ difference; it is held at 0 (a constant loss) otherwise. The
151
+ bottom loss needs 10 windows that open with a gap of 1 °F or
152
+ more; with fewer it is 0, and the loss is the jacket law
153
+ alone.
148
154
  * - Zone gap time constant
149
155
  - How fast the upper and lower probes' gap closes over quiet
150
156
  windows; it needs 20 windows that open with a gap of 2 °F or
151
- more.
157
+ more. The bottom loss opens the gaps it relaxes, so the two are
158
+ fitted in turn until the constant settles.
152
159
  * - Compressor constant, evaporator approach
153
160
  - Heat-pump recoveries that reached their target, against the loss
154
161
  just fitted. The error is the mean absolute error of the
@@ -240,8 +247,12 @@ fit as ``params`` with ``keep_better=True``:
240
247
 
241
248
  Each group of terms holds out its latest evidence from its refit:
242
249
 
243
- - the standby loss and its exponent: its quiet windows;
244
- - the zone gap time constant: its windows that open a gap;
250
+ - the standby loss, its exponent, the bottom loss and its gate: its
251
+ quiet windows, scored on the probes' error in each window's fall,
252
+ °F/h;
253
+ - the zone gap time constant: its windows that open a gap, judged
254
+ before the standby loss, which is then fitted and scored on the
255
+ constant kept;
245
256
  - the compressor constant and the evaporator approach: its reached
246
257
  recoveries;
247
258
  - the power scale: the metered compressor cycles it can use;
@@ -169,6 +169,8 @@ Hot water and the heat book
169
169
  metered_withdrawn, metered_lead,
170
170
  path_to_end, with_power, snapshots, recoveries, quiet_windows,
171
171
  busy_intervals, fit_window, fit_ua_law, fit_gap_tau, with_standby,
172
+ fit_loss, LossFit, loss_windows, standby_ends, standby_error,
173
+ walk_tau, gap_paths, gap_error,
172
174
  with_compressor, UA_SEARCH_BOUNDS, UA_EXPONENT_BOUNDS, DRAW_LEAD
173
175
 
174
176
  .. automodule:: nwp500.sim.profile
@@ -250,7 +252,7 @@ The pieces the entry points are built from.
250
252
  vapor_enthalpy, dew_point_f
251
253
 
252
254
  .. automodule:: nwp500.sim.physics.tank
253
- :members: ua_at
255
+ :members: ua_at, bottom_loss_reach, bottom_loss_drop_f
254
256
 
255
257
  .. automodule:: nwp500.sim.physics.element
256
258
  :members: mode_recovery, ModeRecovery, mode_plan, ModePlan,
@@ -78,7 +78,7 @@ from nwp500.sim.inputs import (
78
78
  Schedules,
79
79
  Settings,
80
80
  )
81
- from nwp500.sim.params import HeaterParams
81
+ from nwp500.sim.params import TAU_GAP_PRIOR_H, HeaterParams
82
82
  from nwp500.sim.physics.compressor import MAX_MINUTES, predict_physics_detailed
83
83
  from nwp500.sim.physics.element import (
84
84
  DEVICE_MODE,
@@ -224,10 +224,6 @@ LOW_CONFIDENCE_FLOOR = 0.6
224
224
  #: start was never searched (the TOU-opening start still was), so an
225
225
  #: answer may miss a start the heater makes.
226
226
  HP_LOWER_ON_UNKNOWN = "hp_lower_on_unknown"
227
- #: The gap time constant served when the parameters carry none
228
- #: (``TankParams.stratification_gap_tau_h``), h: history puts it near
229
- #: 60 h on this product.
230
- TAU_GAP_PRIOR_H = 60.0
231
227
  #: Draws opened a gap that closes on that prior, not a fitted constant:
232
228
  #: under no draws it only shapes how an existing gap closes.
233
229
  TAU_GAP_PRIOR = "tau_gap_prior"
@@ -50,7 +50,12 @@ from zoneinfo import ZoneInfo
50
50
 
51
51
  import numpy as np
52
52
  import pandas as pd
53
- from scipy.optimize import OptimizeResult, minimize, minimize_scalar
53
+ from scipy.optimize import (
54
+ OptimizeResult,
55
+ least_squares,
56
+ minimize,
57
+ minimize_scalar,
58
+ )
54
59
 
55
60
  from nwp500.sim.book import Booked, full_tank, house_gpm, is_full
56
61
  from nwp500.sim.book import Step as BookStep
@@ -61,6 +66,7 @@ from nwp500.sim.heating_trajectory import path_cop
61
66
  from nwp500.sim.inputs import ELECTRIC, ENERGY_SAVER, HIGH_DEMAND
62
67
  from nwp500.sim.params import (
63
68
  INSTALLATION_TERMS,
69
+ TAU_GAP_PRIOR_H,
64
70
  UA_MIN_DELTA_T_F,
65
71
  UA_REF_DELTA_T_F,
66
72
  UNFITTED_TERMS,
@@ -104,6 +110,26 @@ _BAND_WIDTH_F = 20.0
104
110
  MIN_GAP_FIT_START_F = 2.0
105
111
  MIN_GAP_FIT_WINDOWS = 20
106
112
  GAP_TAU_BOUNDS_HOURS = (1.0, 2000.0)
113
+ #: The loss law is fitted on windows opening with a gap of at most
114
+ #: this, °F: a wider one is a draw's front closing, the gap's constant's
115
+ #: regime, not the standby loss's.
116
+ MAX_LOSS_FIT_GAP_F = 10.0
117
+ #: A window opening with at least this gap, °F, shows the bottom loss
118
+ #: (``physics.tank``); the bottom loss is fitted only on this many.
119
+ MIN_BOTTOM_FIT_GAP_F = 1.0
120
+ MIN_BOTTOM_FIT_WINDOWS = 10
121
+ #: Bounds of the bottom loss, BTU/hr, and of its gate, °F.
122
+ BOTTOM_LOSS_BOUNDS = (0.0, 1000.0)
123
+ BOTTOM_GATE_BOUNDS = (0.05, 30.0)
124
+ #: The joint fit's soft_l1 scale, °F/h·√h: a window's end error
125
+ #: per hour, weighted by the root of its hours, past which a residual
126
+ #: counts linearly (``scipy.optimize.least_squares``' ``soft_l1``).
127
+ _LOSS_FIT_SCALE = 0.05
128
+ #: The gap's constant and the loss are fitted in turn
129
+ #: (``fit_standby``) until the constant moves less than this share, or
130
+ #: this many rounds.
131
+ _STANDBY_ROUNDS_RTOL = 0.005
132
+ _MAX_STANDBY_ROUNDS = 20
107
133
 
108
134
 
109
135
  def _instants(frame: pd.DataFrame) -> list[datetime]:
@@ -191,6 +217,11 @@ def quiet_windows(
191
217
  return out
192
218
 
193
219
 
220
+ def _no_series() -> np.ndarray:
221
+ """An empty series: a window made without its probes."""
222
+ return np.zeros(0)
223
+
224
+
194
225
  @dataclass(frozen=True)
195
226
  class StandbyWindow:
196
227
  """One quiet window's fit: the UA that decays the tank's mean as
@@ -206,6 +237,12 @@ class StandbyWindow:
206
237
  #: the stratification fit's input.
207
238
  gap_hours: np.ndarray = field(repr=False)
208
239
  gap_f: np.ndarray = field(repr=False)
240
+ #: The probes and the air on ``gap_hours``' grid, °F: the loss law's
241
+ #: input (``fit_loss``). Empty, a window made without them, is not
242
+ #: fitted on.
243
+ upper_f: np.ndarray = field(default_factory=_no_series, repr=False)
244
+ lower_f: np.ndarray = field(default_factory=_no_series, repr=False)
245
+ air_f: np.ndarray = field(default_factory=_no_series, repr=False)
209
246
 
210
247
 
211
248
  def fit_window(
@@ -272,6 +309,9 @@ def fit_window(
272
309
  duration_hours=(end - start).total_seconds() / 3600.0,
273
310
  gap_hours=np.concatenate(([0.0], np.cumsum(dt_h))),
274
311
  gap_f=(wide["upper_f"] - wide["lower_f"]).to_numpy(dtype=float),
312
+ upper_f=wide["upper_f"].to_numpy(dtype=float),
313
+ lower_f=wide["lower_f"].to_numpy(dtype=float),
314
+ air_f=air,
275
315
  )
276
316
 
277
317
 
@@ -313,16 +353,55 @@ def fit_ua_law(
313
353
  return float(np.exp(intercept)), exponent
314
354
 
315
355
 
356
+ def gap_paths(
357
+ windows: Sequence[tuple[np.ndarray, np.ndarray]],
358
+ tau_gap_hours: float,
359
+ loss: LossParams | None = None,
360
+ tank: TankParams = TankParams(),
361
+ ) -> list[np.ndarray]:
362
+ """Each window's gap, ``(hours, gap)``, as the standby walk takes it
363
+ from its first value: relaxing on ``tau_gap_hours``, and opened by
364
+ ``loss``' bottom loss (``physics.tank``) where it has one. Without
365
+ one, the closed form ``gap_0 exp(-t / tau)``."""
366
+ if loss is None or loss.bottom_loss_btu_hr <= 0.0:
367
+ return [g[0] * np.exp(-t / tau_gap_hours) for t, g in windows]
368
+ q, gate = loss.bottom_loss_btu_hr, loss.bottom_gate_f
369
+ lower_mass = tank.lower_zone_thermal_mass_btu_f
370
+ n = len(windows)
371
+ if n == 0:
372
+ return []
373
+ steps = max(len(g) for _, g in windows) - 1
374
+ dt = np.zeros((n, max(steps, 0)))
375
+ for i, (t, _) in enumerate(windows):
376
+ dt[i, : len(t) - 1] = np.diff(np.asarray(t, dtype=float))
377
+ gap = np.maximum(np.array([g[0] for _, g in windows], dtype=float), 0.0)
378
+ path = np.zeros((n, steps + 1))
379
+ path[:, 0] = gap
380
+ for j in range(steps):
381
+ step = dt[:, j]
382
+ if gate > 0.0:
383
+ # The bottom loss at the step's start, as the walk charges it.
384
+ gap = gap + q * (1.0 - np.exp(-gap / gate)) * step / lower_mass
385
+ gap = gap * np.exp(-step / tau_gap_hours)
386
+ path[:, j + 1] = gap
387
+ return [path[i, : len(g)] for i, (_, g) in enumerate(windows)]
388
+
389
+
316
390
  def fit_gap_tau(
317
391
  windows: Sequence[tuple[np.ndarray, np.ndarray]],
318
392
  min_start_gap_f: float = MIN_GAP_FIT_START_F,
319
393
  min_windows: int = MIN_GAP_FIT_WINDOWS,
394
+ *,
395
+ loss: LossParams | None = None,
396
+ tank: TankParams = TankParams(),
320
397
  ) -> tuple[float | None, int]:
321
398
  """The time constant the stratification gap relaxes on, hours: one
322
- constant minimising the squared gap error of ``gap_0 exp(-t/tau)``
323
- pooled over every sample of every window starting with a gap of at
324
- least ``min_start_gap_f``. ``(None, n)`` with fewer than
325
- ``min_windows``."""
399
+ constant minimising the squared gap error of ``gap_paths`` (with no
400
+ bottom loss, ``gap_0 exp(-t/tau)``) pooled over every sample of
401
+ every window starting with a gap of at least ``min_start_gap_f``.
402
+ ``loss``' bottom loss opens the gaps it relaxes, as the walk does,
403
+ so a gap it widens is not read as one relaxing slowly. ``(None, n)``
404
+ with fewer than ``min_windows``."""
326
405
  used = [
327
406
  (t, g)
328
407
  for t, g in windows
@@ -332,9 +411,10 @@ def fit_gap_tau(
332
411
  return None, len(used)
333
412
 
334
413
  def sse(log_tau: float) -> float:
335
- tau = float(np.exp(log_tau))
414
+ paths = gap_paths(used, float(np.exp(log_tau)), loss, tank)
336
415
  return sum(
337
- float(np.sum((g - g[0] * np.exp(-t / tau)) ** 2)) for t, g in used
416
+ float(np.sum((g - path) ** 2))
417
+ for (_, g), path in zip(used, paths, strict=True)
338
418
  )
339
419
 
340
420
  low, high = GAP_TAU_BOUNDS_HOURS
@@ -347,6 +427,225 @@ def fit_gap_tau(
347
427
  return float(np.exp(found.x)), len(used)
348
428
 
349
429
 
430
+ def gap_error(
431
+ windows: Sequence[StandbyWindow],
432
+ tau_gap_hours: float | None,
433
+ loss: LossParams | None = None,
434
+ tank: TankParams = TankParams(),
435
+ ) -> float | None:
436
+ """The gap's mean squared error on ``windows``, °F², as
437
+ ``gap_paths`` takes it. None with no window or no constant."""
438
+ if not windows or tau_gap_hours is None or not tau_gap_hours > 0.0:
439
+ return None
440
+ pairs = [(w.gap_hours, w.gap_f) for w in windows]
441
+ paths = gap_paths(pairs, tau_gap_hours, loss, tank)
442
+ squared = [
443
+ (g - path) ** 2 for (_, g), path in zip(pairs, paths, strict=True)
444
+ ]
445
+ return float(np.mean(np.concatenate(squared)))
446
+
447
+
448
+ def walk_tau(tank: TankParams, tau: float | None = None) -> float:
449
+ """The gap's constant the standby loss is walked on, hours: ``tau``,
450
+ else ``tank``'s, else ``TAU_GAP_PRIOR_H``, as the simulation serves
451
+ an unfitted one."""
452
+ if tau is not None:
453
+ return tau
454
+ if tank.stratification_gap_tau_h is not None:
455
+ return tank.stratification_gap_tau_h
456
+ return TAU_GAP_PRIOR_H
457
+
458
+
459
+ def loss_windows(
460
+ windows: Sequence[StandbyWindow],
461
+ ) -> list[StandbyWindow]:
462
+ """The windows ``fit_loss`` fits on: with their probes and air,
463
+ opening with a gap of at most ``MAX_LOSS_FIT_GAP_F``, and a UA, a
464
+ ΔT and hours above zero (``fit_ua_law``'s own test)."""
465
+ return [
466
+ w
467
+ for w in windows
468
+ if len(w.upper_f) >= 2
469
+ and len(w.upper_f) == len(w.lower_f) == len(w.air_f)
470
+ and len(w.gap_hours) == len(w.upper_f)
471
+ and bool(np.isfinite(w.upper_f).all())
472
+ and bool(np.isfinite(w.lower_f).all())
473
+ and bool(np.isfinite(w.air_f).all())
474
+ and w.upper_f[0] - w.lower_f[0] <= MAX_LOSS_FIT_GAP_F
475
+ and w.ua_btu_hr_f > 0.0
476
+ and w.delta_t_mean_f > 0.0
477
+ and w.duration_hours > 0.0
478
+ ]
479
+
480
+
481
+ def standby_ends(
482
+ windows: Sequence[StandbyWindow],
483
+ loss: LossParams,
484
+ tau_gap_hours: float,
485
+ tank: TankParams = TankParams(),
486
+ ) -> tuple[np.ndarray, np.ndarray]:
487
+ """Each window's upper and lower probe at its end, °F, as the
488
+ standby walk (``decay.project_decay``) takes them there from its
489
+ first reading on ``loss``: the UA law on the bulk, the bottom loss
490
+ on the lower zone, the gap relaxing on ``tau_gap_hours``. Windows
491
+ from ``loss_windows``; a UA of None is ``ua_baseline_btu_hr_f``."""
492
+ n = len(windows)
493
+ if n == 0:
494
+ return np.zeros(0), np.zeros(0)
495
+ steps = max(len(w.upper_f) for w in windows) - 1
496
+ dt = np.zeros((n, steps))
497
+ air = np.zeros((n, steps))
498
+ for i, w in enumerate(windows):
499
+ k = len(w.upper_f) - 1
500
+ dt[i, :k] = np.diff(np.asarray(w.gap_hours, dtype=float))
501
+ air[i, :k] = w.air_f[:k]
502
+ ua_ref = (
503
+ loss.ua_baseline_btu_hr_f
504
+ if loss.ua_btu_hr_f is None
505
+ else loss.ua_btu_hr_f
506
+ )
507
+ mass = tank.thermal_mass_btu_f
508
+ lower_mass = tank.lower_zone_thermal_mass_btu_f
509
+ upper0 = np.array([w.upper_f[0] for w in windows], dtype=float)
510
+ lower0 = np.array([w.lower_f[0] for w in windows], dtype=float)
511
+ bulk = (upper0 + lower0) / 2.0
512
+ gap = np.maximum(upper0 - lower0, 0.0)
513
+ q, gate = loss.bottom_loss_btu_hr, loss.bottom_gate_f
514
+ for j in range(steps):
515
+ step = dt[:, j]
516
+ if q > 0.0 and gate > 0.0:
517
+ # At the step's start, as the walk charges it.
518
+ drop = q * (1.0 - np.exp(-gap / gate)) * step / lower_mass
519
+ bulk = bulk - drop / 2.0
520
+ gap = gap + drop
521
+ delta = bulk - air[:, j]
522
+ if loss.ua_exponent == 0.0:
523
+ ua = np.full(n, ua_ref)
524
+ else:
525
+ scaled = (
526
+ np.maximum(np.abs(delta), UA_MIN_DELTA_T_F)
527
+ / loss.ua_ref_delta_t_f
528
+ )
529
+ ua = ua_ref * scaled**loss.ua_exponent
530
+ bulk = bulk - ua / mass * delta * step
531
+ gap = gap * np.exp(-step / tau_gap_hours)
532
+ return bulk + gap / 2.0, bulk - gap / 2.0
533
+
534
+
535
+ def standby_error(
536
+ windows: Sequence[StandbyWindow],
537
+ loss: LossParams,
538
+ tau_gap_hours: float,
539
+ tank: TankParams = TankParams(),
540
+ ) -> float | None:
541
+ """The probes' mean absolute error in each window's fall, °F/h:
542
+ ``standby_ends`` against each window's last readings, both probes
543
+ alike. None with no window."""
544
+ if not windows:
545
+ return None
546
+ upper, lower = standby_ends(windows, loss, tau_gap_hours, tank)
547
+ hours = np.array([w.duration_hours for w in windows], dtype=float)
548
+ seen_u = np.array([w.upper_f[-1] for w in windows], dtype=float)
549
+ seen_l = np.array([w.lower_f[-1] for w in windows], dtype=float)
550
+ errors = np.concatenate(
551
+ [np.abs(upper - seen_u) / hours, np.abs(lower - seen_l) / hours]
552
+ )
553
+ return float(np.mean(errors))
554
+
555
+
556
+ @dataclass(frozen=True)
557
+ class LossFit:
558
+ """The loss law ``fit_loss`` found: the UA law's pair, the bottom
559
+ loss and its gate (0.0 and None without the evidence for them), and
560
+ the windows it stood on, and of those, the stratified ones."""
561
+
562
+ ua_ref: float
563
+ ua_exponent: float
564
+ bottom_loss_btu_hr: float
565
+ bottom_gate_f: float | None
566
+ n_windows: int
567
+ n_stratified: int
568
+
569
+
570
+ def fit_loss(
571
+ windows: Sequence[StandbyWindow],
572
+ tau_gap_hours: float,
573
+ tank: TankParams = TankParams(),
574
+ ) -> LossFit | None:
575
+ """The standby loss on ``windows`` (``loss_windows`` of them).
576
+
577
+ With ``MIN_BOTTOM_FIT_WINDOWS`` opening with a gap of at least
578
+ ``MIN_BOTTOM_FIT_GAP_F``, the UA law and the bottom loss are fitted
579
+ together through the standby walk (``standby_ends``): each window's
580
+ upper and lower probe at its end against what it read, per hour,
581
+ weighted by the root of its hours, ``soft_l1``. A mixed window
582
+ holds the UA law; a stratified one, the bottom loss and its gate
583
+ (``physics.tank``). The exponent is fitted only where ``fit_ua_law``
584
+ would fit one. Without that evidence, ``fit_ua_law``'s law and no
585
+ bottom loss. None with no window."""
586
+ used = loss_windows(windows)
587
+ if not used:
588
+ return None
589
+ ua_ref, exponent = fit_ua_law(
590
+ [w.delta_t_mean_f for w in used],
591
+ [w.ua_btu_hr_f for w in used],
592
+ [w.duration_hours for w in used],
593
+ )
594
+ stratified = [
595
+ w for w in used if w.upper_f[0] - w.lower_f[0] >= MIN_BOTTOM_FIT_GAP_F
596
+ ]
597
+ if len(stratified) < MIN_BOTTOM_FIT_WINDOWS:
598
+ return LossFit(ua_ref, exponent, 0.0, None, len(used), len(stratified))
599
+ bands = np.floor(np.array([w.delta_t_mean_f for w in used]) / _BAND_WIDTH_F)
600
+ with_exponent = (
601
+ len(used) >= _MIN_WINDOWS_FOR_LAW
602
+ and len(np.unique(bands)) >= _MIN_BANDS_FOR_LAW
603
+ )
604
+ hours = np.array([w.duration_hours for w in used], dtype=float)
605
+ seen_u = np.array([w.upper_f[-1] for w in used], dtype=float)
606
+ seen_l = np.array([w.lower_f[-1] for w in used], dtype=float)
607
+ weight = np.sqrt(hours) / hours
608
+
609
+ def law(x: np.ndarray) -> LossParams:
610
+ return LossParams(
611
+ ua_btu_hr_f=float(x[0]),
612
+ ua_exponent=float(x[3]) if with_exponent else 0.0,
613
+ bottom_loss_btu_hr=float(x[1]),
614
+ bottom_gate_f=float(x[2]),
615
+ )
616
+
617
+ def residuals(x: np.ndarray) -> np.ndarray:
618
+ upper, lower = standby_ends(used, law(x), tau_gap_hours, tank)
619
+ return np.concatenate(
620
+ [(upper - seen_u) * weight, (lower - seen_l) * weight]
621
+ )
622
+
623
+ lo = [UA_SEARCH_BOUNDS[0], BOTTOM_LOSS_BOUNDS[0], BOTTOM_GATE_BOUNDS[0]]
624
+ hi = [UA_SEARCH_BOUNDS[1], BOTTOM_LOSS_BOUNDS[1], BOTTOM_GATE_BOUNDS[1]]
625
+ x0 = [ua_ref, 50.0, 1.0]
626
+ if with_exponent:
627
+ lo.append(UA_EXPONENT_BOUNDS[0])
628
+ hi.append(UA_EXPONENT_BOUNDS[1])
629
+ x0.append(min(max(exponent, 0.05), UA_EXPONENT_BOUNDS[1] - 0.05))
630
+ x0[0] = min(max(x0[0], lo[0] + 1e-6), hi[0] - 1e-6)
631
+ found = least_squares(
632
+ residuals,
633
+ np.array(x0, dtype=float),
634
+ bounds=(np.array(lo), np.array(hi)),
635
+ loss="soft_l1",
636
+ f_scale=_LOSS_FIT_SCALE,
637
+ )
638
+ fitted = law(found.x)
639
+ return LossFit(
640
+ cast(float, fitted.ua_btu_hr_f),
641
+ fitted.ua_exponent,
642
+ fitted.bottom_loss_btu_hr,
643
+ fitted.bottom_gate_f,
644
+ len(used),
645
+ len(stratified),
646
+ )
647
+
648
+
350
649
  @dataclass(frozen=True)
351
650
  class StandbyFit:
352
651
  """The standby fits: the windows kept, the law, the gap's constant,
@@ -364,6 +663,12 @@ class StandbyFit:
364
663
  #: The quiet test: ``meter`` with draws and circulation recorded,
365
664
  #: ``dhw_in_use`` without them, ``meter+dhw_in_use`` with one.
366
665
  quiet_by: str
666
+ #: The bottom loss and its gate (``fit_loss``): 0.0 and None
667
+ #: without the stratified windows to fit them on, and how many
668
+ #: there were.
669
+ bottom_loss_btu_hr: float = 0.0
670
+ bottom_gate_f: float | None = None
671
+ n_stratified: int = 0
367
672
 
368
673
 
369
674
  def fit_standby(
@@ -372,34 +677,63 @@ def fit_standby(
372
677
  *,
373
678
  min_quiet_hours: float = MIN_QUIET_HOURS,
374
679
  settle_minutes: float = SETTLE_MINUTES,
680
+ tank: TankParams | None = None,
681
+ loss: LossParams | None = None,
375
682
  ) -> StandbyFit:
376
- """The standby loss law and the gap's time constant from the
377
- history's quiet windows."""
683
+ """The standby loss and the gap's time constant from the history's
684
+ quiet windows: the gap's constant first, then the loss
685
+ (``fit_loss``) walked on it, or on ``tank``'s where too few windows
686
+ open with a gap to fit one. ``tank`` (default the default tank of
687
+ ``thermal_mass_btu_f``'s mass) is the walk's lower zone. ``loss``
688
+ is the loss the parameters hold: where no loss is fitted (no window
689
+ the loss can use), its bottom loss opens the gaps the constant is
690
+ fitted on, as the simulation will run them."""
691
+ if tank is None:
692
+ tank = replace(
693
+ TankParams(),
694
+ volume_gal=thermal_mass_btu_f / WATER_BTU_PER_GAL_F,
695
+ )
378
696
  quiet = quiet_windows(history, min_quiet_hours, settle_minutes)
379
697
  kept: list[StandbyWindow] = []
380
698
  for window in quiet:
381
699
  fit = fit_window(history, window, thermal_mass_btu_f)
382
700
  if fit is not None and fit.rmse_f < MAX_WINDOW_RMSE_F:
383
701
  kept.append(fit)
384
- ua_ref = exponent = None
385
- if kept:
386
- ua_ref, exponent = fit_ua_law(
387
- [w.delta_t_mean_f for w in kept],
388
- [w.ua_btu_hr_f for w in kept],
389
- # A long cold soak constrains the decay far better than a
390
- # short window.
391
- [w.duration_hours for w in kept],
702
+ # The gap's constant and the bottom loss each shape the other's
703
+ # evidence: the loss is walked on the constant, and the bottom loss
704
+ # opens the gaps the constant relaxes. Fitted in turn, the constant
705
+ # first on the gaps alone, until the constant settles.
706
+ gaps = [(w.gap_hours, w.gap_f) for w in kept]
707
+ tau, n_gap = fit_gap_tau(gaps)
708
+ found = fit_loss(kept, walk_tau(tank, tau), tank)
709
+ if found is None and loss is not None and loss.bottom_loss_btu_hr > 0.0:
710
+ # No loss to fit: the parameters keep theirs, so the constant is
711
+ # fitted on the gaps their bottom loss opens.
712
+ tau, n_gap = fit_gap_tau(gaps, loss=loss, tank=tank)
713
+ for _ in range(_MAX_STANDBY_ROUNDS):
714
+ if found is None or found.bottom_gate_f is None or tau is None:
715
+ break
716
+ opened = LossParams(
717
+ bottom_loss_btu_hr=found.bottom_loss_btu_hr,
718
+ bottom_gate_f=found.bottom_gate_f,
392
719
  )
393
- tau, n_gap = fit_gap_tau([(w.gap_hours, w.gap_f) for w in kept])
720
+ before = tau
721
+ tau, n_gap = fit_gap_tau(gaps, loss=opened, tank=tank)
722
+ found = fit_loss(kept, walk_tau(tank, tau), tank)
723
+ if tau is None or abs(tau - before) <= _STANDBY_ROUNDS_RTOL * before:
724
+ break
394
725
  tier1 = [c for c in ("drawing", "circulating") if c in history.columns]
395
726
  return StandbyFit(
396
727
  windows=tuple(kept),
397
728
  n_quiet=len(quiet),
398
729
  n_dropped=len(quiet) - len(kept),
399
- ua_ref=ua_ref,
400
- ua_exponent=exponent,
730
+ ua_ref=None if found is None else found.ua_ref,
731
+ ua_exponent=None if found is None else found.ua_exponent,
401
732
  tau_gap_hours=tau,
402
733
  n_gap_windows=n_gap,
734
+ bottom_loss_btu_hr=0.0 if found is None else found.bottom_loss_btu_hr,
735
+ bottom_gate_f=None if found is None else found.bottom_gate_f,
736
+ n_stratified=0 if found is None else found.n_stratified,
403
737
  quiet_by=(
404
738
  "meter"
405
739
  if len(tier1) == 2
@@ -997,12 +1331,20 @@ def with_standby(params: HeaterParams, fit: StandbyFit) -> HeaterParams:
997
1331
  loss, tank = params.loss, params.tank
998
1332
  if fit.ua_ref is not None and fit.ua_exponent is not None:
999
1333
  # Quoted at the ΔT ``fit_standby``'s law was fitted at, not
1000
- # whatever ``params`` held.
1334
+ # whatever ``params`` held. The bottom loss goes with it: a law
1335
+ # fitted without one has the bottom's heat in its UA, so the
1336
+ # parameters' own would count it twice.
1001
1337
  loss = replace(
1002
1338
  loss,
1003
1339
  ua_btu_hr_f=fit.ua_ref,
1004
1340
  ua_exponent=fit.ua_exponent,
1005
1341
  ua_ref_delta_t_f=UA_REF_DELTA_T_F,
1342
+ bottom_loss_btu_hr=fit.bottom_loss_btu_hr,
1343
+ bottom_gate_f=(
1344
+ loss.bottom_gate_f
1345
+ if fit.bottom_gate_f is None
1346
+ else fit.bottom_gate_f
1347
+ ),
1006
1348
  )
1007
1349
  if fit.tau_gap_hours is not None:
1008
1350
  tank = replace(tank, stratification_gap_tau_h=fit.tau_gap_hours)
@@ -1544,7 +1886,9 @@ def calibrate(
1544
1886
  ``gap_tau``. ``base``'s record of the installation's terms
1545
1887
  (``INSTALLATION_TERMS``) is kept: they are fitted apart, and a
1546
1888
  caller applying its own fits drops each from ``unfitted``."""
1547
- standby = fit_standby(history, base.tank.thermal_mass_btu_f)
1889
+ standby = fit_standby(
1890
+ history, base.tank.thermal_mass_btu_f, tank=base.tank, loss=base.loss
1891
+ )
1548
1892
  params = with_standby(base, standby)
1549
1893
  rows = snapshots(
1550
1894
  history, air=recovery_air, draws=draws, circulation=circulation