The base layer (loss, heat, bias) showed values only, so a zone with weeks of samples looked equally trustworthy whether or not the data ever varied its inputs. Each base coefficient now reports a confidence from its own posterior variance, as the extended and ventilation layers already did: it stays at 0 for `heat` while the relay never cycles and below 0.8 for `loss` and `bias` while the indoor-outdoor difference is steady. Readiness is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1333 lines
74 KiB
Python
1333 lines
74 KiB
Python
"""Offline HA boundary doubles; these do not validate the HA runtime itself."""
|
|
import importlib.util
|
|
import sys
|
|
import types
|
|
import asyncio
|
|
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'])
|
|
# The base layer reports how well each coefficient is pinned down too.
|
|
base = details['coefficients']['base']
|
|
self.assertEqual(set(base), {'loss', 'heat', 'bias'})
|
|
self.assertEqual(base['heat']['confidence'], data['models']['office']['confidence']['heat'])
|
|
self.assertGreater(base['heat']['confidence'], 0.8)
|
|
# Replayed against what happened, the electrical term removes most of the base error.
|
|
accuracy = data['forecast_accuracy'][60]
|
|
self.assertLess(accuracy['mae'], 0.06)
|
|
self.assertGreater(accuracy['improvement_vs_base'], 0.6)
|
|
|
|
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)
|
|
# Forecasts replayed with the actual window history are far better than the base model's.
|
|
configured = with_window._evaluator.summary(with_window.rooms)[60]
|
|
self.assertGreater(configured['improvement_vs_base'], 0.6)
|
|
self.assertLess(configured['mae'], 0.06)
|
|
self.assertEqual(without._evaluator.summary(without.rooms)[60]['improvement_vs_base'], 0)
|
|
|
|
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'])
|
|
# Joint multi-zone forecasts still beat the base model at an hour.
|
|
self.assertGreater(c._evaluator.summary(c.rooms)[60]['improvement_vs_base'], 0.15)
|
|
|
|
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)
|
|
|
|
async def test_new_model_layers_survive_a_restart(self):
|
|
self.c = self.bedroom_with_window()
|
|
self.c.entry.subentries['h'] = types.SimpleNamespace(subentry_id='h', subentry_type='heat_source', data={
|
|
'key': 'ups', 'name': 'UPS', 'sensor': 'sensor.ups_power', 'zone': 'bed'})
|
|
self.set_state('sensor.ups_power', '200', unit_of_measurement='W')
|
|
self.c = coordinator.HeatingMPCCoordinator(self.hass, self.entry)
|
|
for k in range(20):
|
|
self.fresh('sensor.room', 'sensor.outside', 'sensor.wind', 'sensor.wind_dir', 'sensor.ups_power')
|
|
if k == 18:
|
|
self.open_window('on', 240)
|
|
await self.sample()
|
|
extended, vent = self.c.extended['bed'], self.c.ventilation['bed']
|
|
self.assertGreater(extended.count, 0)
|
|
self.assertGreater(vent.count, 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.extended['bed'].theta, restored.extended['bed'].count), (extended.theta, extended.count))
|
|
self.assertEqual((restored.ventilation['bed'].theta, restored.ventilation['bed'].count), (vent.theta, vent.count))
|
|
self.assertEqual(restored._heat_sources._baselines['ups'].value(), 200) # no hour of re-learning
|
|
# Adding a feature keeps what was learned for the others.
|
|
self.entry.data['ventilation_cross'] = True
|
|
self.entry.subentries['z2'] = types.SimpleNamespace(subentry_id='z2', subentry_type='zone', data={
|
|
'key': 'living', 'name': 'Living', 'sensors': ['sensor.living']})
|
|
self.entry.subentries['w2'] = types.SimpleNamespace(subentry_id='w2', subentry_type='window', data={
|
|
'key': 'living_window', 'name': 'Living window', 'sensor': 'binary_sensor.living_window', 'zone': 'living'})
|
|
grown = coordinator.HeatingMPCCoordinator(self.hass, self.entry)
|
|
grown._store.async_load.return_value = payload
|
|
await grown.async_restore_model()
|
|
self.assertEqual(grown.ventilation['bed'].features[-1], 'vent_cross')
|
|
self.assertEqual(grown.ventilation['bed'].theta[:-1], vent.theta)
|
|
|
|
async def test_unavailable_wind_during_ventilation(self):
|
|
self.c = self.bedroom_with_window()
|
|
for _ in range(2):
|
|
self.fresh('sensor.room', 'sensor.outside', 'sensor.wind', 'sensor.wind_dir')
|
|
await self.sample()
|
|
self.open_window('on', 240)
|
|
self.states['sensor.wind'].state = 'unavailable'
|
|
self.fresh('sensor.room', 'sensor.outside', 'sensor.wind_dir')
|
|
data = await self.sample()
|
|
# Learning needs the wind: nothing is fitted, the envelope model stays out too.
|
|
self.assertEqual(data['sample_result'], 'ventilation')
|
|
self.assertEqual((self.c.ventilation['bed'].count, self.c.learners['bed'].count), (0, 1))
|
|
# Predictions keep the open window, as calm air, rather than dropping it.
|
|
vent = self.c.ventilation['bed']
|
|
vent.theta[vent.features.index('vent_natural')] = 0.01
|
|
model = coordinator.ZoneModel(self.c.learners['bed'], None, vent)
|
|
inputs = self.c._ventilation.zone_inputs('bed', {'wind': None, 'wind_gust': None})
|
|
self.assertLess(model.ventilation_delta(20.0, {'outside': 5.0, 'ventilation': {**inputs, 'opening': 1.0}}), -0.1)
|
|
window = data['windows']['Bedroom window']
|
|
self.assertEqual((window['state'], window['last_known_state'], window['unknown_minutes']), ('open', 'open', None))
|
|
|
|
ZONES = [{'key': 'living', 'name': 'Living', 'sensors': ['sensor.room'], 'neighbors': ['bed']},
|
|
{'key': 'bed', 'name': 'Bedroom', 'sensors': ['sensor.bedroom']}]
|
|
SOURCES = [{'key': 'ups', 'name': 'UPS', 'sensor': 'sensor.ups_power', 'zone': 'living'}]
|
|
WINDOWS = [{'key': 'w', 'name': 'Bed window', 'sensor': 'binary_sensor.bed_window', 'zone': 'bed',
|
|
'azimuth': 345}]
|
|
|
|
def configured(self, zones=None, sources=None, windows=None):
|
|
self.entry.data.update(outside_wind_sources=['sensor.wind'], outside_wind_direction_sources=['sensor.wind_dir'])
|
|
self.entry.subentries = {}
|
|
for kind, items in (('zone', zones or self.ZONES), ('heat_source', self.SOURCES if sources is None else sources),
|
|
('window', self.WINDOWS if windows is None else windows)):
|
|
for i, data in enumerate(items):
|
|
self.entry.subentries[f'{kind}{i}'] = types.SimpleNamespace(
|
|
subentry_id=f'{kind}{i}', subentry_type=kind, data=data)
|
|
return coordinator.HeatingMPCCoordinator(self.hass, self.entry)
|
|
|
|
def trained_payload(self):
|
|
c = self.configured()
|
|
for key in ('living', 'bed'):
|
|
base = c.learners[key]
|
|
base.count, base.on_count, base.off_count, base.theta = 300, 100, 100, [0.004, 0.12, 0.001]
|
|
living = c.extended['living']
|
|
living.count, living.on_count, living.off_count = 300, 100, 100
|
|
living.theta = [0.004, 0.12, 0.001, 0.15, 0.008]
|
|
vent = c.ventilation['bed']
|
|
vent.count = 80
|
|
vent.theta[vent.features.index('vent_natural')] = 0.01
|
|
vent.theta[vent.features.index('vent_wind_dir')] = 0.0005
|
|
c._evaluator._days = {'2026-10-27': {'60': {'living': [10, 0.1, 0.2, 0.3, 0.4, 0.05, 10],
|
|
'bed': [10, 0.1, 0.2, 0.3, 0.4, 0.05, 10]}}}
|
|
c._heat_sources._baselines['ups'].samples.extend([200.0] * 24)
|
|
return c._payload('sensor.outside')
|
|
|
|
async def restored(self, payload, **config):
|
|
c = self.configured(**config)
|
|
c._store.async_load.return_value = payload
|
|
await c.async_restore_model()
|
|
return c
|
|
|
|
async def test_comfort_settings_keep_every_learned_coefficient(self):
|
|
payload = self.trained_payload()
|
|
zones = [{**self.ZONES[0], 'name': 'Lounge', 'comfort_weight': 4, 'target_offset': 1},
|
|
{**self.ZONES[1], 'comfort_weight': 0.5}]
|
|
c = await self.restored(payload, zones=zones)
|
|
self.assertEqual((c.learners['living'].count, c.extended['living'].theta, c.ventilation['bed'].count),
|
|
(300, [0.004, 0.12, 0.001, 0.15, 0.008], 80))
|
|
self.assertEqual(list(c._model_changes), [])
|
|
c._store.async_save.assert_not_called()
|
|
|
|
async def test_changed_zone_sensors_recalibrate_only_that_zone(self):
|
|
payload = self.trained_payload()
|
|
zones = [{**self.ZONES[0], 'sensors': ['sensor.room', 'sensor.second']}, self.ZONES[1]]
|
|
c = await self.restored(payload, zones=zones)
|
|
base = c.learners['living']
|
|
self.assertEqual((base.theta, base.count, base.p), ([0.004, 0.12, 0.001], 0, coordinator.Learner().p)) # prior uncertainty
|
|
living = c.extended['living']
|
|
self.assertEqual((living.count, living.theta[:3], living.coefficient('internal')), (0, [0.004, 0.12, 0.001], 0.0))
|
|
# The bedroom keeps its model; only its exchange term with the living room restarts.
|
|
self.assertEqual((c.learners['bed'].count, c.ventilation['bed'].count), (300, 80))
|
|
self.assertEqual(c.extended['bed'].coefficient('couple:living'), 0.0)
|
|
self.assertEqual(set(c._evaluator._days['2026-10-27']['60']), {'bed'}) # living measured differently now
|
|
change = next(ch for ch in c._model_changes if ch.get('zone') == 'Living')
|
|
self.assertEqual(change['reasons'], ['zone sensors or sensor weights changed'])
|
|
# Saved at once with the new fingerprints: another restart changes nothing.
|
|
saved = c._store.async_save.call_args.args[0]
|
|
again = await self.restored(saved, zones=zones)
|
|
self.assertEqual(len(again._model_changes), len(c._model_changes))
|
|
again._store.async_save.assert_not_called()
|
|
|
|
async def test_turned_window_resets_only_the_direction_term(self):
|
|
payload = self.trained_payload()
|
|
c = await self.restored(payload, windows=[{**self.WINDOWS[0], 'azimuth': 300}])
|
|
vent = c.ventilation['bed']
|
|
self.assertEqual((vent.coefficient('vent_natural'), vent.coefficient('vent_wind_dir'), vent.count),
|
|
(0.01, 0.0, 80))
|
|
self.assertEqual((c.learners['bed'].count, c.extended['living'].coefficient('internal')), (300, 0.15))
|
|
self.assertEqual(c._model_changes[-1]['reasons'], ['window orientation changed'])
|
|
|
|
async def test_moved_or_replaced_heat_source(self):
|
|
payload = self.trained_payload()
|
|
c = await self.restored(payload, sources=[{**self.SOURCES[0], 'zone': 'bed'}])
|
|
self.assertEqual(c.extended['living'].coefficient('internal'), 0.0)
|
|
self.assertEqual(c.extended['living'].coefficient('couple:bed'), 0.008) # unrelated term kept
|
|
self.assertEqual(c.extended['bed'].coefficient('internal'), 0.0) # new there, from the prior
|
|
self.assertEqual(len(c._heat_sources._baselines['ups'].samples), 24) # same sensor: same load
|
|
replaced = await self.restored(payload, sources=[{**self.SOURCES[0], 'sensor': 'sensor.ups2'}])
|
|
self.assertEqual(len(replaced._heat_sources._baselines['ups'].samples), 0)
|
|
self.assertEqual(replaced.extended['living'].coefficient('internal'), 0.0)
|
|
self.assertIn('baseline', replaced._model_changes[-1]['recalibrated'])
|
|
|
|
async def test_v040_data_without_fingerprints_is_kept_and_then_fingerprinted(self):
|
|
payload = self.trained_payload()
|
|
del payload['fingerprints']
|
|
c = await self.restored(payload)
|
|
self.assertEqual((c.learners['living'].count, c.extended['living'].coefficient('internal')), (300, 0.15))
|
|
# Fingerprinted at once, so an edit right after the upgrade is detected.
|
|
self.assertEqual(c._store.async_save.call_args.args[0]['fingerprints'], c._fingerprints)
|
|
|
|
async def test_re_added_zone_with_other_sensors_is_recalibrated(self):
|
|
payload = self.trained_payload()
|
|
without = await self.restored(payload, zones=[self.ZONES[1]], sources=[])
|
|
self.assertIn('living', without._retired)
|
|
saved = without._payload('sensor.outside')
|
|
back = await self.restored(saved, zones=[{**self.ZONES[0], 'sensors': ['sensor.other']}, self.ZONES[1]])
|
|
self.assertEqual((back.learners['living'].theta, back.learners['living'].count), ([0.004, 0.12, 0.001], 0))
|
|
|
|
def ready_office(self):
|
|
"""Office with a UPS and a window, trained enough to optimize."""
|
|
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'}),
|
|
'w': types.SimpleNamespace(subentry_id='w', subentry_type='window', data={
|
|
'key': 'w', 'name': 'Window', 'sensor': 'binary_sensor.office_window', 'zone': 'office'})}
|
|
self.set_state('sensor.ups_power', '200', unit_of_measurement='W')
|
|
self.set_state('binary_sensor.office_window', 'off')
|
|
self.c = coordinator.HeatingMPCCoordinator(self.hass, self.entry)
|
|
base = self.c.learners['office']
|
|
base.count, base.on_count, base.off_count = 200, 100, 100
|
|
|
|
async def change_during_optimization(self, change):
|
|
async def worker(job):
|
|
output = job()
|
|
change()
|
|
return output
|
|
self.hass.async_add_executor_job = worker
|
|
return await self.sample()
|
|
|
|
async def test_meaningful_input_changes_during_optimization_discard_the_plan(self):
|
|
cases = {
|
|
'window: binary_sensor.office_window': lambda: setattr(self.states['binary_sensor.office_window'], 'state', 'on'),
|
|
'power: sensor.ups_power': lambda: setattr(self.states['sensor.ups_power'], 'state', '600'),
|
|
'thermostat: climate.reference': lambda: self.states['climate.reference'].attributes.update(temperature=21),
|
|
'temperature: sensor.room': lambda: setattr(self.states['sensor.room'], 'state', '20.6'),
|
|
}
|
|
for expected, change in cases.items():
|
|
with self.subTest(change=expected):
|
|
self.setUp()
|
|
self.ready_office()
|
|
data = await self.change_during_optimization(change)
|
|
self.assertEqual((data['status'], data['proposal'], data['changed_inputs']),
|
|
('inputs_changed', None, [expected]))
|
|
|
|
async def test_sensor_noise_during_optimization_does_not_discard_the_plan(self):
|
|
self.ready_office()
|
|
def noise():
|
|
self.states['sensor.room'].state = '20.1'
|
|
self.states['sensor.ups_power'].state = '230'
|
|
self.assertEqual((await self.change_during_optimization(noise))['status'], 'ready')
|
|
|
|
async def test_an_older_run_finishing_late_never_overwrites_a_newer_result(self):
|
|
self.ready_office()
|
|
release = asyncio.Event()
|
|
async def slow_then_fast(job):
|
|
if not release.is_set() and not getattr(self, '_first_started', False):
|
|
self._first_started = True
|
|
await release.wait()
|
|
return job()
|
|
self.hass.async_add_executor_job = slow_then_fast
|
|
with patch.object(coordinator, 'datetime') as clock:
|
|
clock.now.return_value = self.now
|
|
older = asyncio.ensure_future(self.c._async_update_data())
|
|
await asyncio.sleep(0)
|
|
clock.now.return_value = self.now + timedelta(seconds=60)
|
|
newer = await self.c._async_update_data()
|
|
self.c.data = newer # what DataUpdateCoordinator publishes
|
|
release.set()
|
|
result = await older
|
|
self.assertEqual(newer['status'], 'ready')
|
|
self.assertIs(result, newer)
|
|
|
|
async def test_published_forecasts_are_scored_only_when_the_relay_followed_the_plan(self):
|
|
# The relay stays off. A warm room's plan says off, so its forecast is scored;
|
|
# a cold room's plan says heat, so its forecast is counterfactual, never an error.
|
|
for room, follows in (('23.5', True), ('18', False)):
|
|
with self.subTest(room=room):
|
|
self.setUp()
|
|
self.ready_office()
|
|
self.set_state('sensor.room', room, unit_of_measurement='°C')
|
|
first = await self.sample()
|
|
self.assertEqual(first['status'], 'ready')
|
|
self.assertEqual(first['plan_15min'][0], 0 if follows else 1)
|
|
for _ in range(3):
|
|
self.fresh('sensor.room', 'sensor.outside', 'sensor.ups_power')
|
|
data = await self.sample()
|
|
fifteen = data['operational_forecast'][15]
|
|
if follows:
|
|
self.assertEqual((fifteen['samples'], fifteen['counterfactual']), (1, 0))
|
|
predicted = first['prediction_trajectory']['office'][0]
|
|
self.assertAlmostEqual(fifteen['mae'], abs(predicted - 23.5), places=3)
|
|
self.assertEqual(fifteen['persistence_mae'], 0.0)
|
|
self.assertEqual(fifteen['quality'], 'insufficient')
|
|
else:
|
|
self.assertEqual((fifteen['samples'], fifteen['counterfactual'], fifteen['mae']), (0, 1, None))
|
|
# Longer horizons are not due yet.
|
|
self.assertEqual(data['operational_forecast'][30]['samples'], 0)
|
|
stored = self.c._payload('sensor.outside')['operational_evaluation']
|
|
self.assertEqual(sum(sum(v.get('15', {}).values()) for v in stored['verdicts'].values()),
|
|
0 if follows else 1)
|
|
|
|
async def test_unloading_during_save_or_optimization_publishes_nothing_new(self):
|
|
self.ready_office()
|
|
await self.sample() # a first sample to learn from
|
|
previous = self.c.data = {'status': 'learning', 'proposal': None}
|
|
self.fresh('sensor.room', 'sensor.outside', 'sensor.ups_power')
|
|
async def unload_while_saving(payload):
|
|
self.c._shutdown_requested = True
|
|
self.c._store.async_save.side_effect = unload_while_saving
|
|
worker = AsyncMock(side_effect=AssertionError('no optimization after unload'))
|
|
self.hass.async_add_executor_job = worker
|
|
self.assertIs(await self.sample(), previous)
|
|
worker.assert_not_called()
|
|
|
|
async def test_restore_waits_for_a_save_still_in_progress(self):
|
|
self.ready_office()
|
|
order = []
|
|
release = asyncio.Event()
|
|
async def slow_save(payload):
|
|
order.append('save started')
|
|
await release.wait()
|
|
order.append('save finished')
|
|
self.c._store.async_save.side_effect = slow_save
|
|
saving = asyncio.ensure_future(self.c._async_save('sensor.outside'))
|
|
await asyncio.sleep(0)
|
|
successor = coordinator.HeatingMPCCoordinator(self.hass, self.entry)
|
|
async def load():
|
|
order.append('load')
|
|
return None
|
|
successor._store.async_load.side_effect = load
|
|
restoring = asyncio.ensure_future(successor.async_restore_model())
|
|
await asyncio.sleep(0)
|
|
self.assertEqual(order, ['save started']) # the new coordinator waits
|
|
release.set()
|
|
await asyncio.gather(saving, restoring)
|
|
self.assertEqual(order, ['save started', 'save finished', 'load'])
|