Files
heating-mpc-ha/tests/test_data_quality.py
buzeraandClaude Opus 5.5 49565d610a Add Reset learned model to Configure
Configure now opens a menu: Settings (the previous form) or Reset learned
model. After an explicit confirmation the entry is unloaded, the learned
parts of the stored model (zone, extended and ventilation coefficients,
models kept for removed zones, Model Error and Forecast Error statistics,
fingerprints) are removed under the storage lock, and the entry is set up
again. Configuration, heat-source baselines and the window event log are
kept, and model_changes records the reset. Unloading first means no update
still running can write the old model back. English and Romanian strings;
the gust hint now refers to Model Error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 04:29:03 +02:00

82 lines
4.8 KiB
Python

"""Timestamp boundaries and UI translation coverage without HA dependencies."""
import importlib.util
import json
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] / 'custom_components' / 'heating_mpc'
spec = importlib.util.spec_from_file_location('quality', ROOT / 'data_quality.py')
quality = importlib.util.module_from_spec(spec)
spec.loader.exec_module(quality)
spec = importlib.util.spec_from_file_location('heating_mpc_const', ROOT / 'const.py')
const = importlib.util.module_from_spec(spec)
spec.loader.exec_module(const)
class FreshnessTests(unittest.TestCase):
def test_age_boundaries(self):
now = datetime(2026, 10, 26, tzinfo=timezone.utc)
for seconds, expected in ((0, 'valid'), (1800, 'valid'), (1801, 'stale'), (-1, 'future_timestamp')):
with self.subTest(seconds=seconds):
result = quality.freshness(now - timedelta(seconds=seconds), now, 1800)
self.assertEqual(result['status'], expected)
self.assertEqual(result['age_seconds'], seconds)
def test_missing_naive_and_malformed_times(self):
now = datetime.now(timezone.utc)
for timestamp in (None, 'bad', datetime.now()):
self.assertEqual(quality.freshness(timestamp, now, 1800)['status'], 'invalid_timestamp')
def test_invalid_limits_rejected(self):
now = datetime.now(timezone.utc)
for limit in (0, -1, float('inf'), float('nan')):
with self.assertRaises(ValueError):
quality.freshness(now, now, limit)
def test_report_age_history_keeps_24_hour_maximum(self):
history = quality.ReportAgeHistory()
start = datetime(2026, 10, 26, tzinfo=timezone.utc)
self.assertEqual(history.observe('sensor.a', start, 2400), 2400)
self.assertEqual(history.observe('sensor.b', start, 60), 60)
for minutes in range(5, 24 * 60, 5):
result = history.observe('sensor.a', start + timedelta(minutes=minutes), 300)
self.assertEqual(result, 2400) # still inside the 24-hour window
later = start + timedelta(hours=24, minutes=5)
self.assertEqual(history.observe('sensor.a', later, 300), 300)
self.assertEqual(history.observe('sensor.b', later, 30), 30)
def test_inputs_differ_ignores_noise_but_not_meaningful_changes(self):
before = {('temperature', 'sensor.room'): 20.0, ('power', 'sensor.ups'): 210.0,
('wind_direction', 'sensor.dir'): 355.0, ('wind', 'sensor.wind'): 4.0,
('window', 'binary_sensor.w'): False, ('thermostat', 'climate.t'): ('heat', 22, None)}
noise = {**before, ('temperature', 'sensor.room'): 20.1, ('power', 'sensor.ups'): 240.0,
('wind_direction', 'sensor.dir'): 5.0, ('wind', 'sensor.wind'): 4.6}
self.assertEqual(quality.inputs_differ(before, noise), []) # 10° across north is small
for key, value in ((('temperature', 'sensor.room'), 20.5), (('power', 'sensor.ups'), 610.0),
(('wind_direction', 'sensor.dir'), 40.0), (('window', 'binary_sensor.w'), True),
(('window', 'binary_sensor.w'), None), (('thermostat', 'climate.t'), ('heat', 21, None)),
(('temperature', 'sensor.room'), None)):
with self.subTest(key=key, value=value):
self.assertEqual(quality.inputs_differ(before, {**before, key: value}), [key])
self.assertEqual(quality.inputs_differ(before, {**before, ('power', 'sensor.new'): 5.0}),
[('power', 'sensor.new')])
def test_freshness_settings_translated(self):
for name in ('strings.json', 'translations/en.json', 'translations/ro.json'):
data = json.loads((ROOT / name).read_text())
# Configure opens a menu: settings, or resetting the learned model.
options = data['options']
self.assertEqual(set(options['step']['init']['menu_options']), {'settings', 'reset_model'})
self.assertTrue(options['step']['reset_model']['data']['confirm_reset'])
self.assertEqual(set(options['abort']), {'model_reset', 'model_not_reset'})
for section, step in (('config', 'user'), ('options', 'settings')):
labels = data[section]['step'][step]['data']
for key in ('room_max_age_minutes', 'weather_max_age_minutes', 'room_stale_use_last_value'):
self.assertTrue(labels[key])
help_text = data[section]['step'][step]['data_description']
self.assertIn('24', help_text['room_stale_use_last_value'])
# Every poll outcome has a translated state name.
states = data['entity']['sensor']['last_sample']['state']
self.assertEqual(set(states), set(const.SAMPLE_RESULTS))