Opening a window adds heat exchange that the envelope model mistook for
poor insulation or a constant cooling bias.
windows.py adds windows as config subentries ("Add window"): a contact
sensor (optionally inverted; unknown/unavailable stays unknown, never
"closed"), its zone, the direction its outside faces (default 345°,
editable) and an opening factor. Each poll computes the share of the
interval each window was open (one change located by its timestamp, a
round trip is unknown), the wind exposure with proper circular angles
(meteorological "from" direction vs the window's facing) and a recovery
weight after closing that decays towards 0, pulling the air back
towards its pre-ventilation temperature. A bounded event log records
opening/closing, duration, temperatures before/lowest/at close/30 min
after, weather and other open windows.
While a zone's window is open, unknown or recovering, the base and
extended models skip it; a separate ventilation learner fits the
closed-window model's residual with natural exchange, wind speed, a
directional term bounded by the wind term (a leeward window still
ventilates), recovery, and optional gust excess and cross-ventilation
terms. Forecasts assume an open window closes after a configurable time
(default 15 min), then recovers. New poll outcome "ventilation".
Weather gains optional wind direction (degrees, 360 -> 0) and wind gust
sources with the same priorities and fallback; knots and ft/s accepted.
On a synthetic week with ventilation events every four hours and a
daily outdoor swing, the configured zone keeps loss/heat/bias close to
the truth and recovers the combined natural+wind effect within 15 %,
recovery within 15 % and direction within 30 %. The same data without
the window configured produces a -0.013 °C/5 min cooling bias and an
11 % inflated boiler effect.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
966 lines
52 KiB
Python
966 lines
52 KiB
Python
"""Offline HA boundary doubles; these do not validate the HA runtime itself."""
|
|
import importlib.util
|
|
import sys
|
|
import types
|
|
import unittest
|
|
import unittest.mock
|
|
from datetime import datetime, timedelta, timezone
|
|
from math import cos, exp, pi, radians, sin
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, Mock, patch
|
|
|
|
ROOT = Path(__file__).resolve().parents[1] / 'custom_components' / 'heating_mpc'
|
|
|
|
|
|
class CoordinatorBase:
|
|
@classmethod
|
|
def __class_getitem__(cls, item):
|
|
return cls
|
|
|
|
def __init__(self, hass, *args, **kwargs):
|
|
self.hass = hass
|
|
|
|
async def async_config_entry_first_refresh(self):
|
|
self.data = await self._async_update_data()
|
|
|
|
|
|
class Store:
|
|
def __init__(self, *args):
|
|
self.async_load = AsyncMock(return_value=None)
|
|
self.async_save = AsyncMock()
|
|
self.async_remove = AsyncMock()
|
|
|
|
|
|
def load_integration():
|
|
modules = {name: types.ModuleType(name) for name in (
|
|
'homeassistant', 'homeassistant.const', 'homeassistant.core',
|
|
'homeassistant.config_entries', 'homeassistant.helpers',
|
|
'homeassistant.helpers.storage', 'homeassistant.helpers.update_coordinator',
|
|
'homeassistant.helpers.entity_registry', 'homeassistant.helpers.event')}
|
|
modules['homeassistant.const'].Platform = types.SimpleNamespace(SENSOR='sensor', BINARY_SENSOR='binary_sensor')
|
|
modules['homeassistant.core'].HomeAssistant = object
|
|
modules['homeassistant.core'].Event = object
|
|
modules['homeassistant.core'].callback = lambda func: func
|
|
modules['homeassistant.helpers.event'].async_track_state_change_event = (
|
|
lambda hass, entity_ids, action: hass.track_state(entity_ids, action))
|
|
modules['homeassistant.config_entries'].ConfigEntry = object
|
|
modules['homeassistant.config_entries'].ConfigSubentry = lambda **kwargs: types.SimpleNamespace(**kwargs)
|
|
modules['homeassistant.helpers.storage'].Store = Store
|
|
modules['homeassistant.helpers.update_coordinator'].DataUpdateCoordinator = CoordinatorBase
|
|
registry = modules['homeassistant.helpers.entity_registry']
|
|
registry.async_get = lambda hass: hass.registry
|
|
registry.async_entries_for_config_entry = lambda reg, entry: reg.entries
|
|
name = 'audit_heating_mpc'
|
|
spec = importlib.util.spec_from_file_location(name, ROOT / '__init__.py', submodule_search_locations=[str(ROOT)])
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules[name] = module
|
|
with patch.dict(sys.modules, modules):
|
|
spec.loader.exec_module(module)
|
|
return module, sys.modules[name + '.coordinator']
|
|
|
|
|
|
integration, coordinator = load_integration()
|
|
|
|
|
|
class BoundaryTests(unittest.IsolatedAsyncioTestCase):
|
|
def setUp(self):
|
|
self.now = datetime.now(timezone.utc)
|
|
self.states = {}
|
|
self.hass = types.SimpleNamespace(
|
|
data={}, states=types.SimpleNamespace(get=self.states.get),
|
|
config=types.SimpleNamespace(units=types.SimpleNamespace(temperature_unit='°C')),
|
|
services=types.SimpleNamespace(async_call=Mock(side_effect=AssertionError('No service calls permitted'))),
|
|
track_state=Mock(return_value=Mock(name='unsub_relay')),
|
|
registry=types.SimpleNamespace(entries=[], async_remove=Mock()),
|
|
config_entries=types.SimpleNamespace(
|
|
async_forward_entry_setups=AsyncMock(), async_unload_platforms=AsyncMock(return_value=True),
|
|
async_reload=AsyncMock(), async_update_entry=Mock()),
|
|
)
|
|
async def execute(job):
|
|
return job()
|
|
self.hass.async_add_executor_job = execute
|
|
self.entry = types.SimpleNamespace(entry_id='entry', version=2,
|
|
data={'room_entities': ['sensor.room'], 'heater_entity': 'switch.boiler',
|
|
'climate_entity': 'climate.reference', 'outside_temperature_sources': ['sensor.outside'],
|
|
'horizon_minutes': 60}, options={}, async_on_unload=Mock(), add_update_listener=Mock())
|
|
self.set_state('sensor.room', '20', unit_of_measurement='°C')
|
|
self.set_state('sensor.outside', '5', unit_of_measurement='°C')
|
|
self.set_state('switch.boiler', 'off')
|
|
self.set_state('climate.reference', 'heat', temperature=22)
|
|
self.c = coordinator.HeatingMPCCoordinator(self.hass, self.entry)
|
|
|
|
def set_state(self, entity, value, **attrs):
|
|
self.states[entity] = types.SimpleNamespace(state=value, attributes=attrs,
|
|
last_changed=self.now - timedelta(hours=1), last_reported=self.now)
|
|
|
|
async def sample(self, seconds=300):
|
|
with patch.object(coordinator, 'datetime') as clock:
|
|
clock.now.return_value = self.now
|
|
data = await self.c._async_update_data()
|
|
self.now += timedelta(seconds=seconds)
|
|
return data
|
|
|
|
def switch(self, state, seconds_before_next_sample):
|
|
"""Change the relay between polls; `self.now` is already the next poll time."""
|
|
heater = self.states['switch.boiler']
|
|
heater.state = state
|
|
heater.last_changed = self.now - timedelta(seconds=seconds_before_next_sample)
|
|
|
|
def ready(self):
|
|
m = self.c.learners['sensor.room']
|
|
m.count, m.on_count, m.off_count = 200, 100, 100
|
|
self.c._update_counts()
|
|
|
|
async def test_learning_persistence_and_restart(self):
|
|
await self.sample()
|
|
await self.sample()
|
|
self.assertEqual(self.c.learners['sensor.room'].count, 1)
|
|
payload = self.c._store.async_save.call_args.args[0]
|
|
self.assertEqual(payload['schema_version'], 2)
|
|
new = coordinator.HeatingMPCCoordinator(self.hass, self.entry)
|
|
new._store.async_load.return_value = payload
|
|
await new.async_restore_model()
|
|
self.assertEqual(new._off_samples, 1)
|
|
self.assertIsNone(new._previous)
|
|
|
|
async def test_transition_and_round_trip_switch_are_skipped(self):
|
|
for new_state in ('on', 'off'):
|
|
self.c._previous = None
|
|
await self.sample()
|
|
self.states['switch.boiler'].state = new_state
|
|
self.states['switch.boiler'].last_changed = self.now - timedelta(seconds=60)
|
|
await self.sample()
|
|
self.assertEqual(self.c.learners['sensor.room'].count, 0)
|
|
|
|
async def test_gaps_backward_clock_and_excessive_jitter_skipped(self):
|
|
for seconds in (0, -300, 100, 420, 3600):
|
|
self.c._previous = None
|
|
await self.sample(seconds)
|
|
await self.sample()
|
|
self.assertEqual(self.c.learners['sensor.room'].count, 0)
|
|
|
|
async def test_missing_sensor_breaks_training_continuity(self):
|
|
await self.sample()
|
|
self.states['sensor.room'].state = 'unavailable'
|
|
self.assertEqual((await self.sample())['status'], 'waiting_for_sensors')
|
|
self.states['sensor.room'].state = '20'
|
|
await self.sample()
|
|
self.assertEqual(self.c.learners['sensor.room'].count, 0)
|
|
|
|
async def test_weather_fallback_skips_transition(self):
|
|
await self.sample()
|
|
self.c.config['outside_temperature_sources'].append('sensor.alternate')
|
|
self.set_state('sensor.alternate', '7', unit_of_measurement='°C')
|
|
self.states['sensor.outside'].state = 'unavailable'
|
|
data = await self.sample()
|
|
self.assertEqual(data['outside_temperature'], 7)
|
|
self.assertEqual(data['samples'], 0)
|
|
|
|
async def test_outlier_does_not_count_towards_readiness(self):
|
|
await self.sample()
|
|
self.states['sensor.room'].state = '25'
|
|
data = await self.sample()
|
|
self.assertEqual(data['samples'], 0)
|
|
self.assertEqual(data['off_samples'], 0)
|
|
|
|
async def test_fahrenheit_target_and_manual_off(self):
|
|
self.ready()
|
|
self.hass.config.units.temperature_unit = '°F'
|
|
self.states['climate.reference'].attributes['temperature'] = 71.6
|
|
data = await self.sample()
|
|
self.assertEqual(data['target'], 22)
|
|
self.assertEqual(data['status'], 'ready')
|
|
self.states['climate.reference'].state = 'off'
|
|
data = await self.sample()
|
|
self.assertEqual(data['status'], 'paused')
|
|
self.assertIsNone(data['proposal'])
|
|
self.assertEqual(self.states['climate.reference'].attributes['temperature'], 71.6)
|
|
self.hass.services.async_call.assert_not_called()
|
|
|
|
async def test_invalid_target_and_optimizer_failure(self):
|
|
self.ready()
|
|
self.states['climate.reference'].attributes['temperature'] = 'nan'
|
|
self.assertEqual((await self.sample())['status'], 'waiting_for_target')
|
|
self.states['climate.reference'].attributes['temperature'] = 22
|
|
with patch.object(coordinator, 'optimize', side_effect=RuntimeError('test')):
|
|
data = await self.sample()
|
|
self.assertEqual(data['status'], 'prediction_error')
|
|
self.assertIsNone(data['proposal'])
|
|
|
|
async def test_corrupt_storage_and_legacy_counters(self):
|
|
for payload in ([], 'broken', {'schema_version': 999}, {'learners': []},
|
|
{'on_samples': 'bad', 'off_samples': float('inf')}):
|
|
self.c._store.async_load.return_value = payload
|
|
await self.c.async_restore_model()
|
|
self.assertEqual(self.c._on_samples, 0)
|
|
old = self.c.learners['sensor.room'].as_dict()
|
|
old.update(count=500)
|
|
old.pop('on_count'); old.pop('off_count')
|
|
self.c._store.async_load.return_value = {'learners': {'sensor.room': old}, 'on_samples': 250}
|
|
await self.c.async_restore_model()
|
|
self.assertEqual(self.c.learners['sensor.room'].count, 500)
|
|
self.assertEqual(self.c._on_samples, 0)
|
|
|
|
async def test_replaced_heater_invalidates_model(self):
|
|
self.c._store.async_load.return_value = {'heater_entity': 'switch.other',
|
|
'learners': {'sensor.room': {'count': 999}}}
|
|
await self.c.async_restore_model()
|
|
self.assertEqual(self.c.learners['sensor.room'].count, 0)
|
|
|
|
async def test_setup_reload_unload_and_registry_cleanup(self):
|
|
obsolete = types.SimpleNamespace(platform='heating_mpc', unique_id='entry_sensor.old_predicted_60min', entity_id='sensor.old_forecast')
|
|
kept = types.SimpleNamespace(platform='heating_mpc', unique_id='entry_sensor.room_predicted_60min', entity_id='sensor.forecast')
|
|
self.hass.registry.entries = [obsolete, kept]
|
|
self.assertTrue(await integration.async_setup_entry(self.hass, self.entry))
|
|
self.hass.track_state.assert_called_once_with(['switch.boiler'], unittest.mock.ANY)
|
|
unsub = self.hass.track_state.return_value
|
|
self.entry.async_on_unload.assert_any_call(unsub)
|
|
unsub.assert_not_called()
|
|
self.hass.registry.async_remove.assert_called_once_with('sensor.old_forecast')
|
|
await integration.async_reload_entry(self.hass, self.entry)
|
|
self.hass.config_entries.async_reload.assert_awaited_once_with('entry')
|
|
self.hass.config_entries.async_unload_platforms.return_value = False
|
|
self.assertFalse(await integration.async_unload_entry(self.hass, self.entry))
|
|
self.assertIn('entry', self.hass.data['heating_mpc'])
|
|
self.hass.config_entries.async_unload_platforms.return_value = True
|
|
self.assertTrue(await integration.async_unload_entry(self.hass, self.entry))
|
|
self.assertNotIn('entry', self.hass.data['heating_mpc'])
|
|
self.hass.services.async_call.assert_not_called()
|
|
|
|
async def test_failed_platform_setup_removes_coordinator(self):
|
|
self.hass.config_entries.async_forward_entry_setups.side_effect = RuntimeError('test')
|
|
with self.assertRaises(RuntimeError):
|
|
await integration.async_setup_entry(self.hass, self.entry)
|
|
self.assertNotIn('entry', self.hass.data['heating_mpc'])
|
|
self.hass.track_state.return_value.assert_called_once_with()
|
|
|
|
async def test_uninstall_removes_storage(self):
|
|
store = Store()
|
|
with patch.object(integration, 'Store', return_value=store):
|
|
await integration.async_remove_entry(self.hass, self.entry)
|
|
store.async_remove.assert_awaited_once()
|
|
|
|
async def test_config_migration_preserves_options_precedence(self):
|
|
self.entry.version = 1
|
|
self.entry.data['outside_entity'] = 'sensor.old'
|
|
self.entry.options = {'outside_entity': 'sensor.override'}
|
|
self.assertTrue(await integration.async_migrate_entry(self.hass, self.entry))
|
|
kwargs = self.hass.config_entries.async_update_entry.call_args.kwargs
|
|
self.assertEqual(kwargs['options']['outside_temperature_sources'], ['sensor.override'])
|
|
self.assertEqual(kwargs['data']['outside_temperature_sources'], ['sensor.outside'])
|
|
self.assertNotIn('outside_entity', kwargs['data'])
|
|
self.assertEqual(kwargs['version'], 2)
|
|
self.entry.version = 4
|
|
self.assertFalse(await integration.async_migrate_entry(self.hass, self.entry))
|
|
|
|
async def test_changed_inputs_during_optimization_discard_result(self):
|
|
self.ready()
|
|
async def changed(job):
|
|
output = job()
|
|
self.set_state('climate.reference', 'off', temperature=22)
|
|
return output
|
|
self.hass.async_add_executor_job = changed
|
|
data = await self.sample()
|
|
self.assertEqual(data['status'], 'inputs_changed')
|
|
self.assertIsNone(data['proposal'])
|
|
|
|
async def test_invalid_stored_comfort_weights_fall_back_to_one(self):
|
|
for weight in (0, -1, 'bad', float('nan'), float('inf')):
|
|
self.entry.data['weights'] = {'sensor.room': weight}
|
|
self.c = coordinator.HeatingMPCCoordinator(self.hass, self.entry)
|
|
self.assertEqual(self.c.zones['sensor.room'].comfort_weight, 1.0)
|
|
|
|
async def test_added_room_needs_its_own_excitation_evidence(self):
|
|
model = coordinator.Learner(count=500, on_count=250, off_count=250)
|
|
self.entry.options['room_entities'] = ['sensor.room', 'sensor.new']
|
|
self.c = coordinator.HeatingMPCCoordinator(self.hass, self.entry)
|
|
self.c._store.async_load.return_value = {'learners': {'sensor.room': model.as_dict()}}
|
|
await self.c.async_restore_model()
|
|
self.assertEqual(self.c.learners['sensor.room'].count, 500)
|
|
self.assertEqual(self.c._on_samples, 0)
|
|
self.assertEqual(self.c._off_samples, 0)
|
|
|
|
async def test_changed_inputs_during_save_discard_result(self):
|
|
self.ready()
|
|
await self.sample()
|
|
async def changed(payload):
|
|
self.set_state('climate.reference', 'off', temperature=22)
|
|
self.c._store.async_save.side_effect = changed
|
|
data = await self.sample()
|
|
self.assertEqual(data['status'], 'inputs_changed')
|
|
self.assertIsNone(data['proposal'])
|
|
|
|
async def test_stale_room_pauses_and_recovery_skips_gap(self):
|
|
await self.sample()
|
|
self.states['sensor.room'].last_reported = self.now - timedelta(minutes=31)
|
|
data = await self.sample()
|
|
self.assertEqual(data['status'], 'waiting_for_sensors')
|
|
self.assertEqual(data['input_quality']['zones']['sensor.room']['sensors']['sensor.room']['status'], 'stale')
|
|
self.assertIsNone(data['proposal'])
|
|
self.set_state('sensor.room', '20', unit_of_measurement='°C')
|
|
data = await self.sample()
|
|
self.assertEqual(data['samples'], 0)
|
|
self.assertEqual(data['status'], 'learning')
|
|
|
|
async def test_stale_weather_fallback_and_optional_outage(self):
|
|
self.c.config['outside_temperature_sources'].append('sensor.alternate')
|
|
self.set_state('sensor.alternate', '7', unit_of_measurement='°C')
|
|
self.states['sensor.outside'].last_reported = self.now - timedelta(minutes=121)
|
|
self.c.config['outside_wind_sources'] = ['sensor.missing']
|
|
data = await self.sample()
|
|
self.assertEqual(data['outside_temperature'], 7)
|
|
self.assertEqual(data['status'], 'learning')
|
|
quality = data['input_quality']['weather']
|
|
self.assertEqual(quality['temperature']['sensor.outside']['status'], 'stale')
|
|
self.assertEqual(quality['wind']['sensor.missing']['status'], 'missing')
|
|
|
|
async def test_fresh_unchanged_reading_is_valid(self):
|
|
self.states['sensor.room'].last_changed = self.now - timedelta(days=2)
|
|
self.assertEqual((await self.sample())['status'], 'learning')
|
|
|
|
async def test_configured_age_limit_applied(self):
|
|
self.c.config['room_max_age_minutes'] = 5
|
|
self.states['sensor.room'].last_reported = self.now - timedelta(minutes=6)
|
|
self.assertEqual((await self.sample())['status'], 'waiting_for_sensors')
|
|
|
|
async def test_invalid_unit_diagnosed_by_metric(self):
|
|
self.states['sensor.outside'].attributes['unit_of_measurement'] = 'km/h'
|
|
data = await self.sample()
|
|
self.assertEqual(data['input_quality']['weather']['temperature']['sensor.outside']['status'],
|
|
'invalid_value_or_unit')
|
|
|
|
async def test_lag_is_continuous_across_relay_transitions(self):
|
|
advance = coordinator.advance_lag
|
|
await self.sample()
|
|
lag = self.c._lag['lag']
|
|
self.switch('on', 150)
|
|
data = await self.sample()
|
|
lag = advance(advance(lag, False, 150), True, 150)
|
|
self.assertEqual(data['sample_result'], 'relay_changed')
|
|
self.assertAlmostEqual(self.c._lag['lag'], lag)
|
|
self.assertLess(lag, 0.2) # radiators have only started warming, not 1.0
|
|
data = await self.sample()
|
|
lag = advance(lag, True, 300)
|
|
self.assertEqual(data['sample_result'], 'learned')
|
|
self.assertAlmostEqual(self.c._lag['lag'], lag)
|
|
self.switch('off', 100)
|
|
await self.sample()
|
|
lag = advance(advance(lag, True, 200), False, 100)
|
|
self.assertAlmostEqual(self.c._lag['lag'], lag)
|
|
self.assertGreater(lag, 0.3) # residual radiator heat is kept, not 0.0
|
|
|
|
async def test_prediction_starts_from_tracked_lag(self):
|
|
self.ready()
|
|
self.set_state('switch.boiler', 'on')
|
|
await self.sample()
|
|
self.switch('off', 60)
|
|
with patch.object(coordinator, 'optimize', wraps=coordinator.optimize) as spy:
|
|
data = await self.sample()
|
|
self.assertEqual(data['status'], 'ready')
|
|
self.assertAlmostEqual(spy.call_args.args[2], self.c._lag['lag'])
|
|
self.assertGreater(spy.call_args.args[2], 0.9)
|
|
|
|
async def test_round_trip_between_polls_restarts_lag_estimate(self):
|
|
await self.sample()
|
|
self.switch('off', 60) # off -> on -> off; only the last change is visible
|
|
data = await self.sample()
|
|
self.assertEqual(data['sample_result'], 'relay_changed')
|
|
self.assertEqual(data['heat_lag_uncertainty'], round(exp(-60 / 1200) / 2, 3))
|
|
data = await self.sample()
|
|
self.assertEqual(data['sample_result'], 'lag_uncertain')
|
|
self.assertEqual(self.c.learners['sensor.room'].count, 0)
|
|
|
|
async def test_learning_waits_until_lag_settles_after_restart(self):
|
|
self.states['switch.boiler'].last_changed = self.now # HA restart resets last_changed
|
|
results = []
|
|
for _ in range(8):
|
|
for entity in ('sensor.room', 'sensor.outside'):
|
|
self.states[entity].last_reported = self.now
|
|
results.append((await self.sample())['sample_result'])
|
|
self.assertEqual(results[0], 'first_sample')
|
|
self.assertEqual(results[1:7], ['lag_uncertain'] * 6)
|
|
self.assertEqual(results[7], 'learned')
|
|
|
|
async def test_room_outage_keeps_relay_lag_tracking(self):
|
|
await self.sample()
|
|
self.switch('on', 150)
|
|
self.states['sensor.room'].state = 'unavailable'
|
|
data = await self.sample()
|
|
self.assertEqual(data['status'], 'waiting_for_sensors')
|
|
self.assertLess(data['heat_lag'], 0.2)
|
|
|
|
async def test_relay_outage_restarts_lag_estimate(self):
|
|
await self.sample()
|
|
self.states['switch.boiler'].state = 'unavailable'
|
|
data = await self.sample()
|
|
self.assertNotIn('heat_lag', data)
|
|
self.switch('off', 30)
|
|
data = await self.sample()
|
|
self.assertEqual(data['sample_result'], 'first_sample')
|
|
self.assertGreater(data['heat_lag_uncertainty'], 0.4)
|
|
|
|
async def test_diagnostic_input_changes_do_not_discard_prediction(self):
|
|
self.ready()
|
|
self.c.config['outside_pressure_sources'] = ['sensor.pressure']
|
|
self.set_state('sensor.pressure', '1010', unit_of_measurement='hPa')
|
|
self.set_state('climate.reference', 'heat', temperature=22, current_temperature=20.0)
|
|
async def changed(job):
|
|
output = job()
|
|
self.set_state('sensor.pressure', '1011', unit_of_measurement='hPa')
|
|
self.set_state('climate.reference', 'heat', temperature=22, current_temperature=20.1)
|
|
return output
|
|
self.hass.async_add_executor_job = changed
|
|
self.assertEqual((await self.sample())['status'], 'ready')
|
|
|
|
async def test_heat_gain_identified_with_mid_interval_switching(self):
|
|
"""Synthetic plant with radiator lag, cycled by a hysteresis thermostat."""
|
|
a, b, c = 0.004, 0.12, 0.0005
|
|
advance = coordinator.advance_lag
|
|
self.states['climate.reference'].state = 'off' # learn only
|
|
temp, lag, on = 21.0, 0.0, False
|
|
for k in range(1500):
|
|
outside = 5 + 5 * sin(2 * pi * k / 288)
|
|
for entity, value in (('sensor.room', temp), ('sensor.outside', outside)):
|
|
self.states[entity].state = f'{value:.5f}'
|
|
self.states[entity].last_reported = self.now
|
|
data = await self.sample()
|
|
command = temp < 20.7 or (on and temp < 21.3)
|
|
if command != on: # relay switches 120 s after this poll
|
|
self.switch('on' if command else 'off', 180)
|
|
lag = advance(advance(lag, on, 120), command, 180)
|
|
else:
|
|
lag = advance(lag, on, 300)
|
|
on = command
|
|
temp += a * (outside - temp) + b * lag + c
|
|
model = self.c.learners['sensor.room']
|
|
self.assertGreater(model.on_count, 100)
|
|
self.assertAlmostEqual(model.theta[1], b, delta=0.1 * b)
|
|
self.assertAlmostEqual(model.theta[0], a, delta=0.25 * a)
|
|
# Walk-forward replay with the real relay history beats persistence clearly.
|
|
accuracy = data['forecast_accuracy'][60]
|
|
self.assertGreater(accuracy['samples'], 1400)
|
|
self.assertLess(accuracy['mae'], 0.06)
|
|
self.assertGreater(accuracy['skill'], 0.85)
|
|
|
|
async def test_several_relay_changes_between_polls_are_integrated(self):
|
|
advance = coordinator.advance_lag
|
|
await self.sample()
|
|
lag, uncertainty = self.c._lag['lag'], self.c._lag['uncertainty']
|
|
start = self.now - timedelta(seconds=300)
|
|
# The listener saw on at +60 s and off at +200 s; the state reads off again.
|
|
self.c.record_relay_state(start + timedelta(seconds=60), 'on')
|
|
self.c.record_relay_state(start + timedelta(seconds=200), 'off')
|
|
self.switch('off', 100)
|
|
data = await self.sample()
|
|
lag = advance(advance(advance(lag, False, 60), True, 140), False, 100)
|
|
self.assertEqual(data['sample_result'], 'relay_changed')
|
|
self.assertAlmostEqual(self.c._lag['lag'], lag)
|
|
self.assertAlmostEqual(self.c._lag['uncertainty'], uncertainty * exp(-300 / 1200))
|
|
self.assertEqual(len(self.c._relay_events), 0)
|
|
self.assertEqual((await self.sample())['sample_result'], 'learned')
|
|
|
|
async def test_queued_relay_event_is_reconciled_from_current_state(self):
|
|
advance = coordinator.advance_lag
|
|
await self.sample()
|
|
lag = self.c._lag['lag']
|
|
start = self.now - timedelta(seconds=300)
|
|
# Only the first change reached the listener before this poll.
|
|
self.c.record_relay_state(start + timedelta(seconds=60), 'on')
|
|
self.switch('off', 100)
|
|
await self.sample()
|
|
lag = advance(advance(advance(lag, False, 60), True, 140), False, 100)
|
|
self.assertAlmostEqual(self.c._lag['lag'], lag)
|
|
# The late event for the change already accounted for is ignored next time.
|
|
self.c.record_relay_state(start + timedelta(seconds=200), 'off')
|
|
self.assertEqual((await self.sample())['sample_result'], 'learned')
|
|
|
|
async def test_relay_outage_or_dropped_events_restart_lag_estimate(self):
|
|
for events in ((('unavailable', 60), ('off', 120)), ((None, 0),)):
|
|
with self.subTest(events=events):
|
|
self.c._lag = self.c._previous = None
|
|
await self.sample()
|
|
start = self.now - timedelta(seconds=300)
|
|
if events[0][0] is None:
|
|
self.c._relay_events_dropped = True
|
|
for state, offset in events:
|
|
if state:
|
|
self.c.record_relay_state(start + timedelta(seconds=offset), state)
|
|
self.switch('off', 180)
|
|
data = await self.sample()
|
|
self.assertEqual(data['sample_result'], 'relay_changed')
|
|
self.assertAlmostEqual(data['heat_lag_uncertainty'], round(exp(-180 / 1200) / 2, 3))
|
|
self.assertFalse(self.c._relay_events_dropped)
|
|
|
|
async def test_relay_listener_records_only_state_changes(self):
|
|
def event(old, new, when):
|
|
return types.SimpleNamespace(time_fired=when, data={
|
|
'old_state': old and types.SimpleNamespace(state=old),
|
|
'new_state': new and types.SimpleNamespace(state=new, last_changed=when)})
|
|
self.c.async_relay_changed(event('off', 'on', self.now))
|
|
self.c.async_relay_changed(event('on', 'on', self.now)) # attribute-only update
|
|
self.c.async_relay_changed(event('on', None, self.now)) # entity removed
|
|
self.assertEqual(list(self.c._relay_events), [(self.now, 'on'), (self.now, 'removed')])
|
|
for _ in range(coordinator.MAX_RELAY_EVENTS):
|
|
self.c.record_relay_state(self.now, 'on')
|
|
self.assertTrue(self.c._relay_events_dropped)
|
|
|
|
async def test_removed_room_model_is_kept_and_restored(self):
|
|
old = coordinator.Learner(count=500, on_count=250, off_count=250)
|
|
self.c._store.async_load.return_value = {'schema_version': 2, 'learners': {
|
|
'sensor.room': coordinator.Learner(count=7).as_dict(), 'sensor.gone': old.as_dict()}}
|
|
await self.c.async_restore_model()
|
|
self.assertEqual(self.c.learners['sensor.room'].count, 7)
|
|
await self.sample()
|
|
self.assertEqual((await self.sample())['retired_zone_models'], ['sensor.gone'])
|
|
payload = self.c._store.async_save.call_args.args[0]
|
|
self.assertEqual(set(payload['learners']), {'sensor.room'})
|
|
self.assertEqual(payload['retired_learners']['sensor.gone']['count'], 500)
|
|
self.entry.options['room_entities'] = ['sensor.room', 'sensor.gone']
|
|
new = coordinator.HeatingMPCCoordinator(self.hass, self.entry)
|
|
new._store.async_load.return_value = payload
|
|
await new.async_restore_model()
|
|
self.assertEqual(new.learners['sensor.gone'].count, 500)
|
|
self.assertEqual(new.learners['sensor.room'].count, 8)
|
|
self.assertEqual(new._retired, {})
|
|
|
|
async def test_retired_room_models_are_bounded_and_validated(self):
|
|
retired = {f'sensor.old{i}': coordinator.Learner(count=i + 1).as_dict() for i in range(30)}
|
|
retired.update({'sensor.bad': 'broken', 'sensor.empty': coordinator.Learner().as_dict()})
|
|
self.c._store.async_load.return_value = {'retired_learners': retired}
|
|
await self.c.async_restore_model()
|
|
self.assertEqual(list(self.c._retired), [f'sensor.old{i}' for i in range(6, 30)])
|
|
|
|
async def test_report_gap_maximum_is_exposed_per_sensor(self):
|
|
await self.sample(2400) # the room then stays silent for 40 minutes
|
|
self.states['sensor.outside'].last_reported = self.now
|
|
data = await self.sample()
|
|
room = data['input_quality']['zones']['sensor.room']['sensors']['sensor.room']
|
|
self.assertEqual((room['status'], room['max_age_seconds_24h']), ('stale', 2400))
|
|
self.states['sensor.room'].last_reported = self.now
|
|
self.states['sensor.outside'].last_reported = self.now
|
|
data = await self.sample()
|
|
room = data['input_quality']['zones']['sensor.room']['sensors']['sensor.room']
|
|
self.assertEqual((room['status'], room['age_seconds'], room['max_age_seconds_24h']),
|
|
('valid', 0, 2400))
|
|
weather = data['input_quality']['weather']['temperature']['sensor.outside']
|
|
self.assertEqual(weather['max_age_seconds_24h'], 0) # kept reporting; tracked separately
|
|
|
|
def two_rooms(self, keep_last=False):
|
|
self.entry.data['room_entities'] = ['sensor.room', 'sensor.bedroom']
|
|
self.entry.data['weights'] = {'sensor.room': 3, 'sensor.bedroom': 1}
|
|
self.entry.data['room_stale_use_last_value'] = keep_last
|
|
self.set_state('sensor.bedroom', '18', unit_of_measurement='°C')
|
|
self.c = coordinator.HeatingMPCCoordinator(self.hass, self.entry)
|
|
|
|
def fresh(self, *entities):
|
|
for entity in entities or ('sensor.room', 'sensor.bedroom', 'sensor.outside'):
|
|
self.states[entity].last_reported = self.now
|
|
|
|
async def test_stale_room_is_left_out_and_other_rooms_continue(self):
|
|
self.two_rooms()
|
|
await self.sample()
|
|
self.fresh('sensor.room', 'sensor.outside')
|
|
self.states['sensor.bedroom'].last_reported = self.now - timedelta(minutes=31)
|
|
data = await self.sample()
|
|
self.assertEqual(data['status'], 'learning')
|
|
self.assertEqual(data['excluded_zones'], ['sensor.bedroom'])
|
|
zones = data['input_quality']['zones']
|
|
bedroom = zones['sensor.bedroom']['sensors']['sensor.bedroom']
|
|
self.assertEqual((bedroom['status'], bedroom['used'], zones['sensor.bedroom']['used']),
|
|
('stale', False, False))
|
|
self.assertTrue(zones['sensor.room']['used'])
|
|
self.assertEqual((data['weighted_temperature'], data['weight_coverage']), (20, 0.75))
|
|
self.assertEqual(data['sample_result'], 'learned')
|
|
counts = lambda: (self.c.learners['sensor.room'].count, self.c.learners['sensor.bedroom'].count)
|
|
self.assertEqual(counts(), (1, 0))
|
|
self.fresh() # bedroom reports again: no learning across its gap
|
|
data = await self.sample()
|
|
self.assertEqual((data['excluded_zones'], data['weight_coverage']), ([], 1.0))
|
|
self.assertEqual(data['weighted_temperature'], 19.5)
|
|
self.assertEqual(counts(), (2, 0))
|
|
self.fresh()
|
|
await self.sample()
|
|
self.assertEqual(counts(), (3, 1))
|
|
|
|
async def test_left_out_room_is_not_used_for_prediction(self):
|
|
self.two_rooms()
|
|
model = self.c.learners['sensor.room'] # the bedroom stays untrained
|
|
model.count, model.on_count, model.off_count = 200, 100, 100
|
|
self.states['sensor.bedroom'].state = 'unavailable'
|
|
with patch.object(coordinator, 'optimize', wraps=coordinator.optimize) as spy:
|
|
data = await self.sample()
|
|
self.assertEqual(data['status'], 'ready')
|
|
temps, weights, learners = (spy.call_args.args[i] for i in (0, 4, 5))
|
|
self.assertEqual((set(temps), weights, set(learners)),
|
|
({'sensor.room'}, {'sensor.room': 3.0}, {'sensor.room'}))
|
|
self.assertEqual(set(data['prediction_60m']), {'sensor.room'})
|
|
|
|
async def test_all_rooms_unusable_still_pauses(self):
|
|
self.two_rooms()
|
|
for room in ('sensor.room', 'sensor.bedroom'):
|
|
self.states[room].last_reported = self.now - timedelta(minutes=31)
|
|
data = await self.sample()
|
|
self.assertEqual(data['status'], 'waiting_for_sensors')
|
|
self.assertEqual(data['excluded_zones'], ['sensor.room', 'sensor.bedroom'])
|
|
self.assertIsNone(data['proposal'])
|
|
|
|
async def test_keep_last_value_option_uses_stale_room_for_up_to_a_day(self):
|
|
self.two_rooms(keep_last=True)
|
|
self.states['sensor.bedroom'].last_reported = self.now - timedelta(hours=2)
|
|
await self.sample()
|
|
self.fresh('sensor.room', 'sensor.outside')
|
|
data = await self.sample()
|
|
bedroom = data['input_quality']['zones']['sensor.bedroom']['sensors']['sensor.bedroom']
|
|
self.assertEqual((bedroom['status'], bedroom['used']), ('stale', True))
|
|
self.assertEqual((data['excluded_zones'], data['temperatures']['sensor.bedroom']), ([], 18))
|
|
self.assertEqual(self.c.learners['sensor.bedroom'].count, 1)
|
|
for state, age in (('18', timedelta(hours=24, seconds=1)), ('unavailable', timedelta(0))):
|
|
with self.subTest(state=state):
|
|
self.fresh('sensor.room', 'sensor.outside')
|
|
self.states['sensor.bedroom'].state = state
|
|
self.states['sensor.bedroom'].last_reported = self.now - age
|
|
data = await self.sample()
|
|
self.assertEqual(data['excluded_zones'], ['sensor.bedroom'])
|
|
self.assertEqual(data['status'], 'learning')
|
|
|
|
async def test_poll_outcomes_and_rejections_are_counted(self):
|
|
await self.sample()
|
|
self.states['sensor.room'].state = '25' # implausible 5 °C jump in five minutes
|
|
data = await self.sample()
|
|
self.assertEqual(data['sample_result'], 'partly_rejected')
|
|
self.assertEqual(data['models']['sensor.room']['rejected_samples'], 1)
|
|
self.fresh('sensor.room', 'sensor.outside')
|
|
data = await self.sample()
|
|
self.assertEqual(data['sample_results_since_start'],
|
|
{'first_sample': 1, 'partly_rejected': 1, 'learned': 1})
|
|
|
|
async def test_restores_model_saved_by_v021(self):
|
|
# Exact shape written by 0.2.1: no schema version, per-room counts or new keys.
|
|
legacy = {'learners': {'sensor.room': {
|
|
'theta': [0.004, 0.11, 0.0005], 'p': [[1e-4, 0, 0], [0, 0.01, 0], [0, 0, 1e-3]],
|
|
'count': 900, 'error_sum': 27.0}},
|
|
'on_samples': 300, 'off_samples': 600, 'heater_entity': 'switch.boiler',
|
|
'outside_source': 'sensor.outside'}
|
|
self.c._store.async_load.return_value = legacy
|
|
await self.c.async_restore_model()
|
|
model = self.c.learners['sensor.room']
|
|
self.assertEqual((model.theta, model.count), ([0.004, 0.11, 0.0005], 900))
|
|
self.assertEqual((model.on_count, model.off_count, model.rejected_count), (0, 0, 0))
|
|
|
|
async def test_saved_model_stays_readable_by_v021(self):
|
|
"""Rollback contract: the keys and types 0.2.1's restore code reads."""
|
|
await self.sample()
|
|
await self.sample()
|
|
payload = self.c._store.async_save.call_args.args[0]
|
|
self.assertIsInstance(payload['heater_entity'], str)
|
|
for key in ('on_samples', 'off_samples'):
|
|
self.assertIs(type(payload[key]), int)
|
|
for data in payload['learners'].values():
|
|
self.assertEqual(len(data['theta']), 3)
|
|
self.assertTrue(all(len(row) == 3 for row in data['p']))
|
|
self.assertIs(type(data['count']), int)
|
|
float(data['error_sum'])
|
|
[float(v) for v in data['theta'] + [v for row in data['p'] for v in row]]
|
|
|
|
async def test_forecasts_are_scored_saved_and_restored(self):
|
|
for _ in range(5):
|
|
self.fresh('sensor.room', 'sensor.outside')
|
|
data = await self.sample()
|
|
accuracy = data['forecast_accuracy']
|
|
self.assertEqual((accuracy[15]['samples'], accuracy[30]['samples'], accuracy[120]['samples']),
|
|
(2, 0, 0))
|
|
# The default parameters predict cooling while the room held 20 °C.
|
|
self.assertLess(accuracy[15]['rooms']['sensor.room']['bias'], 0)
|
|
self.assertEqual(accuracy[15]['persistence_mae'], 0)
|
|
payload = self.c._store.async_save.call_args.args[0]
|
|
restored = coordinator.HeatingMPCCoordinator(self.hass, self.entry)
|
|
restored._store.async_load.return_value = payload
|
|
await restored.async_restore_model()
|
|
self.assertEqual(restored._evaluator.summary(['sensor.room']), self.c._evaluator.summary(['sensor.room']))
|
|
# A poll without a usable relay state breaks the replay window.
|
|
self.states['switch.boiler'].state = 'unavailable'
|
|
self.fresh('sensor.room', 'sensor.outside')
|
|
await self.sample()
|
|
self.states['switch.boiler'].state = 'off'
|
|
for _ in range(3):
|
|
self.fresh('sensor.room', 'sensor.outside')
|
|
data = await self.sample()
|
|
self.assertEqual(data['forecast_accuracy'][15]['samples'], 2)
|
|
self.fresh('sensor.room', 'sensor.outside')
|
|
self.assertEqual((await self.sample())['forecast_accuracy'][15]['samples'], 3)
|
|
|
|
async def test_learning_progress_and_waiting_result(self):
|
|
data = await self.sample()
|
|
self.assertEqual((data['sample_result'], data['learning_progress']), ('first_sample', 0))
|
|
self.ready()
|
|
self.fresh('sensor.room', 'sensor.outside')
|
|
self.assertEqual((await self.sample())['learning_progress'], 100)
|
|
self.states['sensor.outside'].state = 'unavailable'
|
|
data = await self.sample()
|
|
self.assertEqual(data['sample_result'], 'waiting_for_inputs')
|
|
self.assertEqual(data['sample_results_since_start']['waiting_for_inputs'], 1)
|
|
|
|
def multi_sensor_zone(self):
|
|
self.entry.subentries = {'s1': types.SimpleNamespace(subentry_id='s1', subentry_type='zone', data={
|
|
'key': 'zone_living', 'name': 'Living', 'sensors': ['sensor.room', 'sensor.second'],
|
|
'sensor_weights': {'sensor.room': 3, 'sensor.second': 1}, 'comfort_weight': 2,
|
|
'target_offset': 0.5})}
|
|
self.set_state('sensor.second', '22', unit_of_measurement='°C')
|
|
self.c = coordinator.HeatingMPCCoordinator(self.hass, self.entry)
|
|
|
|
async def test_multi_sensor_zone_weights_sensors_and_skips_composition_changes(self):
|
|
self.multi_sensor_zone()
|
|
data = await self.sample()
|
|
self.assertEqual((data['temperatures'], data['zone_names']), ({'zone_living': 20.5}, {'zone_living': 'Living'}))
|
|
self.fresh('sensor.room', 'sensor.second', 'sensor.outside')
|
|
self.assertEqual((await self.sample())['sample_result'], 'learned')
|
|
self.states['sensor.second'].state = 'unavailable'
|
|
self.fresh('sensor.room', 'sensor.outside')
|
|
data = await self.sample()
|
|
self.assertEqual(data['temperatures'], {'zone_living': 20})
|
|
# The mean jumped by the sensors' offset, not by a real change: not learned.
|
|
self.assertEqual(data['sample_result'], 'sensors_changed')
|
|
self.assertEqual(self.c.learners['zone_living'].count, 1)
|
|
quality = data['input_quality']['zones']['zone_living']
|
|
self.assertEqual((quality['used'], quality['sensors']['sensor.second']['status']), (True, 'unavailable'))
|
|
self.fresh('sensor.room', 'sensor.outside')
|
|
self.assertEqual((await self.sample())['sample_result'], 'learned')
|
|
|
|
async def test_zone_comfort_weight_and_target_offset_reach_optimizer(self):
|
|
self.multi_sensor_zone()
|
|
model = self.c.learners['zone_living']
|
|
model.count, model.on_count, model.off_count = 200, 100, 100
|
|
with patch.object(coordinator, 'optimize', wraps=coordinator.optimize) as spy:
|
|
data = await self.sample()
|
|
self.assertEqual(data['status'], 'ready')
|
|
self.assertEqual(spy.call_args.args[4], {'zone_living': 2.0})
|
|
self.assertEqual(spy.call_args.kwargs['target_offsets'], {'zone_living': 0.5})
|
|
|
|
def office_with_ups(self):
|
|
self.entry.subentries = {
|
|
'z': types.SimpleNamespace(subentry_id='z', subentry_type='zone', data={
|
|
'key': 'office', 'name': 'Office', 'sensors': ['sensor.room']}),
|
|
'h': types.SimpleNamespace(subentry_id='h', subentry_type='heat_source', data={
|
|
'key': 'ups', 'name': 'UPS', 'sensor': 'sensor.ups_power', 'zone': 'office',
|
|
'response_minutes': 15})}
|
|
self.set_state('sensor.ups_power', '200', unit_of_measurement='W')
|
|
self.c = coordinator.HeatingMPCCoordinator(self.hass, self.entry)
|
|
|
|
async def test_gaming_heat_identified_separately_from_boiler(self):
|
|
"""Synthetic office: UPS 200 W idle, +400 W gaming sessions, thermostat-cycled boiler."""
|
|
self.office_with_ups()
|
|
a, b, c, d = 0.004, 0.12, 0.0005, 0.15
|
|
advance = coordinator.advance_lag
|
|
alpha = 1 - exp(-300 / 900)
|
|
self.states['climate.reference'].state = 'off' # learn only
|
|
temp, lag, on, response = 21.0, 0.0, False, 0.0
|
|
for k in range(2000):
|
|
outside = 5 + 5 * sin(2 * pi * k / 288)
|
|
gaming = (k % 120) >= 96 # two hours of every ten
|
|
power = 600.0 if gaming else 200.0
|
|
for entity, value in (('sensor.room', temp), ('sensor.outside', outside), ('sensor.ups_power', power)):
|
|
self.states[entity].state = f'{value:.5f}'
|
|
self.states[entity].last_reported = self.now
|
|
data = await self.sample()
|
|
command = temp < 20.7 or (on and temp < 21.3)
|
|
if command != on:
|
|
self.switch('on' if command else 'off', 180)
|
|
lag = advance(advance(lag, on, 120), command, 180)
|
|
else:
|
|
lag = advance(lag, on, 300)
|
|
on = command
|
|
response += alpha * ((power - 200) / 1000 - response) # this interval's held power
|
|
temp += a * (outside - temp) + b * lag + c + d * response
|
|
model = self.c.extended['office']
|
|
for name, value in (('internal', d), ('heat', b), ('loss', a)):
|
|
with self.subTest(coefficient=name):
|
|
self.assertAlmostEqual(model.coefficient(name), value, delta=0.1 * value)
|
|
info = data['models']['office']['extended']['coefficients']['internal']
|
|
self.assertTrue(info['identified'])
|
|
source = data['heat_sources']['UPS']
|
|
self.assertEqual((source['baseline_w'], source['zone'], source['settled']), (200.0, 'Office', True))
|
|
|
|
async def test_unknown_power_pauses_extended_learning_only(self):
|
|
self.office_with_ups()
|
|
learned = []
|
|
for k in range(30):
|
|
self.fresh('sensor.room', 'sensor.outside', 'sensor.ups_power')
|
|
self.states['sensor.ups_power'].state = 'unavailable' if k == 20 else '200'
|
|
before = self.c.extended['office'].count
|
|
data = await self.sample()
|
|
learned.append(self.c.extended['office'].count > before)
|
|
if k == 20:
|
|
self.assertEqual(data['heat_sources']['UPS']['status'], 'unavailable')
|
|
# Baseline after 12 polls (k = 11), then 2 time constants (6 polls) to settle.
|
|
# The interval ending at the outage is known (power held from k = 19); the
|
|
# next one is not, and the response must settle again.
|
|
self.assertEqual([k for k, ok in enumerate(learned) if ok], [17, 18, 19, 20, 27, 28, 29])
|
|
self.assertEqual(self.c.learners['office'].count, 29) # the base model never pauses
|
|
|
|
async def test_forecast_uses_extended_model_with_decaying_gaming_heat(self):
|
|
self.office_with_ups()
|
|
self.states['climate.reference'].state = 'off'
|
|
for k in range(20): # an hour idle (baseline), then gaming until the response settles
|
|
self.fresh('sensor.room', 'sensor.outside', 'sensor.ups_power')
|
|
self.states['sensor.ups_power'].state = '600' if k >= 12 else '200'
|
|
await self.sample()
|
|
base, extended = self.c.learners['office'], self.c.extended['office']
|
|
for model in (base, extended):
|
|
model.count, model.on_count, model.off_count = 200, 100, 100
|
|
extended.theta[extended.features.index('internal')] = 0.15
|
|
self.states['climate.reference'].state = 'heat'
|
|
self.fresh('sensor.room', 'sensor.outside', 'sensor.ups_power')
|
|
with patch.object(coordinator, 'optimize', wraps=coordinator.optimize) as spy:
|
|
data = await self.sample()
|
|
self.assertEqual((data['status'], data['models']['office']['model_in_use']), ('ready', 'extended'))
|
|
step = spy.call_args.kwargs['step_model']
|
|
now_heat = step({'office': 20.0}, 0.0, 0)['office']
|
|
later_heat = step({'office': 20.0}, 0.0, 23)['office']
|
|
self.assertGreater(now_heat - later_heat, 0.03) # the session is assumed to wind down
|
|
# With only base models trained, the optimizer keeps its default step.
|
|
extended.count = 10
|
|
self.fresh('sensor.room', 'sensor.outside', 'sensor.ups_power')
|
|
with patch.object(coordinator, 'optimize', wraps=coordinator.optimize) as spy:
|
|
await self.sample()
|
|
self.assertIsNone(spy.call_args.kwargs['step_model'])
|
|
|
|
def bedroom_with_window(self, with_window=True):
|
|
self.entry.data.update(outside_wind_sources=['sensor.wind'],
|
|
outside_wind_direction_sources=['sensor.wind_dir'])
|
|
self.entry.subentries = {
|
|
'z': types.SimpleNamespace(subentry_id='z', subentry_type='zone', data={
|
|
'key': 'bed', 'name': 'Bedroom', 'sensors': ['sensor.room']})}
|
|
if with_window:
|
|
self.entry.subentries['w'] = types.SimpleNamespace(subentry_id='w', subentry_type='window', data={
|
|
'key': 'bed_window', 'name': 'Bedroom window', 'sensor': 'binary_sensor.bed_window',
|
|
'zone': 'bed', 'azimuth': 345})
|
|
self.set_state('sensor.wind', '4', unit_of_measurement='m/s')
|
|
self.set_state('sensor.wind_dir', '204', unit_of_measurement='°')
|
|
self.set_state('binary_sensor.bed_window', 'off')
|
|
return coordinator.HeatingMPCCoordinator(self.hass, self.entry)
|
|
|
|
def open_window(self, state, seconds_before_next_sample):
|
|
window = self.states['binary_sensor.bed_window']
|
|
window.state = state
|
|
window.last_changed = self.now - timedelta(seconds=seconds_before_next_sample)
|
|
|
|
async def test_open_window_goes_to_ventilation_model_not_envelope_loss(self):
|
|
self.c = self.bedroom_with_window()
|
|
self.assertEqual(self.c.ventilation['bed'].features,
|
|
['vent_natural', 'recovery', 'vent_wind', 'vent_wind_dir'])
|
|
for _ in range(2):
|
|
self.fresh('sensor.room', 'sensor.outside', 'sensor.wind', 'sensor.wind_dir')
|
|
await self.sample()
|
|
self.open_window('on', 240) # opened a minute after the last poll
|
|
self.fresh('sensor.room', 'sensor.outside', 'sensor.wind', 'sensor.wind_dir')
|
|
base = self.c.learners['bed'].count
|
|
data = await self.sample()
|
|
self.assertEqual(data['sample_result'], 'ventilation')
|
|
self.assertEqual((self.c.learners['bed'].count, self.c.ventilation['bed'].count), (base, 1))
|
|
window = data['windows']['Bedroom window']
|
|
self.assertEqual((window['state'], window['open_minutes'], window['relative_wind_direction']),
|
|
('open', 4.0, -141.0))
|
|
self.assertEqual(window['wind_exposure'], round(cos(radians(141)), 3))
|
|
ventilation = data['ventilation']['Bedroom']
|
|
self.assertEqual((ventilation['opening'], ventilation['affected']), (0.8, True))
|
|
# Unknown contact state: neither model learns, and it never counts as closed.
|
|
self.open_window('unavailable', 120)
|
|
self.fresh('sensor.room', 'sensor.outside', 'sensor.wind', 'sensor.wind_dir')
|
|
data = await self.sample()
|
|
self.assertEqual(data['windows']['Bedroom window']['state'], 'unknown')
|
|
self.assertEqual((self.c.learners['bed'].count, self.c.ventilation['bed'].count), (base, 1))
|
|
|
|
async def test_ventilation_week_keeps_envelope_loss_clean(self):
|
|
"""Same synthetic week through a zone with and without its window configured."""
|
|
with_window = self.bedroom_with_window(True)
|
|
without = self.bedroom_with_window(False)
|
|
a, b, c = 0.004, 0.12, 0.0005
|
|
k_nat, k_wind, k_dir, k_rec = 0.01, 0.001, 0.0006, 0.15
|
|
advance = coordinator.advance_lag
|
|
self.states['climate.reference'].state = 'off'
|
|
temp, lag, on, outside = 21.0, 0.0, False, 2.0
|
|
window_open, closed_at, t_ref, prev_temp = False, None, None, temp
|
|
for k in range(2400):
|
|
speed, direction = 2 + 3 * abs(sin(k / 50)), (k * 7) % 360
|
|
outside = 2 + 4 * sin(2 * pi * k / 288) # daily swing keeps loss and bias apart
|
|
for entity, value in (('sensor.room', temp), ('sensor.outside', outside),
|
|
('sensor.wind', speed), ('sensor.wind_dir', direction)):
|
|
self.states[entity].state = f'{value:.5f}'
|
|
self.states[entity].last_reported = self.now
|
|
with patch.object(coordinator, 'datetime') as clock:
|
|
clock.now.return_value = self.now
|
|
for c_ in (with_window, without):
|
|
await c_._async_update_data()
|
|
poll = self.now
|
|
self.now += timedelta(seconds=300)
|
|
# Thermostat: switch 120 s after the poll when needed.
|
|
command = temp < 20.7 or (on and temp < 21.3)
|
|
if command != on:
|
|
self.switch('on' if command else 'off', 180)
|
|
lag = advance(advance(lag, on, 120), command, 180)
|
|
else:
|
|
lag = advance(lag, on, 300)
|
|
on = command
|
|
# Ventilation: 15-20 minutes every four hours, changes 60 s after a poll.
|
|
phase = k % 48
|
|
fraction, recovery = (1.0 if window_open else 0.0), 0.0
|
|
if phase == 0 and not window_open:
|
|
t_ref, window_open, fraction = prev_temp, True, 0.8
|
|
self.open_window('on', 240)
|
|
elif phase == 4 and window_open:
|
|
window_open, fraction, closed_at = False, 0.2, poll.timestamp() + 60
|
|
self.open_window('off', 240)
|
|
if closed_at is not None and not window_open:
|
|
recovery = exp(-(self.now.timestamp() - closed_at) / 1200)
|
|
recovery = recovery if recovery >= 0.05 else 0.0
|
|
facing = fraction * cos(radians((direction - 345 + 180) % 360 - 180))
|
|
difference = outside - temp
|
|
prev_temp = temp
|
|
temp += (a * difference + b * lag + c
|
|
+ (k_nat * fraction + k_wind * fraction * speed) * difference
|
|
+ k_dir * facing * speed * difference
|
|
+ k_rec * recovery * ((t_ref if t_ref is not None else temp) - temp))
|
|
vent = with_window.ventilation['bed']
|
|
# Natural and wind terms both scale with opening x (T_out - T); with wind only
|
|
# 2-5 m/s their split is uncertain, but their combined effect is identified.
|
|
mean_speed = 3.5
|
|
learned = vent.coefficient('vent_natural') + vent.coefficient('vent_wind') * mean_speed
|
|
self.assertAlmostEqual(learned, k_nat + k_wind * mean_speed, delta=0.15 * (k_nat + k_wind * mean_speed))
|
|
self.assertAlmostEqual(vent.coefficient('recovery'), k_rec, delta=0.15 * k_rec)
|
|
self.assertAlmostEqual(vent.coefficient('vent_wind_dir'), k_dir, delta=0.3 * k_dir)
|
|
# With the window configured the envelope model stays close to the truth...
|
|
clean = with_window.learners['bed'].theta
|
|
self.assertAlmostEqual(clean[0], a, delta=0.1 * a)
|
|
self.assertAlmostEqual(clean[1], b, delta=0.05 * b)
|
|
self.assertLess(abs(clean[2] - c), 0.003)
|
|
# ...without it, ventilation shows up as a constant cooling bias and an inflated boiler effect.
|
|
mixed = without.learners['bed'].theta
|
|
self.assertLess(mixed[2], -0.008)
|
|
self.assertGreater(mixed[1], 1.05 * b)
|
|
|
|
async def test_forecast_assumes_window_closes_then_room_recovers(self):
|
|
self.c = self.bedroom_with_window()
|
|
self.states['climate.reference'].state = 'off'
|
|
for _ in range(2):
|
|
self.fresh('sensor.room', 'sensor.outside', 'sensor.wind', 'sensor.wind_dir')
|
|
await self.sample()
|
|
self.open_window('on', 240)
|
|
self.fresh('sensor.room', 'sensor.outside', 'sensor.wind', 'sensor.wind_dir')
|
|
await self.sample()
|
|
model = self.c.learners['bed']
|
|
model.count, model.on_count, model.off_count = 200, 100, 100
|
|
vent = self.c.ventilation['bed']
|
|
vent.theta[vent.features.index('vent_natural')] = 0.01
|
|
vent.theta[vent.features.index('recovery')] = 0.15
|
|
self.states['climate.reference'].state = 'heat'
|
|
self.fresh('sensor.room', 'sensor.outside', 'sensor.wind', 'sensor.wind_dir')
|
|
with patch.object(coordinator, 'optimize', wraps=coordinator.optimize) as spy:
|
|
data = await self.sample()
|
|
self.assertEqual(data['status'], 'ready')
|
|
step = spy.call_args.kwargs['step_model']
|
|
base = model.predict_delta(19.0, 5.0, 0.0)
|
|
open_change = step({'bed': 19.0}, 0.0, 0)['bed'] - 19.0
|
|
recovering = step({'bed': 19.0}, 0.0, 4)['bed'] - 19.0 # 15 min default: closed by step 3
|
|
self.assertLess(open_change, base - 0.1) # 0.01 x (5 - 19) = -0.14 °C/5 min
|
|
self.assertGreater(recovering, base) # air recovers towards 20 °C
|