Files
heating-mpc-ha/tests/test_coordinator.py
T
buzeraandClaude Opus 5.5 d2bd398a0f Show learning activity with Learning Progress and Last Sample sensors
During a first test the Status entity only said "learning" for the
first half hour (the radiator-lag wait after a restart), and the
attributes explaining why were hard to find. Add two sensors:

- Learning Progress (%): for the least trained room, the mean of its
  three readiness requirements (200 samples, 30 ON, 30 OFF), each capped
  at 100 %; 100 % means ready. Attributes show the counts.
- Last Sample: an enum of the last poll's learning outcome, with English
  and Romanian state names (e.g. "Waiting for radiators to settle"). Its
  history shows when learning happens.

Polls blocked by missing shared inputs now report waiting_for_inputs.
Readiness thresholds move to const.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 22:39:25 +02:00

700 lines
36 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 exp, pi, 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.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 = 3
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_weights_do_not_publish_proposal(self):
for weight in (0, -1, 'bad', float('nan'), float('inf')):
self.c.config['weights'] = {'sensor.room': weight}
data = await self.sample()
self.assertEqual(data['status'], 'invalid_weights')
self.assertIsNone(data['proposal'])
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']['rooms']['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_room_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']['rooms']['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']['rooms']['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_rooms'], ['sensor.bedroom'])
rooms = data['input_quality']['rooms']
self.assertEqual((rooms['sensor.bedroom']['status'], rooms['sensor.bedroom']['used']), ('stale', False))
self.assertTrue(rooms['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_rooms'], 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_rooms'], ['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']['rooms']['sensor.bedroom']
self.assertEqual((bedroom['status'], bedroom['used']), ('stale', True))
self.assertEqual((data['excluded_rooms'], 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_rooms'], ['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)