Model windows, ventilation, wind exposure and recovery
Opening a window adds heat exchange that the envelope model mistook for
poor insulation or a constant cooling bias.
windows.py adds windows as config subentries ("Add window"): a contact
sensor (optionally inverted; unknown/unavailable stays unknown, never
"closed"), its zone, the direction its outside faces (default 345°,
editable) and an opening factor. Each poll computes the share of the
interval each window was open (one change located by its timestamp, a
round trip is unknown), the wind exposure with proper circular angles
(meteorological "from" direction vs the window's facing) and a recovery
weight after closing that decays towards 0, pulling the air back
towards its pre-ventilation temperature. A bounded event log records
opening/closing, duration, temperatures before/lowest/at close/30 min
after, weather and other open windows.
While a zone's window is open, unknown or recovering, the base and
extended models skip it; a separate ventilation learner fits the
closed-window model's residual with natural exchange, wind speed, a
directional term bounded by the wind term (a leeward window still
ventilates), recovery, and optional gust excess and cross-ventilation
terms. Forecasts assume an open window closes after a configurable time
(default 15 min), then recovers. New poll outcome "ventilation".
Weather gains optional wind direction (degrees, 360 -> 0) and wind gust
sources with the same priorities and fallback; knots and ft/s accepted.
On a synthetic week with ventilation events every four hours and a
daily outdoor swing, the configured zone keeps loss/heat/bias close to
the truth and recovers the combined natural+wind effect within 15 %,
recovery within 15 % and direction within 30 %. The same data without
the window configured produces a -0.013 °C/5 min cooling bias and an
11 % inflated boiler effect.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
00e70c612b
commit
ed8f9fe2e2
13 files changed
+1157
-39
No files matched your search
@@ -209,3 +209,45 @@ async def test_heat_source_subentry_flow(hass, entry, inputs):
|
||||
sources = status.attributes["heat_sources"]
|
||||
assert (sources["UPS"]["power_w"], sources["UPS"]["effective_w"]) == (600, 200) # PC not counted twice
|
||||
assert (sources["Gaming PC"]["included_in"], sources["Gaming PC"]["variable_w"]) == ("UPS", 400)
|
||||
|
||||
|
||||
async def test_window_subentry_flow_and_ventilation_options(hass, entry, inputs):
|
||||
await setup_entry(hass, entry)
|
||||
hass.states.async_set("binary_sensor.bedroom_window", "off", {"device_class": "window"})
|
||||
flows = hass.config_entries.subentries
|
||||
result = await flows.async_init((entry.entry_id, "window"), context={"source": SOURCE_USER})
|
||||
form = result["data_schema"]({"name": "Bedroom window", "sensor": "binary_sensor.bedroom_window",
|
||||
"zone": "sensor.room"})
|
||||
assert (form["azimuth"], form["opening_factor"], form["inverted"]) == (345.0, 1.0, False)
|
||||
result = await flows.async_configure(result["flow_id"], {**form, "azimuth": 340})
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
await hass.async_block_till_done()
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
assert [(w.name, w.azimuth) for w in coordinator.windows] == [("Bedroom window", 340.0)]
|
||||
# No wind sources yet: only the terms whose inputs exist.
|
||||
assert coordinator.ventilation["sensor.room"].features == ["vent_natural", "recovery"]
|
||||
result = await flows.async_init((entry.entry_id, "window"), context={"source": SOURCE_USER})
|
||||
result = await flows.async_configure(result["flow_id"], {**form, "name": "Again"})
|
||||
assert result["errors"] == {"base": "sensor_in_other_window"}
|
||||
flows.async_abort(result["flow_id"])
|
||||
# Wind, direction and gust sources plus the gust option add their terms.
|
||||
for sensor, value, unit in (("sensor.wind", "4", "m/s"), ("sensor.wind_dir", "204", "°"),
|
||||
("sensor.gust", "9", "m/s")):
|
||||
hass.states.async_set(sensor, value, {"unit_of_measurement": unit})
|
||||
options = hass.config_entries.options
|
||||
result = await options.async_init(entry.entry_id)
|
||||
result = await options.async_configure(result["flow_id"], result["data_schema"](
|
||||
{**MAIN, "ventilation_gust": True, "ventilation_cross": True}))
|
||||
result = await options.async_configure(result["flow_id"], result["data_schema"]({
|
||||
"outside_temperature_sources": ["sensor.outside"], "outside_wind_sources": ["sensor.wind"],
|
||||
"outside_wind_direction_sources": ["sensor.wind_dir"], "outside_wind_gust_sources": ["sensor.gust"]}))
|
||||
result = await options.async_configure(result["flow_id"], result["data_schema"]({}))
|
||||
await hass.async_block_till_done()
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
# Cross-ventilation needs windows in two zones, so it stays off.
|
||||
assert coordinator.ventilation["sensor.room"].features == [
|
||||
"vent_natural", "recovery", "vent_wind", "vent_wind_dir", "vent_gust"]
|
||||
status = hass.states.get(er.async_get(hass).async_get_entity_id("sensor", DOMAIN, f"{entry.entry_id}_status"))
|
||||
window = status.attributes["windows"]["Bedroom window"]
|
||||
assert (window["state"], window["relative_wind_direction"]) == ("closed", -136.0)
|
||||
assert {"windows", "ventilation"} <= status.state_info["unrecorded_attributes"]
|
||||
+141
-1
@@ -5,7 +5,7 @@ import types
|
||||
import unittest
|
||||
import unittest.mock
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from math import exp, pi, sin
|
||||
from math import cos, exp, pi, radians, sin
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
@@ -823,3 +823,143 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
|
||||
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)
|
||||
|
||||
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
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Windows, wind exposure, ventilation inputs and recovery, without Home Assistant."""
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from math import cos, exp, radians
|
||||
from pathlib import Path
|
||||
|
||||
COMPONENTS = Path(__file__).resolve().parents[1] / 'custom_components' / 'heating_mpc'
|
||||
sys.modules.setdefault('heating_mpc', types.ModuleType('heating_mpc'))
|
||||
sys.modules['heating_mpc'].__path__ = [str(COMPONENTS)]
|
||||
from heating_mpc import windows as wm # noqa: E402
|
||||
|
||||
WEATHER = {'temperature': 2.0, 'wind': 4.0, 'wind_direction': 204.0, 'wind_gust': 9.0}
|
||||
|
||||
|
||||
def window(key, zone='bed', **extra):
|
||||
return {'key': key, 'name': key.title(), 'sensor': f'binary_sensor.{key}', 'zone': zone, **extra}
|
||||
|
||||
|
||||
class GeometryTests(unittest.TestCase):
|
||||
def test_wind_relative_to_facade(self):
|
||||
# The apartment's façade faces about 345° (NNW); OpenWeatherMap wind from 204°.
|
||||
self.assertAlmostEqual(wm.angle_difference(204, 345), -141)
|
||||
self.assertAlmostEqual(wm.facing_cos(204, 345), cos(radians(141)))
|
||||
self.assertAlmostEqual(wm.facing_cos(345, 345), 1) # blowing straight at the window
|
||||
self.assertAlmostEqual(wm.facing_cos(165, 345), -1) # from behind the building
|
||||
self.assertAlmostEqual(wm.facing_cos(75, 345), 0, places=9) # crosswind
|
||||
# Around north, differences wrap instead of jumping by 360°.
|
||||
self.assertAlmostEqual(wm.angle_difference(5, 355), 10)
|
||||
self.assertAlmostEqual(wm.angle_difference(355, 5), -10)
|
||||
self.assertAlmostEqual(wm.facing_cos(5, 355), cos(radians(10)))
|
||||
|
||||
def test_circular_mean(self):
|
||||
self.assertAlmostEqual(wm.circular_mean([350, 10]) % 360 if wm.circular_mean([350, 10]) < 359 else 0, 0)
|
||||
self.assertAlmostEqual(wm.circular_mean([350, 20], [1, 1]), 5)
|
||||
self.assertIsNone(wm.circular_mean([90, 270]))
|
||||
self.assertAlmostEqual(wm.circular_mean([0, 90], [3, 1]), 18.4349488, places=5)
|
||||
|
||||
def test_contact_states_and_open_fraction(self):
|
||||
self.assertEqual([wm.contact_open(s) for s in ('on', 'off', 'unknown', 'unavailable', None)],
|
||||
[True, False, None, None, None])
|
||||
self.assertEqual([wm.contact_open(s, inverted=True) for s in ('on', 'off')], [False, True])
|
||||
self.assertEqual(wm.open_fraction(True, True, 0, 100, 400), 1.0)
|
||||
self.assertEqual(wm.open_fraction(False, False, 0, 100, 400), 0.0)
|
||||
self.assertAlmostEqual(wm.open_fraction(False, True, 220, 100, 400), 0.6) # opened 120 s in
|
||||
self.assertAlmostEqual(wm.open_fraction(True, False, 220, 100, 400), 0.4) # closed 120 s in
|
||||
self.assertIsNone(wm.open_fraction(False, False, 220, 100, 400)) # round trip: unknown
|
||||
self.assertIsNone(wm.open_fraction(None, True, 0, 100, 400))
|
||||
|
||||
def test_ventilation_vector(self):
|
||||
features = list(wm.VENTILATION_FEATURES)
|
||||
inputs = {'opening': 0.5, 'facing': -0.3, 'other_opening': 1.0, 'recovery': 0.0, 't_ref': None,
|
||||
'wind': 4.0, 'gust': 9.0}
|
||||
for value, expected in zip(wm.ventilation_vector(features, 20.0, 2.0, inputs),
|
||||
[-9.0, -36.0, 21.6, -45.0, -9.0, 0.0]):
|
||||
self.assertAlmostEqual(value, expected)
|
||||
self.assertIsNone(wm.ventilation_vector(['vent_wind'], 20, 2, {**inputs, 'wind': None}))
|
||||
self.assertEqual(wm.ventilation_vector(['vent_natural'], 20, 2, {**inputs, 'wind': None}), [-9.0])
|
||||
self.assertIsNone(wm.ventilation_vector(['recovery'], 20, 2, {**inputs, 'recovery': 0.5}))
|
||||
self.assertEqual(wm.ventilation_vector(['recovery'], 19, 2, {**inputs, 'recovery': 0.5, 't_ref': 21}),
|
||||
[1.0])
|
||||
|
||||
def test_window_config_validation(self):
|
||||
built = wm.build_windows([
|
||||
(window('a', azimuth=705), None), (window('b', opening_factor=9), None),
|
||||
(window('c', zone='gone'), None), (window('a'), None),
|
||||
({**window('d'), 'sensor': 'sensor.d'}, None), (window('e', inverted=True), 's5')], ['bed'])
|
||||
self.assertEqual([(w.key, w.azimuth, w.inverted) for w in built], [('a', 345.0, False), ('e', 345.0, True)])
|
||||
self.assertEqual(wm.validate_window({'name': ' '}, built), 'window_name_required')
|
||||
self.assertEqual(wm.validate_window({'name': 'x', 'sensor': 'binary_sensor.a'}, built),
|
||||
'sensor_in_other_window')
|
||||
self.assertIsNone(wm.validate_window({'name': 'x', 'sensor': 'binary_sensor.x'}, built))
|
||||
|
||||
|
||||
class TrackerTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tracker = wm.VentilationTracker(wm.build_windows([(window('bed_window'), None)], ['bed']),
|
||||
recovery_minutes=20)
|
||||
self.t = 1_000_000.0
|
||||
self.changed = 0.0
|
||||
|
||||
def poll(self, state, temp, changed_offset=None, weather=WEATHER):
|
||||
if changed_offset is not None:
|
||||
self.changed = self.t - changed_offset
|
||||
self.tracker.update(self.t, {'bed_window': (state, self.changed)}, {'bed': temp}, weather)
|
||||
inputs = self.tracker.zone_inputs('bed', weather)
|
||||
self.t += 300
|
||||
return inputs
|
||||
|
||||
def test_ventilation_event_and_recovery(self):
|
||||
self.assertFalse(self.poll('off', 21.0)['known']) # no previous poll yet
|
||||
closed = self.poll('off', 21.0)
|
||||
self.assertEqual((closed['opening'], closed['affected']), (0.0, False))
|
||||
opened = self.poll('on', 20.6, changed_offset=180) # opened 3 minutes ago
|
||||
self.assertAlmostEqual(opened['opening'], 0.6)
|
||||
self.assertAlmostEqual(opened['facing'], 0.6 * cos(radians(141)))
|
||||
self.assertTrue(opened['affected'])
|
||||
self.assertEqual(opened['t_ref'], 21.0) # temperature at the poll before opening
|
||||
for temp in (20.2, 19.9):
|
||||
self.assertEqual(self.poll('on', temp)['opening'], 1.0)
|
||||
closing = self.poll('off', 20.0, changed_offset=60) # closed a minute ago
|
||||
self.assertAlmostEqual(closing['opening'], 0.8)
|
||||
self.assertAlmostEqual(closing['recovery'], exp(-60 / 1200))
|
||||
after = [self.poll('off', 20.0 + 0.1 * k) for k in range(12)]
|
||||
self.assertTrue(after[0]['affected'] and after[0]['recovery'] > 0.7)
|
||||
self.assertEqual((after[-1]['recovery'], after[-1]['affected']), (0.0, False)) # ~3 time constants
|
||||
event = self.tracker.events[-1]
|
||||
self.assertEqual((event['temperature_before'], event['lowest_temperature'], event['temperature_at_close']),
|
||||
(21.0, 19.9, 20.0))
|
||||
self.assertEqual((event['duration_minutes'], event['wind_direction'], event['windows']), (17.0, 204.0, ['Bed_Window'])) # 3 min + 15 min - 1 min
|
||||
self.assertEqual(event['temperature_after_minutes'], 30)
|
||||
restored = wm.VentilationTracker(self.tracker.windows, saved=self.tracker.as_dict())
|
||||
self.assertEqual(list(restored.events), list(self.tracker.events))
|
||||
|
||||
def test_unknown_state_blocks_learning_and_multiple_windows_add(self):
|
||||
tracker = wm.VentilationTracker(wm.build_windows(
|
||||
[(window('w1'), None), (window('w2', opening_factor=0.5), None), (window('w3', zone='living'), None)],
|
||||
['bed', 'living']))
|
||||
for t in (0.0, 300.0):
|
||||
tracker.update(t, {'w1': ('on', -10), 'w2': ('on', -10), 'w3': ('on', -10)}, {'bed': 20, 'living': 21},
|
||||
WEATHER)
|
||||
bed = tracker.zone_inputs('bed', WEATHER)
|
||||
self.assertEqual((bed['opening'], bed['other_opening']), (1.5, 1.0)) # living's window is open too
|
||||
tracker.update(600.0, {'w1': ('unavailable', 550), 'w2': ('on', -10), 'w3': ('off', 400)},
|
||||
{'bed': 20, 'living': 21}, WEATHER)
|
||||
bed = tracker.zone_inputs('bed', WEATHER)
|
||||
self.assertEqual((bed['opening'], bed['known'], bed['affected']), (None, False, True))
|
||||
self.assertIsNone(tracker.zone_inputs('kitchen', WEATHER))
|
||||
|
||||
def test_forecast_assumes_closing_then_recovery(self):
|
||||
self.poll('off', 21.0)
|
||||
self.poll('off', 21.0)
|
||||
self.poll('on', 20.5, changed_offset=100)
|
||||
steps = self.tracker.forecast('bed', 12, open_minutes=15, weather=WEATHER, now=self.t)
|
||||
self.assertEqual([s['opening'] for s in steps[:4]], [1.0, 1.0, 1.0, 0.0])
|
||||
self.assertAlmostEqual(steps[0]['facing'], cos(radians(141)))
|
||||
self.assertAlmostEqual(steps[3]['recovery'], exp(-300 / 1200))
|
||||
self.assertGreater(steps[3]['recovery'], steps[6]['recovery'])
|
||||
self.assertEqual(steps[5]['t_ref'], 21.0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user