Keep spans with unknown inputs out of Model Error
Reproduced: a window unknown for a whole 15-minute span was replayed as closed and scored as a Model Error sample (MAE 0.76 °C on one sample). - A zone's replay is complete only if every step's model inputs were observed: window openings (and a confirmed recovery), the wind, direction and other zones' windows that an open window's terms use, and, for the extended layer, electrical heat and neighbours, whose own replay must be complete too. - Incomplete spans are counted, not scored; the base model's error on them is kept separately (it needs only the relay and outdoor temperature). Spans that could not be replayed (uncertain radiator lag, no reading at the end) are counted as skipped. Model Error sensors show incomplete, skipped and incomplete_base_model_mae; stored data gains an "excluded" section and older data loads unchanged. - Window inputs no longer stand in for unknown values: the facing term is unknown while a window is open and the wind direction is not, and the cross-ventilation input is unknown while another zone's window is. With every window closed, opening terms are 0 and need no weather input. Learning refuses such samples; forecasts still assume calm air. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
13cb972566
commit
2cfba024ff
6 files changed
+274
-34
No files matched your search
@@ -302,6 +302,7 @@ async def test_forecast_error_sensors_score_scheduled_polls(hass, entry, inputs,
|
||||
assert state.attributes["mae"] == accuracy["mae"] > 0
|
||||
assert state.attributes["quality"] == "insufficient"
|
||||
assert state.attributes["samples"] == 1
|
||||
assert (state.attributes["incomplete"], state.attributes["skipped"]) == (0, 0)
|
||||
assert state.attributes["zones"]["room"]["samples"] == 1 # by zone name
|
||||
assert state.attributes["unit_of_measurement"] == "°C"
|
||||
assert state.attributes["friendly_name"] == "Heating MPC Model Error 15 min"
|
||||
|
||||
@@ -116,15 +116,22 @@ class EvaluationTests(unittest.TestCase):
|
||||
day: {'45': {'sensor.room': [1, 0.0, 0.1, 0.1]},
|
||||
'15': {'sensor.a': [1, 0.0, -0.1, 0.1], 'sensor.b': [True, 0, 0, 0],
|
||||
'sensor.c': [1, float('nan'), 0, 0], 'sensor.d': [2, 0.1, 0.2, 0.4],
|
||||
'sensor.e': [2, 0.1, 0.2, 0.4, 0.3], 'sensor.f': [2, 0.1, 0.2, 0.4, -1]}}}})
|
||||
'sensor.e': [2, 0.1, 0.2, 0.4, 0.3], 'sensor.f': [2, 0.1, 0.2, 0.4, -1]}}},
|
||||
'excluded': {day: {'15': {'sensor.d': [1, 2, 0.3], 'sensor.x': [1, -2, 0.3], 'sensor.y': [1, 2]},
|
||||
'45': {'sensor.d': [1, 2, 0.3]}}}})
|
||||
# A 0.3 bucket has no base-model column; its model was the base model then.
|
||||
# Neither 0.3 nor 0.4 buckets have squared errors: they do not count for RMSE.
|
||||
# 0.4 data has no excluded-span counts.
|
||||
self.assertEqual(restored.as_dict(), {'days': {day: {'15': {
|
||||
'sensor.d': [2, 0.1, 0.2, 0.4, 0.2, 0.0, 0], 'sensor.e': [2, 0.1, 0.2, 0.4, 0.3, 0.0, 0]}}}})
|
||||
'sensor.d': [2, 0.1, 0.2, 0.4, 0.2, 0.0, 0], 'sensor.e': [2, 0.1, 0.2, 0.4, 0.3, 0.0, 0]}}},
|
||||
'excluded': {day: {'15': {'sensor.d': [1, 2, 0.3]}}}})
|
||||
self.assertIsNone(restored.summary(['sensor.d'])[15]['rmse'])
|
||||
self.assertEqual({k: restored.summary(['sensor.d'])[15][k] for k in ('incomplete', 'skipped',
|
||||
'incomplete_base_model_mae')},
|
||||
{'incomplete': 1, 'skipped': 2, 'incomplete_base_model_mae': 0.3})
|
||||
for garbage in (None, [], {'days': []}):
|
||||
restored.load(garbage)
|
||||
self.assertEqual(restored.as_dict(), {'days': {}})
|
||||
self.assertEqual(restored.as_dict(), {'days': {}, 'excluded': {}})
|
||||
|
||||
|
||||
T0 = START.timestamp()
|
||||
@@ -206,3 +213,89 @@ class OperationalForecastTests(unittest.TestCase):
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
|
||||
BASE = (0.004, 0.12, 0.0005)
|
||||
|
||||
|
||||
def ventilated(observed, actual=None, wind=4.0, features=('vent_natural',), zones=('r',), internal=None,
|
||||
extended=None, lag_uncertain_at=(), missing_end_at=()):
|
||||
"""Zones cooled by real ventilation; `observed[k]` is what the window sensor said
|
||||
for the interval ending at poll k (None: unknown). Returns the summary at 15 min."""
|
||||
actual = actual if actual is not None else [1.0 if o is None else o for o in observed]
|
||||
snapshot = {'base': BASE, 'uses_extended': extended is not None, 'extended': extended,
|
||||
'ventilation': (list(features), (0.02,) * len(features))}
|
||||
evaluator, temps = ev.ForecastEvaluator(0.1), {zone: 20.0 for zone in zones}
|
||||
for k, seen in enumerate(observed):
|
||||
if k:
|
||||
temps = {zone: t + 0.004 * (5 - t) + 0.12 * 0.5 + 0.0005
|
||||
+ 0.02 * actual[k] * (5 - t) * (1 + (wind or 0) * ('vent_wind' in features))
|
||||
for zone, t in temps.items()}
|
||||
vent = {'opening': seen, 'facing': 0.0, 'other_opening': 0.0, 'recovery': 0.0, 't_ref': None,
|
||||
'wind': wind}
|
||||
readings = {z: t for z, t in temps.items() if not (k in missing_end_at and z == zones[0])}
|
||||
evaluator.observe(START + timedelta(seconds=300 * k), readings, 5.0, 0.5,
|
||||
0.5 if k in lag_uncertain_at else 0.0, {z: BASE for z in zones},
|
||||
models={z: snapshot for z in zones},
|
||||
inputs={z: {'internal': internal, 'ventilation': vent if z == zones[0] else
|
||||
{**vent, 'opening': actual[k]}} for z in zones})
|
||||
return evaluator.summary(list(zones))[15]
|
||||
|
||||
|
||||
class IncompleteInputTests(unittest.TestCase):
|
||||
"""Unknown ventilation or extended inputs never become Model Error samples."""
|
||||
|
||||
def test_window_unknown_for_the_whole_span_is_incomplete(self):
|
||||
stats = ventilated([0.0, None, None, None])
|
||||
self.assertEqual((stats['samples'], stats['mae'], stats['incomplete']), (0, None, 1))
|
||||
# The base model needs no window input: its error is still kept, apart.
|
||||
self.assertGreater(stats['incomplete_base_model_mae'], 0.1)
|
||||
|
||||
def test_window_unknown_halfway_or_recovering_during_the_span(self):
|
||||
self.assertEqual(ventilated([0.0, 1.0, None, 1.0])['incomplete'], 1) # unavailable halfway
|
||||
stats = ventilated([0.0, None, 1.0, 1.0, 1.0, 1.0, 1.0]) # back after one poll
|
||||
# Spans ending at polls 3-6: only the first contains the unknown interval.
|
||||
self.assertEqual((stats['incomplete'], stats['samples']), (1, 3))
|
||||
self.assertAlmostEqual(stats['mae'], 0, places=6) # the known spans are exact
|
||||
|
||||
def test_one_zone_unknown_leaves_the_other_zone_scored(self):
|
||||
stats = ventilated([0.0, None, None, None], zones=('a', 'b'))
|
||||
self.assertEqual((stats['rooms']['a']['incomplete'], stats['rooms']['a']['samples']), (1, 0))
|
||||
self.assertEqual((stats['rooms']['b']['incomplete'], stats['rooms']['b']['samples']), (0, 1))
|
||||
|
||||
def test_missing_wind_matters_only_while_a_window_is_open(self):
|
||||
features = ('vent_natural', 'vent_wind')
|
||||
open_span = ventilated([0.0, 1.0, 1.0, 1.0], wind=None, features=features)
|
||||
closed_span = ventilated([0.0, 0.0, 0.0, 0.0], wind=None, features=features)
|
||||
self.assertEqual((open_span['incomplete'], open_span['samples']), (1, 0))
|
||||
self.assertEqual((closed_span['incomplete'], closed_span['samples']), (0, 1))
|
||||
self.assertEqual(ventilated([0.0, 1.0, 1.0, 1.0], features=features)['samples'], 1)
|
||||
|
||||
def test_unknown_electrical_heat_makes_the_extended_replay_incomplete(self):
|
||||
extended = (['loss', 'heat', 'bias', 'internal'], BASE + (0.15,))
|
||||
stats = ventilated([0.0] * 4, extended=extended, internal=None)
|
||||
self.assertEqual((stats['incomplete'], stats['samples']), (1, 0))
|
||||
self.assertIsNotNone(stats['incomplete_base_model_mae'])
|
||||
self.assertEqual(ventilated([0.0] * 4, extended=extended, internal=0.0)['samples'], 1)
|
||||
|
||||
def test_a_neighbour_with_unknown_inputs_spoils_a_coupled_zone(self):
|
||||
coupled = {'base': BASE, 'uses_extended': True, 'ventilation': None,
|
||||
'extended': (['loss', 'heat', 'bias', 'couple:a'], BASE + (0.01,))}
|
||||
plain = {'base': BASE, 'uses_extended': False, 'extended': None,
|
||||
'ventilation': (['vent_natural'], (0.02,))}
|
||||
origin = {'temps': {'a': 20.0, 'b': 20.0}, 'outside': 5.0, 'models': {'a': plain, 'b': coupled}}
|
||||
path = [origin] + [{'time': START + timedelta(seconds=300 * k), 'lag': 0.5,
|
||||
'inputs': {'a': {'ventilation': {'opening': None}}, 'b': {}}} for k in range(4)]
|
||||
origin['time'] = START - timedelta(seconds=300)
|
||||
_, incomplete = ev.replay_zones(origin, path)
|
||||
self.assertEqual(incomplete, {'a', 'b'})
|
||||
path = [origin] + [{**poll, 'inputs': {'a': {'ventilation': {'opening': 0.0}}, 'b': {}}} for poll in path[1:]]
|
||||
self.assertEqual(ev.replay_zones(origin, path)[1], set())
|
||||
|
||||
def test_skipped_spans_are_counted_and_samples_stay_correct(self):
|
||||
stats = ventilated([0.0] * 9, lag_uncertain_at=(4,))
|
||||
# Spans ending at 3..8: those crossing poll 4 (ending 4, 5, 6) are skipped.
|
||||
self.assertEqual((stats['samples'], stats['skipped'], stats['incomplete']), (3, 3, 0))
|
||||
stats = ventilated([0.0] * 5, missing_end_at=(4,), zones=('a', 'b'))
|
||||
self.assertEqual((stats['rooms']['a']['samples'], stats['rooms']['a']['skipped']), (1, 1))
|
||||
self.assertEqual(stats['rooms']['b']['samples'], 2)
|
||||
@@ -392,3 +392,26 @@ class StartupAndRestartTests(unittest.TestCase):
|
||||
self.assertFalse(tracker.changed) # nothing new
|
||||
self.poll(tracker, 'unavailable')
|
||||
self.assertTrue(tracker.changed)
|
||||
|
||||
|
||||
class StrictInputTests(unittest.TestCase):
|
||||
"""Weather inputs of an open window are required, never assumed, outside forecasts."""
|
||||
|
||||
def test_unknown_direction_or_other_zone_while_open(self):
|
||||
windows = wm.build_windows([(window('a'), None), (window('c', zone='living'), None)], ['bed', 'living'])
|
||||
tracker = wm.VentilationTracker(windows)
|
||||
calm = {**WEATHER, 'wind_direction': None}
|
||||
for t, a, c in ((300.0, 'off', 'off'), (600.0, 'on', 'unavailable'), (900.0, 'on', 'unavailable')):
|
||||
tracker.update(t, {'a': (a, 450.0), 'c': (c, 450.0)}, {'bed': 20.0, 'living': 21.0}, calm)
|
||||
bed = tracker.zone_inputs('bed', calm)
|
||||
self.assertEqual((bed['opening'], bed['facing'], bed['other_opening']), (1.0, None, None))
|
||||
features = ['vent_natural', 'vent_wind', 'vent_wind_dir', 'vent_cross']
|
||||
self.assertFalse(wm.ventilation_known(features, bed))
|
||||
self.assertTrue(wm.ventilation_known(['vent_natural', 'vent_wind'], bed))
|
||||
self.assertIsNone(wm.ventilation_vector(features, 20.0, 2.0, bed)) # learning refuses
|
||||
self.assertIsNotNone(wm.ventilation_vector_for_prediction(features, 20.0, 2.0, bed))
|
||||
# Closed, every opening term is 0 whatever the weather: nothing is missing.
|
||||
closed = {**bed, 'opening': 0.0, 'facing': 0.0, 'wind': None, 'other_opening': None}
|
||||
self.assertEqual(wm.ventilation_vector(features, 20.0, 2.0, closed), [0.0, 0.0, 0.0, 0.0])
|
||||
self.assertTrue(wm.ventilation_known(features, closed))
|
||||
self.assertTrue(wm.ventilation_known(features, None)) # no windows
|
||||
Reference in new issue
Block a user