nwp500-python 9.10.0__tar.gz → 9.11.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.
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/CHANGELOG.rst +34 -0
- {nwp500_python-9.10.0/src/nwp500_python.egg-info → nwp500_python-9.11.0}/PKG-INFO +1 -1
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/how-to/simulate-heater.rst +16 -4
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/reference/python_api/sim.rst +4 -2
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/params.py +50 -6
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim_home_assistant.py +325 -17
- {nwp500_python-9.10.0 → nwp500_python-9.11.0/src/nwp500_python.egg-info}/PKG-INFO +1 -1
- nwp500_python-9.11.0/src/nwp500_python.egg-info/scm_version.json +8 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_home_assistant.py +427 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_hot_water.py +47 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_overlay.py +15 -0
- nwp500_python-9.10.0/src/nwp500_python.egg-info/scm_version.json +0 -8
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/.coveragerc +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/.github/RESOLVING_PR_COMMENTS.md +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/.github/copilot-instructions.md +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/.github/workflows/ci.yml +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/.github/workflows/release.yml +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/.gitignore +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/.pre-commit-config.yaml +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/.readthedocs.yml +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/AUTHORS.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/CONTRIBUTING.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/LICENSE.txt +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/Makefile +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/README.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/RELEASE.md +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/Makefile +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/_static/.gitignore +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/conf.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/explanation/architecture.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/explanation/draws.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/explanation/heating-elements.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/explanation/index.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/explanation/recovery-speed.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/explanation/simulator.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/explanation/standby-loss.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/explanation/tank-energy.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/explanation/tou-recovery-cap.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/explanation/unknown-values.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/explanation/what-starts-a-recovery.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/how-to/authenticate.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/how-to/auto-recovery.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/how-to/bluetooth.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/how-to/calibrate-simulator.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/how-to/diagnose-mqtt.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/how-to/home-assistant.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/how-to/index.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/how-to/maintenance.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/how-to/manage-units.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/how-to/monitor-status.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/how-to/optimize-tou.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/how-to/queue-commands.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/how-to/schedule-operation.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/how-to/track-energy.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/index.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/openapi.yaml +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/project/authors.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/project/changelog.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/project/contributing.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/project/history.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/project/license.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/reference/configuration.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/reference/enumerations.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/reference/index.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/reference/installation.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/reference/protocol/data_conversions.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/reference/protocol/device_features.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/reference/protocol/device_status.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/reference/protocol/error_codes.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/reference/protocol/mqtt_protocol.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/reference/protocol/quick_reference.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/reference/protocol/rest_api.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/reference/python_api/api_client.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/reference/python_api/auth_client.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/reference/python_api/cli.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/reference/python_api/events.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/reference/python_api/exceptions.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/reference/python_api/models.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/reference/python_api/mqtt_client.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/reference/simulator-inputs.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/requirements.txt +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/tutorials/getting-started.rst +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/.ruff.toml +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/README.md +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/advanced/air_filter_reset.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/advanced/anti_legionella.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/advanced/auto_recovery.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/advanced/combined_callbacks.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/advanced/demand_response.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/advanced/device_capabilities.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/advanced/device_status_debug.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/advanced/energy_analytics.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/advanced/error_code_demo.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/advanced/firmware_payload_capture.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/advanced/mqtt_diagnostics.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/advanced/power_control.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/advanced/recirculation_control.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/advanced/reconnection_demo.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/advanced/reservation_schedule.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/advanced/simple_auto_recovery.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/advanced/token_restoration.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/advanced/tou_openei.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/advanced/tou_schedule.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/advanced/water_reservation.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/beginner/01_authentication.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/beginner/02_list_devices.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/beginner/03_get_status.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/beginner/04_set_temperature.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/intermediate/advanced_auth_patterns.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/intermediate/command_queue.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/intermediate/device_status_callback.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/intermediate/error_handling.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/intermediate/event_driven_control.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/intermediate/improved_auth.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/intermediate/legacy_auth_constructor.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/intermediate/mqtt_realtime_monitoring.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/intermediate/periodic_requests.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/intermediate/set_mode.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/intermediate/vacation_mode.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/mask.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/simulator/lower-setpoint.json +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/simulator/plan.json +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/simulator/scenario.json +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/testing/periodic_device_info.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/testing/simple_periodic_info.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/testing/test_api_client.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/testing/test_mqtt_connection.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/testing/test_mqtt_messaging.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/testing/test_periodic_minimal.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/pyproject.toml +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/scripts/README.md +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/scripts/bump_version.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/scripts/diagnose_mqtt_connection.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/scripts/extract_changelog.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/scripts/format.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/scripts/lint.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/scripts/setup-dev.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/scripts/validate_version.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/setup.cfg +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/setup.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/__init__.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/_base.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/api_client.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/auth.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/ble/__init__.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/ble/__main__.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/ble/client.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/ble/mqtt_adapter.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/ble/protocol.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/cli/__init__.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/cli/__main__.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/cli/ble_backend.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/cli/calibration.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/cli/handlers.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/cli/monitoring.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/cli/output_formatters.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/cli/presentation.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/cli/recording.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/cli/rich_output.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/cli/simulate.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/cli/token_storage.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/command_decorators.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/config.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/converters.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/device_capabilities.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/device_info_cache.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/encoding.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/enums.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/events.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/exceptions.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/factory.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/field_factory.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/models/__init__.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/models/_converters.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/models/account.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/models/device.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/models/diagnostics.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/models/energy.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/models/feature.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/models/firmware.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/models/maintenance.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/models/mqtt_models.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/models/schedule.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/models/status.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/models/tou.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/mqtt/__init__.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/mqtt/_control_commands.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/mqtt/_device_subscriptions.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/mqtt/client.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/mqtt/command_queue.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/mqtt/connection.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/mqtt/control.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/mqtt/diagnostics.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/mqtt/periodic.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/mqtt/reconnection.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/mqtt/state_tracker.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/mqtt/subscriptions.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/mqtt/types.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/mqtt/utils.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/mqtt_events.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/openei.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/py.typed +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/reservations.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/__init__.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/api.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/attribution.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/book.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/calibration.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/circulation.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/codec.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/cold_supply.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/controller.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/decay.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/defaults.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/delivery.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/draw_forecast.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/engine.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/extensions.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/heat_pump_recovery.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/heating_trajectory.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/inputs.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/location_air.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/observed.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/physics/__init__.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/physics/compressor.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/physics/draw.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/physics/element.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/physics/refrigerant.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/physics/tank.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/profile.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/ready.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/record.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/result.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/schedules.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/tank_physics.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/tou_triggers.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/units.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim_adapters.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim_calibrate.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim_live.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/status_history.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/temperature.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/topic_builder.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/tou_schedule.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/unit_system.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/utils.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500_python.egg-info/SOURCES.txt +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500_python.egg-info/dependency_links.txt +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500_python.egg-info/entry_points.txt +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500_python.egg-info/not-zip-safe +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500_python.egg-info/requires.txt +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500_python.egg-info/scm_file_list.json +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500_python.egg-info/top_level.txt +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/_sim_site.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/conftest.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/fixtures/sim/engine_golden.json +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/fixtures/sim/heater_params.json +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/fixtures/sim/heater_params_fitted.json +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_api_endpoints_201.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_api_helpers.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_app_queries.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_auth.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_auth_session_lifecycle.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_auth_session_refresh.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_ble_mqtt_adapter.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_ble_protocol.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_bug_fixes.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_canonical_schedule.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_cli_basic.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_cli_calibrate.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_cli_commands.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_cli_simulate.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_cli_tristate_presentation.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_command_decorators.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_command_queue.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_device_capabilities.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_device_info_cache.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_device_rest_models.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_events.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_exceptions.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_model_converters.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_models.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_mqtt_clean_session_resume.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_mqtt_client_init.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_mqtt_control_fail.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_mqtt_events.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_mqtt_hypothesis.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_mqtt_last_will.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_mqtt_reconnection.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_mqtt_reconnection_storm.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_mqtt_reliability.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_multi_device.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_navilink_201_enums.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_openei.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_protocol_correctness.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_public_api.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_reservation_validation.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_reservations.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_adapters.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_api.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_book.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_book_history.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_bottom_loss.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_calibrate.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_calibration.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_change_history.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_circulation.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_codec.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_cold_supply.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_controller.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_defaults.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_documents.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_draw_forecast.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_draw_loss.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_element.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_element_fit.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_fit_book.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_forecast.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_golden.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_import_contract.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_live.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_location_air.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_no_clock_no_state.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_observed.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_profile.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_readiness.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_readiness_fit.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_ready.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_ready_api.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_replay.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_schedules.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_tank_physics.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_status_history.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_temperature_converters.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_threading_model.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_token_storage.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_tou_api.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_tou_schedule.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_unit_switching.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_unit_system_process_wide.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_utility_modules.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_utils.py +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tox.ini +0 -0
- {nwp500_python-9.10.0 → nwp500_python-9.11.0}/uv.lock +0 -0
|
@@ -2,6 +2,40 @@
|
|
|
2
2
|
Changelog
|
|
3
3
|
=========
|
|
4
4
|
|
|
5
|
+
Version 9.11.0 (2026-10-22)
|
|
6
|
+
===========================
|
|
7
|
+
|
|
8
|
+
Added
|
|
9
|
+
-----
|
|
10
|
+
- **The air from a weather entity without a model** (#214). With a
|
|
11
|
+
weather entity and no ``location_air`` model,
|
|
12
|
+
``sim_home_assistant.HomeAssistantEnvironment`` fits one
|
|
13
|
+
(``sim.location_air.fit``) on the last 3 days
|
|
14
|
+
(``LOCATION_AIR_WINDOW``) of the air entity's readings, or with
|
|
15
|
+
``heater=`` the heater's own air reading while its compressor runs,
|
|
16
|
+
against the weather entity's outdoor temperature, each reading from
|
|
17
|
+
the unit it was recorded in (``HomeAssistantClient.outdoor_history``,
|
|
18
|
+
``parse_outdoor_history``). It projects the forecast through the fit
|
|
19
|
+
when the readings the outdoor history covers span a day and follow
|
|
20
|
+
the outdoor air at r 0.5 or more, and keeps it as ``fitted_air``.
|
|
21
|
+
``nwp-cli simulate --sources`` fits one the same way when the sources
|
|
22
|
+
file has no ``[location_air]``.
|
|
23
|
+
|
|
24
|
+
Fixed
|
|
25
|
+
-----
|
|
26
|
+
- ``sim.params.params_from_document`` raised ``TypeError`` for a list
|
|
27
|
+
field given as something else (``unfitted``, the draw domain ranges,
|
|
28
|
+
readiness ``evidence``), past the ``ValueError`` it documents and
|
|
29
|
+
that ``sim_live.overlay`` and ``nwp-cli simulate`` handle. It read a
|
|
30
|
+
string, null or bool in a number field without complaint. Each is
|
|
31
|
+
now a ``ValueError`` naming the field (#212).
|
|
32
|
+
- ``HomeAssistantEnvironment`` found no inlet plateau for a probe that
|
|
33
|
+
held one value through a long draw's tail. Home Assistant's history
|
|
34
|
+
writes a row only when a value changes, so the tail had one reading
|
|
35
|
+
or none, under the plateau's 3. The probe's history is now read as
|
|
36
|
+
the value in force every 10 seconds (``held``, ``PROBE_STEP``)
|
|
37
|
+
(#216).
|
|
38
|
+
|
|
5
39
|
Version 9.10.0 (2026-10-22)
|
|
6
40
|
===========================
|
|
7
41
|
|
|
@@ -159,10 +159,13 @@ give:
|
|
|
159
159
|
|
|
160
160
|
- **the air:** the hourly forecast projected through the
|
|
161
161
|
``[location_air]`` model, shifted to pass through a reading beside the
|
|
162
|
-
heater that is at most an hour old
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
``
|
|
162
|
+
heater that is at most an hour old; without a fresh one, the
|
|
163
|
+
projection is unshifted. Without a ``[location_air]`` model, one is
|
|
164
|
+
fitted on the last 3 days of the ``air`` entity's readings against
|
|
165
|
+
the ``weather`` entity's outdoor temperature. It is used when the
|
|
166
|
+
readings span a day and follow the outdoor air at r 0.5 or more; a
|
|
167
|
+
note gives the fit. Otherwise, and without a ``weather`` entity, the
|
|
168
|
+
reading beside the heater is held over the span;
|
|
166
169
|
- **the humidity:** a reading at most an hour old;
|
|
167
170
|
- **the inlet:** the plateau of the long draws (11 minutes or more,
|
|
168
171
|
circulation off) in the last 72 hours, from the inlet probe and the
|
|
@@ -182,6 +185,15 @@ in rpm or Hz. A part that can't be read, or is stale, keeps its
|
|
|
182
185
|
default, and a warning on stderr says why. ``--ambient``, ``--inlet``
|
|
183
186
|
and ``--environment`` override what the sources give.
|
|
184
187
|
|
|
188
|
+
From Python, ``sim_home_assistant.HomeAssistantEnvironment`` reads the
|
|
189
|
+
same parts. Given ``heater=``, a ``HeaterEntities`` with ``ambient`` and
|
|
190
|
+
``compressor``, it fits the model without an ``air`` entity: on the
|
|
191
|
+
heater's own air reading every 10 minutes while the compressor runs,
|
|
192
|
+
where the reading was taken 2 minutes or more into the run (earlier, or
|
|
193
|
+
idle, it reads the cabinet), and not while the sensor was unavailable.
|
|
194
|
+
The latest such reading, if under an hour old, anchors the projection.
|
|
195
|
+
``fitted_air`` holds the fit once ``read`` has run.
|
|
196
|
+
|
|
185
197
|
|
|
186
198
|
Fetch what the heater reports
|
|
187
199
|
=============================
|
|
@@ -313,9 +313,11 @@ Home Assistant sources
|
|
|
313
313
|
|
|
314
314
|
.. automodule:: nwp500.sim_home_assistant
|
|
315
315
|
:members: HomeAssistantEnvironment, HomeAssistantClient, record_inputs,
|
|
316
|
-
RecordInputs, INLET_LOOKBACK,
|
|
316
|
+
RecordInputs, INLET_LOOKBACK, PROBE_STEP, held, LOCATION_AIR_WINDOW,
|
|
317
|
+
LOCATION_AIR_MIN_SPAN, LOCATION_AIR_MIN_R, RUN_SETTLE, Sources,
|
|
318
|
+
Entities, sources_from, State, parse_state,
|
|
317
319
|
parse_history, parse_outages, parse_states, History, history_for,
|
|
318
|
-
parse_forecast, read_heater_history, HeaterEntities, HeaterHistory,
|
|
320
|
+
parse_forecast, parse_outdoor_history, read_heater_history, HeaterEntities, HeaterHistory,
|
|
319
321
|
HISTORY_WINDOW, to_w, to_wh,
|
|
320
322
|
pump_runs, demand_spans, busy, covers_start, percent,
|
|
321
323
|
fresh, to_f, to_gpm, to_gal, to_rpm, HomeAssistantError
|
|
@@ -45,8 +45,8 @@ from collections.abc import Mapping
|
|
|
45
45
|
from dataclasses import MISSING, dataclass, field, fields, replace
|
|
46
46
|
from datetime import timedelta
|
|
47
47
|
from enum import StrEnum
|
|
48
|
-
from types import MappingProxyType
|
|
49
|
-
from typing import Any
|
|
48
|
+
from types import MappingProxyType, NoneType, UnionType
|
|
49
|
+
from typing import Any, cast, get_args, get_origin, get_type_hints
|
|
50
50
|
|
|
51
51
|
from nwp500.sim.units import WATER_BTU_PER_GAL_F
|
|
52
52
|
|
|
@@ -1187,10 +1187,49 @@ def unknown_fields(doc: Mapping[str, Any]) -> list[str]:
|
|
|
1187
1187
|
out = [k for k in doc if k not in ("schema", "unfitted") and k not in PARTS]
|
|
1188
1188
|
for name, cls in PARTS.items():
|
|
1189
1189
|
known = {f.name for f in fields(cls)}
|
|
1190
|
-
|
|
1190
|
+
given = doc.get(name, {})
|
|
1191
|
+
if isinstance(given, Mapping):
|
|
1192
|
+
keys = cast(Mapping[str, Any], given)
|
|
1193
|
+
out.extend(f"{name}.{k}" for k in keys if k not in known)
|
|
1191
1194
|
return out
|
|
1192
1195
|
|
|
1193
1196
|
|
|
1197
|
+
def _check(where: str, hint: Any, value: Any) -> None:
|
|
1198
|
+
"""A ``ValueError`` naming ``where`` when ``value`` is not what a
|
|
1199
|
+
document holds for a field of type ``hint``: a number for a float,
|
|
1200
|
+
a list for a tuple, null for None."""
|
|
1201
|
+
args = get_args(hint)
|
|
1202
|
+
if get_origin(hint) is UnionType:
|
|
1203
|
+
if value is None and NoneType in args:
|
|
1204
|
+
return
|
|
1205
|
+
(hint,) = (a for a in args if a is not NoneType)
|
|
1206
|
+
args = get_args(hint)
|
|
1207
|
+
if get_origin(hint) is tuple:
|
|
1208
|
+
if not isinstance(value, list | tuple):
|
|
1209
|
+
raise ValueError(f"{where} is {value!r}, not a list")
|
|
1210
|
+
items: list[Any] | tuple[Any, ...] = value
|
|
1211
|
+
if len(args) == 2 and args[1] is Ellipsis:
|
|
1212
|
+
args = (args[0],) * len(items)
|
|
1213
|
+
elif len(items) != len(args):
|
|
1214
|
+
raise ValueError(
|
|
1215
|
+
f"{where} has {len(items)} values, not {len(args)}"
|
|
1216
|
+
)
|
|
1217
|
+
for i, (h, v) in enumerate(zip(args, items, strict=True)):
|
|
1218
|
+
_check(f"{where}[{i}]", h, v)
|
|
1219
|
+
return
|
|
1220
|
+
if hint is float:
|
|
1221
|
+
ok = isinstance(value, int | float) and not isinstance(value, bool)
|
|
1222
|
+
kind = "a number"
|
|
1223
|
+
elif hint is bool:
|
|
1224
|
+
ok, kind = isinstance(value, bool), "true or false"
|
|
1225
|
+
elif hint is str:
|
|
1226
|
+
ok, kind = isinstance(value, str), "a string"
|
|
1227
|
+
else:
|
|
1228
|
+
return
|
|
1229
|
+
if not ok:
|
|
1230
|
+
raise ValueError(f"{where} is {value!r}, not {kind}")
|
|
1231
|
+
|
|
1232
|
+
|
|
1194
1233
|
def params_from_document(doc: Mapping[str, Any]) -> HeaterParams:
|
|
1195
1234
|
"""The ``HeaterParams`` ``doc`` holds.
|
|
1196
1235
|
|
|
@@ -1199,8 +1238,9 @@ def params_from_document(doc: Mapping[str, Any]) -> HeaterParams:
|
|
|
1199
1238
|
a ``ValueError``. Parts and fields this code does not know are left
|
|
1200
1239
|
out (``unknown_fields`` names them): the code reading the document
|
|
1201
1240
|
runs no rule that could use them. A schema other than
|
|
1202
|
-
``PARAMS_SCHEMA`` is a ``ValueError``, and so
|
|
1203
|
-
not an object
|
|
1241
|
+
``PARAMS_SCHEMA`` is a ``ValueError``, and so are a ``doc`` that is
|
|
1242
|
+
not an object and a value of the wrong type (a number where a list
|
|
1243
|
+
goes, a string where a number goes).
|
|
1204
1244
|
"""
|
|
1205
1245
|
if not isinstance(doc, Mapping): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
1206
1246
|
raise ValueError("a parameters document is an object")
|
|
@@ -1216,10 +1256,14 @@ def params_from_document(doc: Mapping[str, Any]) -> HeaterParams:
|
|
|
1216
1256
|
if not isinstance(given, Mapping):
|
|
1217
1257
|
raise ValueError(f"part {name!r} is not an object")
|
|
1218
1258
|
values = {}
|
|
1259
|
+
hints = get_type_hints(cls)
|
|
1219
1260
|
for f in fields(cls):
|
|
1220
1261
|
if f.name in given:
|
|
1262
|
+
_check(f"{name}.{f.name}", hints[f.name], given[f.name])
|
|
1221
1263
|
values[f.name] = given[f.name]
|
|
1222
1264
|
elif f.default is MISSING and f.default_factory is MISSING:
|
|
1223
1265
|
raise ValueError(f"{name}.{f.name} is missing")
|
|
1224
1266
|
parts[name] = cls(**values)
|
|
1225
|
-
|
|
1267
|
+
unfitted = doc.get("unfitted", ())
|
|
1268
|
+
_check("unfitted", tuple[str, ...], unfitted)
|
|
1269
|
+
return HeaterParams(**parts, unfitted=tuple(unfitted))
|
|
@@ -14,9 +14,9 @@ this module, a ``sim_live`` source:
|
|
|
14
14
|
``sim_live.compose()``:
|
|
15
15
|
|
|
16
16
|
- **the air:** the outdoor forecast projected through a
|
|
17
|
-
``location_air`` model,
|
|
18
|
-
|
|
19
|
-
span;
|
|
17
|
+
``location_air`` model, given or fitted on the recent history,
|
|
18
|
+
anchored to a fresh reading of the air beside the heater; without
|
|
19
|
+
a weather entity or a model, that reading held over the span;
|
|
20
20
|
- **the humidity:** a fresh reading;
|
|
21
21
|
- **the inlet:** the plateau of the long draws in the last 72 h, from
|
|
22
22
|
the inlet probe, the household's draws (a demand entity) and the
|
|
@@ -37,6 +37,7 @@ from the caller's configuration (``sources_from``).
|
|
|
37
37
|
import enum
|
|
38
38
|
import logging
|
|
39
39
|
import math
|
|
40
|
+
from bisect import bisect_right
|
|
40
41
|
from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence
|
|
41
42
|
from dataclasses import dataclass, field
|
|
42
43
|
from datetime import UTC, datetime, timedelta
|
|
@@ -45,7 +46,12 @@ from typing import Any, cast
|
|
|
45
46
|
from nwp500 import sim_adapters as sa
|
|
46
47
|
from nwp500.exceptions import Nwp500Error
|
|
47
48
|
from nwp500.sim import Environment
|
|
48
|
-
from nwp500.sim.location_air import
|
|
49
|
+
from nwp500.sim.location_air import (
|
|
50
|
+
FIT_LAGS_H,
|
|
51
|
+
FittedLocationAir,
|
|
52
|
+
LocationAirModel,
|
|
53
|
+
)
|
|
54
|
+
from nwp500.sim.location_air import fit as fit_air
|
|
49
55
|
from nwp500.sim.location_air import project as project_air
|
|
50
56
|
|
|
51
57
|
__author__ = "Emmanuel Levijarvi"
|
|
@@ -67,6 +73,20 @@ PUMP_ON_RPM = 10.0
|
|
|
67
73
|
PUMP_STALE = timedelta(seconds=300)
|
|
68
74
|
#: How far back the inlet's plateau looks.
|
|
69
75
|
INLET_LOOKBACK = timedelta(hours=72)
|
|
76
|
+
#: The grid the inlet probe's history is read on (``held``): the
|
|
77
|
+
#: plateau takes readings as samples, and a history keeps only changes.
|
|
78
|
+
PROBE_STEP = timedelta(seconds=10)
|
|
79
|
+
#: How far back the air beside the heater and the outdoor air are read
|
|
80
|
+
#: to fit a ``location_air`` model where none is given.
|
|
81
|
+
LOCATION_AIR_WINDOW = timedelta(days=3)
|
|
82
|
+
#: The least span of readings such a fit is made on: a day, so it sees
|
|
83
|
+
#: the outdoor air's daily swing.
|
|
84
|
+
LOCATION_AIR_MIN_SPAN = timedelta(days=1)
|
|
85
|
+
#: The least r (``FittedLocationAir.r``) such a fit is used at.
|
|
86
|
+
LOCATION_AIR_MIN_R = 0.5
|
|
87
|
+
#: The heater's own air reading is the air it stands in from this far
|
|
88
|
+
#: into a compressor run (about 2.7 °F warm before), and not while idle.
|
|
89
|
+
RUN_SETTLE = timedelta(minutes=2)
|
|
70
90
|
#: How long a request waits, seconds.
|
|
71
91
|
DEFAULT_TIMEOUT = 10.0
|
|
72
92
|
|
|
@@ -98,7 +118,8 @@ class Entities:
|
|
|
98
118
|
demand: str | None = None
|
|
99
119
|
#: The circulation pump's speed.
|
|
100
120
|
circulation_speed: str | None = None
|
|
101
|
-
#: A weather entity with an hourly forecast
|
|
121
|
+
#: A weather entity with an hourly forecast, its ``temperature``
|
|
122
|
+
#: attribute the outdoor air.
|
|
102
123
|
weather: str | None = None
|
|
103
124
|
|
|
104
125
|
|
|
@@ -106,7 +127,8 @@ class Entities:
|
|
|
106
127
|
class Sources:
|
|
107
128
|
"""Where the inputs are: Home Assistant's address and token, the
|
|
108
129
|
entities, and the model that projects the air from the outdoor
|
|
109
|
-
forecast (None:
|
|
130
|
+
forecast (None: one fitted from the recent history, as
|
|
131
|
+
``HomeAssistantEnvironment`` fits it)."""
|
|
110
132
|
|
|
111
133
|
url: str
|
|
112
134
|
token: str = field(repr=False)
|
|
@@ -454,6 +476,45 @@ def parse_forecast(doc: Any, entity: str) -> tuple[list[Reading], str | None]:
|
|
|
454
476
|
)
|
|
455
477
|
|
|
456
478
|
|
|
479
|
+
def parse_outdoor_history(
|
|
480
|
+
doc: Any, entity: str
|
|
481
|
+
) -> list[tuple[datetime, float, str | None]]:
|
|
482
|
+
"""A weather entity's outdoor air from ``/api/history/period``'s
|
|
483
|
+
answer: each row's instant, ``temperature`` attribute and the unit
|
|
484
|
+
it was recorded in (``temperature_unit``, None where the row names
|
|
485
|
+
none), in time order. Rows without a number are left out."""
|
|
486
|
+
if not isinstance(doc, list):
|
|
487
|
+
raise HomeAssistantError("not a history")
|
|
488
|
+
out: list[tuple[datetime, float, str | None]] = []
|
|
489
|
+
for series in doc:
|
|
490
|
+
if not isinstance(series, list):
|
|
491
|
+
continue
|
|
492
|
+
for r in series:
|
|
493
|
+
if not isinstance(r, Mapping):
|
|
494
|
+
continue
|
|
495
|
+
row = cast(Mapping[str, Any], r)
|
|
496
|
+
if row.get("entity_id", entity) != entity:
|
|
497
|
+
break
|
|
498
|
+
stamp = row.get("last_updated") or row.get("last_changed")
|
|
499
|
+
attributes = row.get("attributes")
|
|
500
|
+
if stamp is None or not isinstance(attributes, Mapping):
|
|
501
|
+
continue
|
|
502
|
+
a = cast(Mapping[str, Any], attributes)
|
|
503
|
+
value = _number(a.get("temperature"))
|
|
504
|
+
if value is None:
|
|
505
|
+
continue
|
|
506
|
+
unit = a.get("temperature_unit")
|
|
507
|
+
out.append(
|
|
508
|
+
(
|
|
509
|
+
_instant(stamp),
|
|
510
|
+
value,
|
|
511
|
+
unit if isinstance(unit, str) else None,
|
|
512
|
+
)
|
|
513
|
+
)
|
|
514
|
+
out.sort(key=lambda x: x[0])
|
|
515
|
+
return out
|
|
516
|
+
|
|
517
|
+
|
|
457
518
|
def to_f(value: float, unit: str | None) -> float:
|
|
458
519
|
"""A temperature in ``unit`` (°F, °C or K), in °F."""
|
|
459
520
|
u = (unit or "").strip().replace("º", "°")
|
|
@@ -490,6 +551,35 @@ def to_rpm(value: float, unit: str | None) -> float:
|
|
|
490
551
|
raise ValueError(f"{unit!r} is not a rotational speed unit")
|
|
491
552
|
|
|
492
553
|
|
|
554
|
+
def held(
|
|
555
|
+
readings: Sequence[Reading],
|
|
556
|
+
start: datetime,
|
|
557
|
+
end: datetime,
|
|
558
|
+
step: timedelta = PROBE_STEP,
|
|
559
|
+
) -> list[Reading]:
|
|
560
|
+
"""``readings``, a history of changes, as a probe sampled every
|
|
561
|
+
``step`` from ``start`` to ``end`` would read them: the value in
|
|
562
|
+
force at each instant, none before the first reading. A history
|
|
563
|
+
writes no row for a report of the value it already holds, so a
|
|
564
|
+
probe steady over a span has one reading there, or none. The grid
|
|
565
|
+
is in UTC, every ``step`` of elapsed time across a clock change."""
|
|
566
|
+
if step <= timedelta(0):
|
|
567
|
+
raise ValueError("step must be positive")
|
|
568
|
+
# In UTC: two local times in one zone compare, and step, by the wall
|
|
569
|
+
# clock, which skips or repeats an hour at a daylight-saving change.
|
|
570
|
+
rows = sorted((t.astimezone(UTC), v) for t, v in readings)
|
|
571
|
+
out: list[Reading] = []
|
|
572
|
+
i = -1
|
|
573
|
+
t, end = start.astimezone(UTC), end.astimezone(UTC)
|
|
574
|
+
while t <= end:
|
|
575
|
+
while i + 1 < len(rows) and rows[i + 1][0] <= t:
|
|
576
|
+
i += 1
|
|
577
|
+
if i >= 0:
|
|
578
|
+
out.append((t, rows[i][1]))
|
|
579
|
+
t += step
|
|
580
|
+
return out
|
|
581
|
+
|
|
582
|
+
|
|
493
583
|
def busy(*spans: Sequence[Span]) -> list[Span]:
|
|
494
584
|
"""``spans`` together, overlapping ones joined, in time order, in
|
|
495
585
|
UTC."""
|
|
@@ -756,6 +846,36 @@ class HomeAssistantClient:
|
|
|
756
846
|
unit = (await self._attributes(entity)).get("temperature_unit")
|
|
757
847
|
return hours, unit if isinstance(unit, str) else None
|
|
758
848
|
|
|
849
|
+
async def outdoor_history(
|
|
850
|
+
self, entity: str, start: datetime, end: datetime
|
|
851
|
+
) -> list[Reading]:
|
|
852
|
+
"""A weather entity's outdoor air from ``start`` to ``end``, °F,
|
|
853
|
+
each reading from the unit it was recorded in
|
|
854
|
+
(``parse_outdoor_history``), the entity's own unit where a row
|
|
855
|
+
names none. A reading in no unit ``to_f`` reads is left out."""
|
|
856
|
+
doc = await self._json(
|
|
857
|
+
"GET",
|
|
858
|
+
f"/api/history/period/{start.astimezone(UTC).isoformat()}",
|
|
859
|
+
params={
|
|
860
|
+
"filter_entity_id": entity,
|
|
861
|
+
"end_time": end.astimezone(UTC).isoformat(),
|
|
862
|
+
# Every row, with its attributes: the temperature is one.
|
|
863
|
+
"significant_changes_only": "0",
|
|
864
|
+
},
|
|
865
|
+
)
|
|
866
|
+
rows = parse_outdoor_history(doc, entity)
|
|
867
|
+
today: str | None = None
|
|
868
|
+
if any(u is None for _, _, u in rows):
|
|
869
|
+
u = (await self._attributes(entity)).get("temperature_unit")
|
|
870
|
+
today = u if isinstance(u, str) else None
|
|
871
|
+
out: list[Reading] = []
|
|
872
|
+
for t, v, u in rows:
|
|
873
|
+
try:
|
|
874
|
+
out.append((t, to_f(v, u or today)))
|
|
875
|
+
except ValueError:
|
|
876
|
+
continue
|
|
877
|
+
return out
|
|
878
|
+
|
|
759
879
|
async def _attributes(self, entity: str) -> Mapping[str, Any]:
|
|
760
880
|
doc = await self._json("GET", f"/api/states/{entity}")
|
|
761
881
|
attributes = doc.get("attributes") if isinstance(doc, dict) else None
|
|
@@ -769,11 +889,61 @@ class HomeAssistantClient:
|
|
|
769
889
|
# ── the environment source ──────────────────────────────────────────
|
|
770
890
|
|
|
771
891
|
|
|
892
|
+
def _while_running(
|
|
893
|
+
air: Sequence[Reading],
|
|
894
|
+
compressor: Sequence[tuple[datetime, str]],
|
|
895
|
+
end: datetime,
|
|
896
|
+
unknown: Sequence[Span] = (),
|
|
897
|
+
step: timedelta = timedelta(minutes=10),
|
|
898
|
+
) -> tuple[list[Reading], Reading | None]:
|
|
899
|
+
"""``air``'s value in force every ``step`` through each of
|
|
900
|
+
``compressor``'s runs (its ``on`` states) to its end, or ``end`` for
|
|
901
|
+
one still running, where that value was read ``RUN_SETTLE`` or more
|
|
902
|
+
into the run: one read earlier, or while idle, is not the air the
|
|
903
|
+
heater stands in. None inside ``unknown``, when the sensor said
|
|
904
|
+
nothing that was read. The second item is the newest reading those
|
|
905
|
+
samples rest on, at its own instant."""
|
|
906
|
+
# A row that repeats the state (an attribute's update) is not a new
|
|
907
|
+
# run; a change to anything else, ``unavailable`` included, ends one.
|
|
908
|
+
changes: list[tuple[datetime, str]] = []
|
|
909
|
+
for at, state in compressor:
|
|
910
|
+
text = state.strip().lower()
|
|
911
|
+
if not changes or changes[-1][1] != text:
|
|
912
|
+
changes.append((at, text))
|
|
913
|
+
times = [t for t, _ in air]
|
|
914
|
+
out: list[Reading] = []
|
|
915
|
+
newest: Reading | None = None
|
|
916
|
+
for i, (on_at, state) in enumerate(changes):
|
|
917
|
+
if state != "on":
|
|
918
|
+
continue
|
|
919
|
+
stop = changes[i + 1][0] if i + 1 < len(changes) else end
|
|
920
|
+
settled = on_at + RUN_SETTLE
|
|
921
|
+
t = settled
|
|
922
|
+
while t < stop:
|
|
923
|
+
k = bisect_right(times, t)
|
|
924
|
+
if (
|
|
925
|
+
k
|
|
926
|
+
and times[k - 1] >= settled
|
|
927
|
+
and not any(a <= t < b for a, b in unknown)
|
|
928
|
+
):
|
|
929
|
+
out.append((t, air[k - 1][1]))
|
|
930
|
+
if newest is None or air[k - 1][0] > newest[0]:
|
|
931
|
+
newest = air[k - 1]
|
|
932
|
+
t += step
|
|
933
|
+
return out, newest
|
|
934
|
+
|
|
935
|
+
|
|
772
936
|
class HomeAssistantEnvironment:
|
|
773
937
|
"""An ``EnvironmentSource``: the air, its humidity and the inlet from
|
|
774
938
|
Home Assistant, over ``start`` to ``until``, as of ``now`` (the
|
|
775
939
|
driver's clock). ``notes`` say what each part rests on, or why it
|
|
776
|
-
was left to the default, once ``read`` has run.
|
|
940
|
+
was left to the default, once ``read`` has run.
|
|
941
|
+
|
|
942
|
+
With a weather entity and no ``location_air`` model, ``read`` fits
|
|
943
|
+
one (``fitted_air``) on the last ``LOCATION_AIR_WINDOW``: the air
|
|
944
|
+
entity's readings, or without one the heater's own air reading
|
|
945
|
+
while its compressor runs (``heater``'s ``ambient`` and
|
|
946
|
+
``compressor``), against the weather entity's outdoor air."""
|
|
777
947
|
|
|
778
948
|
def __init__(
|
|
779
949
|
self,
|
|
@@ -784,6 +954,7 @@ class HomeAssistantEnvironment:
|
|
|
784
954
|
until: datetime,
|
|
785
955
|
now: datetime | None = None,
|
|
786
956
|
location_air: LocationAirModel | None = None,
|
|
957
|
+
heater: HeaterEntities | None = None,
|
|
787
958
|
) -> None:
|
|
788
959
|
self.client = client
|
|
789
960
|
self.entities = entities
|
|
@@ -791,7 +962,13 @@ class HomeAssistantEnvironment:
|
|
|
791
962
|
self.until = until
|
|
792
963
|
self.now = start if now is None else now
|
|
793
964
|
self.location_air = location_air
|
|
965
|
+
self.heater = heater
|
|
794
966
|
self.notes: list[str] = []
|
|
967
|
+
#: The model fitted where none was given, once ``read`` has run;
|
|
968
|
+
#: None when it was given, or none could be fitted.
|
|
969
|
+
self.fitted_air: FittedLocationAir | None = None
|
|
970
|
+
self._fit_notes: list[str] | None = None
|
|
971
|
+
self._heater_anchor: Reading | None = None
|
|
795
972
|
|
|
796
973
|
async def read(self) -> Environment:
|
|
797
974
|
"""The environment; a part that cannot be read is left to the
|
|
@@ -839,15 +1016,23 @@ class HomeAssistantEnvironment:
|
|
|
839
1016
|
async def _air(self) -> float | tuple[Reading, ...]:
|
|
840
1017
|
e = self.entities
|
|
841
1018
|
weather = e.weather
|
|
842
|
-
|
|
1019
|
+
held: Reading | None = None
|
|
843
1020
|
if e.air is not None:
|
|
844
1021
|
state = await self._fresh_state("air", e.air)
|
|
845
1022
|
if state is not None and state.value is not None:
|
|
846
1023
|
try:
|
|
847
|
-
|
|
1024
|
+
held = (state.at, to_f(state.value, state.unit))
|
|
848
1025
|
except ValueError as err:
|
|
849
1026
|
self.notes.append(f"air: {err}")
|
|
850
|
-
|
|
1027
|
+
model = self.location_air
|
|
1028
|
+
anchor = held
|
|
1029
|
+
if weather is not None and model is None:
|
|
1030
|
+
fitted = await self._fit_air(weather)
|
|
1031
|
+
if fitted is not None:
|
|
1032
|
+
model = fitted.model
|
|
1033
|
+
if anchor is None:
|
|
1034
|
+
anchor = self._heater_anchor
|
|
1035
|
+
if weather is not None and model is not None:
|
|
851
1036
|
hours = await self._guard(
|
|
852
1037
|
"outdoor forecast",
|
|
853
1038
|
lambda: self.client.forecast(weather),
|
|
@@ -861,7 +1046,7 @@ class HomeAssistantEnvironment:
|
|
|
861
1046
|
outdoor = []
|
|
862
1047
|
self.notes.append(f"outdoor forecast: {err}")
|
|
863
1048
|
air = project_air(
|
|
864
|
-
|
|
1049
|
+
model,
|
|
865
1050
|
outdoor,
|
|
866
1051
|
self.start,
|
|
867
1052
|
self.until,
|
|
@@ -871,17 +1056,140 @@ class HomeAssistantEnvironment:
|
|
|
871
1056
|
self.notes.append(
|
|
872
1057
|
"air: projected from the outdoor forecast, "
|
|
873
1058
|
+ (
|
|
874
|
-
"
|
|
875
|
-
if anchor
|
|
876
|
-
else "
|
|
1059
|
+
"unanchored"
|
|
1060
|
+
if anchor is None
|
|
1061
|
+
else "anchored to the reading beside the heater"
|
|
1062
|
+
if anchor is held
|
|
1063
|
+
else "anchored to the heater's own reading"
|
|
877
1064
|
)
|
|
878
1065
|
)
|
|
879
1066
|
return air
|
|
880
|
-
if
|
|
1067
|
+
if held is not None:
|
|
881
1068
|
self.notes.append("air: the reading beside the heater, held")
|
|
882
|
-
return
|
|
1069
|
+
return held[1]
|
|
883
1070
|
return math.nan
|
|
884
1071
|
|
|
1072
|
+
async def _fit_air(self, weather: str) -> FittedLocationAir | None:
|
|
1073
|
+
"""The ``location_air`` model fitted on the last
|
|
1074
|
+
``LOCATION_AIR_WINDOW``, or None (and a note): too little
|
|
1075
|
+
history, or a poor fit. Fitted once; a later ``read`` reuses it."""
|
|
1076
|
+
if self._fit_notes is None:
|
|
1077
|
+
before = len(self.notes)
|
|
1078
|
+
fitted = await self._guard(
|
|
1079
|
+
"air", lambda: self._fit_air_now(weather), None
|
|
1080
|
+
)
|
|
1081
|
+
self.fitted_air = cast("FittedLocationAir | None", fitted)
|
|
1082
|
+
self._fit_notes = self.notes[before:]
|
|
1083
|
+
else:
|
|
1084
|
+
self.notes.extend(self._fit_notes)
|
|
1085
|
+
return self.fitted_air
|
|
1086
|
+
|
|
1087
|
+
async def _fit_air_now(self, weather: str) -> FittedLocationAir | None:
|
|
1088
|
+
now = self.now.astimezone(UTC)
|
|
1089
|
+
since = now - LOCATION_AIR_WINDOW
|
|
1090
|
+
air = await self._air_history(since, now)
|
|
1091
|
+
if air is None:
|
|
1092
|
+
return None
|
|
1093
|
+
readings, what = air
|
|
1094
|
+
if not readings:
|
|
1095
|
+
self.notes.append(f"air: no readings of {what} to fit to")
|
|
1096
|
+
return None
|
|
1097
|
+
if not self._spans_a_day(readings, what, ""):
|
|
1098
|
+
return None
|
|
1099
|
+
lag = timedelta(hours=max(FIT_LAGS_H))
|
|
1100
|
+
# From before the first reading by the longest lag a fit tries.
|
|
1101
|
+
outdoor = await self.client.outdoor_history(weather, since - lag, now)
|
|
1102
|
+
if not outdoor:
|
|
1103
|
+
self.notes.append(f"air: no outdoor air recorded for {weather}")
|
|
1104
|
+
return None
|
|
1105
|
+
# Only readings the outdoor history covers at every lag tried:
|
|
1106
|
+
# past its ends, the model holds the outdoor air flat.
|
|
1107
|
+
first, last = outdoor[0][0] + lag, outdoor[-1][0]
|
|
1108
|
+
readings = [(t, v) for t, v in readings if first <= t <= last]
|
|
1109
|
+
if not readings or not self._spans_a_day(
|
|
1110
|
+
readings, what, " that the outdoor history covers"
|
|
1111
|
+
):
|
|
1112
|
+
if not readings:
|
|
1113
|
+
self.notes.append(
|
|
1114
|
+
f"air: no readings of {what} that the outdoor history "
|
|
1115
|
+
"covers"
|
|
1116
|
+
)
|
|
1117
|
+
return None
|
|
1118
|
+
if len({v for _, v in readings}) < 2:
|
|
1119
|
+
self.notes.append(
|
|
1120
|
+
f"air: {what} held one value, so nothing to fit to"
|
|
1121
|
+
)
|
|
1122
|
+
return None
|
|
1123
|
+
fitted = fit_air(readings, outdoor)
|
|
1124
|
+
days = LOCATION_AIR_WINDOW.total_seconds() / 86400
|
|
1125
|
+
# A steady air has no r (NaN): no fit either.
|
|
1126
|
+
if not fitted.r >= LOCATION_AIR_MIN_R:
|
|
1127
|
+
self.notes.append(
|
|
1128
|
+
f"air: {what} follows the outdoor air too loosely to "
|
|
1129
|
+
f"project it (r {fitted.r:.2f}, under {LOCATION_AIR_MIN_R})"
|
|
1130
|
+
)
|
|
1131
|
+
return None
|
|
1132
|
+
self.notes.append(
|
|
1133
|
+
f"air: the outdoor air fitted to {what} over the last {days:g} "
|
|
1134
|
+
f"days (r {fitted.r:.2f}, "
|
|
1135
|
+
f"{fitted.model.lag.total_seconds() / 3600:g} h late)"
|
|
1136
|
+
)
|
|
1137
|
+
return fitted
|
|
1138
|
+
|
|
1139
|
+
def _spans_a_day(
|
|
1140
|
+
self, readings: Sequence[Reading], what: str, covered: str
|
|
1141
|
+
) -> bool:
|
|
1142
|
+
"""Whether ``readings`` span ``LOCATION_AIR_MIN_SPAN``; a note
|
|
1143
|
+
when they don't."""
|
|
1144
|
+
span = readings[-1][0] - readings[0][0]
|
|
1145
|
+
if span >= LOCATION_AIR_MIN_SPAN:
|
|
1146
|
+
return True
|
|
1147
|
+
self.notes.append(
|
|
1148
|
+
f"air: {span.total_seconds() / 3600:.0f} h of {what}{covered}, "
|
|
1149
|
+
f"under the {LOCATION_AIR_MIN_SPAN.total_seconds() / 3600:g} h "
|
|
1150
|
+
"a fit needs"
|
|
1151
|
+
)
|
|
1152
|
+
return False
|
|
1153
|
+
|
|
1154
|
+
async def _air_history(
|
|
1155
|
+
self, since: datetime, now: datetime
|
|
1156
|
+
) -> tuple[list[Reading], str] | None:
|
|
1157
|
+
"""The air where the heater stands since ``since``, °F, and what
|
|
1158
|
+
it was read from: the air entity, or the heater's own reading
|
|
1159
|
+
every 10 minutes while its compressor runs, from ``RUN_SETTLE``
|
|
1160
|
+
into each run. None (and a note) when neither is recorded."""
|
|
1161
|
+
e, h = self.entities, self.heater
|
|
1162
|
+
if e.air is not None:
|
|
1163
|
+
got = await self.client.read_history([e.air], since, now)
|
|
1164
|
+
unit = (await self.client.state(e.air)).unit
|
|
1165
|
+
readings, _ = got.converted(e.air, unit, to_f, now)
|
|
1166
|
+
return readings, "the air beside the heater"
|
|
1167
|
+
if h is None or h.ambient is None or h.compressor is None:
|
|
1168
|
+
self.notes.append(
|
|
1169
|
+
"air: no air reading to fit the outdoor air to (an air "
|
|
1170
|
+
"entity, or the heater's own air and compressor)"
|
|
1171
|
+
)
|
|
1172
|
+
return None
|
|
1173
|
+
got = await self.client.read_history(
|
|
1174
|
+
[h.ambient, h.compressor], since, now
|
|
1175
|
+
)
|
|
1176
|
+
if got.states is None:
|
|
1177
|
+
self.notes.append(
|
|
1178
|
+
f"air: {h.compressor} not read (no states in the history)"
|
|
1179
|
+
)
|
|
1180
|
+
return None
|
|
1181
|
+
unit = (await self.client.state(h.ambient)).unit
|
|
1182
|
+
ambient, unread = got.converted(h.ambient, unit, to_f, now)
|
|
1183
|
+
samples, newest = _while_running(
|
|
1184
|
+
ambient,
|
|
1185
|
+
got.states.get(h.compressor, []),
|
|
1186
|
+
now,
|
|
1187
|
+
busy(unread, got.outages.get(h.ambient, [])),
|
|
1188
|
+
)
|
|
1189
|
+
if newest is not None and now - newest[0] <= FRESH:
|
|
1190
|
+
self._heater_anchor = newest
|
|
1191
|
+
return samples, "the heater's own air reading"
|
|
1192
|
+
|
|
885
1193
|
async def _humidity(self) -> float | None:
|
|
886
1194
|
e = self.entities
|
|
887
1195
|
if e.humidity is None:
|
|
@@ -942,7 +1250,7 @@ class HomeAssistantEnvironment:
|
|
|
942
1250
|
)
|
|
943
1251
|
# In °F, as read: no unit preference is read here.
|
|
944
1252
|
cold = sa.cold_supply_f(
|
|
945
|
-
probe,
|
|
1253
|
+
held(probe, since, now),
|
|
946
1254
|
at=now,
|
|
947
1255
|
draws=draws,
|
|
948
1256
|
circulation=busy_spans,
|