Each zone forecast entity now shows the current temperature, the forecast at +15/+30/+60/+120 minutes (what the horizon covers), which model layer is in use, and what drives the zone right now in °C/h: envelope loss, boiler, bias, electrical heat, neighbours, ventilation and recovery, plus each layer's coefficients with standard error, confidence and whether it is identified. These attributes change every poll and are not recorded. Tests for inter-zone exchange on a two-room synthetic apartment (living room and a colder bedroom, one boiler): with a true coupling of 0.01 the learner finds a clearly positive value but, because the difference between rooms is fairly steady and therefore collinear with each room's bias, correctly reports it as not identified; with no coupling it stays near zero. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1025 lines
56 KiB
Python
1025 lines
56 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(2021): # ends 25 minutes into a session (2020 % 120 == 100)
|
|
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))
|
|
# Mid-session the zone shows the electrical share.
|
|
details = data['zone_details']['office']
|
|
self.assertEqual(details['model_in_use'], 'extended')
|
|
self.assertGreater(details['contributions_c_per_h']['internal'], 0.2)
|
|
self.assertTrue(details['coefficients']['extended']['internal']['identified'])
|
|
|
|
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
|
|
|
|
async def coupled_rooms(self, coupling, bed=(0.006, 0.09, 0.0)):
|
|
"""Living room and a colder bedroom (two exterior walls), one boiler, a shared wall."""
|
|
self.entry.subentries = {
|
|
'l': types.SimpleNamespace(subentry_id='l', subentry_type='zone', data={
|
|
'key': 'living', 'name': 'Living', 'sensors': ['sensor.room'], 'neighbors': ['bed']}),
|
|
'b': types.SimpleNamespace(subentry_id='b', subentry_type='zone', data={
|
|
'key': 'bed', 'name': 'Bedroom', 'sensors': ['sensor.bedroom'], 'comfort_weight': 2})}
|
|
self.set_state('sensor.bedroom', '19', unit_of_measurement='°C')
|
|
self.c = coordinator.HeatingMPCCoordinator(self.hass, self.entry)
|
|
self.states['climate.reference'].state = 'off'
|
|
params = {'living': (0.003, 0.12, 0.0005), 'bed': bed}
|
|
advance = coordinator.advance_lag
|
|
temps, lag, on = {'living': 21.0, 'bed': 19.5}, 0.0, False
|
|
for k in range(2400):
|
|
outside = 3 + 5 * sin(2 * pi * k / 288)
|
|
for entity, value in (('sensor.room', temps['living']), ('sensor.bedroom', temps['bed']),
|
|
('sensor.outside', outside)):
|
|
self.states[entity].state = f'{value:.5f}'
|
|
self.states[entity].last_reported = self.now
|
|
await self.sample()
|
|
average = (temps['living'] + 2 * temps['bed']) / 3
|
|
command = average < 20.2 or (on and average < 20.8)
|
|
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
|
|
new = {}
|
|
for zone, other in (('living', 'bed'), ('bed', 'living')):
|
|
a, b, c = params[zone]
|
|
new[zone] = (temps[zone] + a * (outside - temps[zone]) + b * lag + c
|
|
+ coupling * (temps[other] - temps[zone]))
|
|
temps = new
|
|
return self.c
|
|
|
|
async def test_inter_zone_transfer_learned_where_present(self):
|
|
c = await self.coupled_rooms(0.01)
|
|
self.assertEqual(self.c.zones['bed'].neighbors, ('living',)) # made symmetric
|
|
for zone, other in (('living', 'bed'), ('bed', 'living')):
|
|
with self.subTest(zone=zone):
|
|
info = c.extended[zone].coefficients()[f'couple:{other}']
|
|
self.assertGreater(info['value'], 0.004) # clearly present (true 0.01)...
|
|
self.assertLess(info['value'], 0.013)
|
|
# ...but a steady difference between rooms is collinear with each room's
|
|
# bias, so the split stays uncertain and is not reported as identified.
|
|
self.assertFalse(info['identified'])
|
|
|
|
async def test_inter_zone_transfer_stays_near_zero_when_absent(self):
|
|
c = await self.coupled_rooms(0.0, bed=(0.004, 0.11, 0.0))
|
|
for zone, other in (('living', 'bed'), ('bed', 'living')):
|
|
with self.subTest(zone=zone):
|
|
self.assertLess(c.extended[zone].coefficient(f'couple:{other}'), 0.002)
|