initial commit v0.2.1

This commit is contained in:
buzera committed 2026-10-26 17:34:32 +02:00
commit 2b9a10979b
19 files changed
+1665

No files matched your search

+65
View File
@@ -0,0 +1,65 @@
"""Offline tests: run python -m unittest discover -s tests -v."""
import importlib.util
import unittest
from pathlib import Path
path = Path(__file__).resolve().parents[1] / 'custom_components' / 'heating_mpc' / 'physics.py'
spec = importlib.util.spec_from_file_location('heating_mpc_physics', path)
m = importlib.util.module_from_spec(spec)
import sys
sys.modules[spec.name] = m
spec.loader.exec_module(m)
class ModelTests(unittest.TestCase):
def test_fitting_and_restore(self):
learn = m.Learner()
t = 20.0
lag = 0.0
for step in range(1000):
heater = int(step % 96 < 35)
lag = lag + m.HEAT_LAG * (heater - lag)
value = t + 0.003 * (8 - t) + 0.055 * lag + 0.001
learn.learn(t, value, 8, lag)
t = value
self.assertGreaterEqual(learn.count, 950)
self.assertAlmostEqual(learn.theta[0], 0.003, delta=0.003)
self.assertAlmostEqual(learn.theta[1], 0.055, delta=0.03)
restored = m.Learner.from_dict(learn.as_dict())
self.assertEqual(restored.count, learn.count)
self.assertEqual(restored.theta, learn.theta)
def test_optimize_warm_room_prefers_off(self):
zones = {'living': 23.5, 'bedroom': 23.4}
learners = {'living': m.Learner(), 'bedroom': m.Learner()}
out = m.optimize(zones, 12, 0, 22, {'living': 2, 'bedroom': 1},
learners, False, 3600, horizon_minutes=60)
self.assertFalse(out['proposal'])
self.assertEqual(len(out['plan_15min']), 4)
def test_optimize_cold_room_prefers_heat(self):
zones = {'living': 19.5, 'bedroom': 19.9}
learners = {'living': m.Learner(), 'bedroom': m.Learner()}
out = m.optimize(zones, 4, 0, 22, {'living': 2, 'bedroom': 1},
learners, False, 3600, horizon_minutes=60)
self.assertTrue(out['proposal'])
def test_minimum_duration_on_and_off(self):
learners = {'r': m.Learner()}
# Could not switch OFF during first 15 minutes if just turned ON.
out = m.optimize({'r': 24}, 12, 1, 22, {'r': 1}, learners, True, 120,
minimum_on_minutes=15, horizon_minutes=60)
self.assertTrue(out['proposal'])
# Could not switch ON during first 10 minutes if just turned OFF.
out = m.optimize({'r': 18}, 0, 0, 22, {'r': 1}, learners, False, 120,
minimum_off_minutes=10, horizon_minutes=60)
self.assertFalse(out['proposal'])
def test_invalid_room_weights_rejected(self):
with self.assertRaises(ValueError):
m.optimize({'r': 20}, 10, 0, 22, {'r': 0}, {'r': m.Learner()},
False, 1000, horizon_minutes=60)
if __name__ == '__main__':
unittest.main()
+73
View File
@@ -0,0 +1,73 @@
"""Offline weather inputs selection, converter, fallback, migration and source order."""
import importlib.util
from pathlib import Path
import sys
import types
import unittest
COMPONENTS = Path(__file__).resolve().parents[1] / 'custom_components' / 'heating_mpc'
# Fake only the package container, to avoid importing Home Assistant from __init__.
sys.modules.setdefault('heating_mpc', types.ModuleType('heating_mpc'))
sys.modules['heating_mpc'].__path__ = [str(COMPONENTS)]
for name in ('const', 'weather_sources'):
spec = importlib.util.spec_from_file_location(f'heating_mpc.{name}', COMPONENTS / f'{name}.py')
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
from heating_mpc import weather_sources as w
class WeatherInputs(unittest.TestCase):
def test_priority_fallback_and_compare(self):
data = {"outside_temperature_sources": ["sensor.owm", "sensor.alt"]}
states = {
"sensor.owm": ("unavailable", "°C"),
"sensor.alt": (53.6, "°F"),
}
values, active, comparisons = w.choose_weather_sources(data, states)
self.assertEqual(values["temperature"], 12.0)
self.assertEqual(active["temperature"], "sensor.alt")
self.assertEqual(comparisons["temperature"], {"sensor.alt": 12.0})
states["sensor.owm"] = (10.3, "°C")
values, active, comparisons = w.choose_weather_sources(data, states)
self.assertEqual(values["temperature"], 10.3)
self.assertEqual(active["temperature"], "sensor.owm")
self.assertEqual(comparisons["temperature"]["sensor.alt"], 12.0)
def test_source_reorder_changes_selected_source(self):
data = {"outside_temperature_sources": ["sensor.alt", "sensor.owm"]}
states = {"sensor.owm": (11.0, "°C"), "sensor.alt": (12.0, "°C")}
values, active, _ = w.choose_weather_sources(data, states)
self.assertEqual(active["temperature"], "sensor.alt")
self.assertEqual(values["temperature"], 12.0)
def test_legacy_v01_temperature_migration(self):
self.assertEqual(w.sources_for({"outside_entity": "sensor.temperature"}, "temperature"),
["sensor.temperature"])
self.assertEqual(w.sources_for({"outside_temperature_sources": ["sensor.b", "sensor.b"]}, "temperature"),
["sensor.b"])
def test_optional_weather_and_unit_conversions(self):
data = {"outside_temperature_sources": ["sensor.t"],
"outside_wind_sources": ["sensor.wind"],
"outside_pressure_sources": ["sensor.pressure"],
"outside_clouds_sources": ["sensor.clouds"]}
states = {"sensor.t": (12, "°C"), "sensor.wind": (36, "km/h"),
"sensor.pressure": (101325, "Pa"), "sensor.clouds": (40, "%")}
values, _, _ = w.choose_weather_sources(data, states)
self.assertEqual(values["wind"], 10.0)
self.assertEqual(values["pressure"], 1013.25)
self.assertEqual(values["clouds"], 40.0)
self.assertIsNone(values["snow"])
def test_bad_values_rejected(self):
for value in (None, 'unavailable', 'nan', 'inf', 1000):
self.assertIsNone(w.normalize("temperature", value, "°C"))
self.assertIsNone(w.normalize("temperature", 22.0, "km/h"))
self.assertIsNone(w.normalize("wind", -3, "m/s"))
self.assertIsNone(w.normalize("clouds", 150, "%"))
if __name__ == "__main__":
unittest.main()