diff --git a/apps/predbat/solcast.py b/apps/predbat/solcast.py index c78719548..659d11250 100644 --- a/apps/predbat/solcast.py +++ b/apps/predbat/solcast.py @@ -55,14 +55,23 @@ def initialize(self, solcast_host, solcast_api_key, solcast_sites, solcast_poll_ self.forecast_solar_failures_total = 0 self.solcast_last_success_timestamp = None self.forecast_solar_last_success_timestamp = None + self.last_fetched_timestamp = None self.forecast_days = 4 async def run(self, seconds, first): """ Run the Solar API """ + fetch_age = 9999 + same_day = False + if self.last_fetched_timestamp: + fetch_age = (self.now_utc_exact - self.last_fetched_timestamp).total_seconds() / 60 + same_day = self.last_fetched_timestamp.date() == self.now_utc_exact.date() + if seconds % (self.plan_interval_minutes * 60) == 0: # Every plan_interval_minutes await self.fetch_pv_forecast() + elif not same_day or (fetch_age > 60): # If data is older than 60 minutes or it's a new day, fetch new data + await self.fetch_pv_forecast() return True async def cache_get_url(self, url, params, max_age=8 * 60): @@ -879,5 +888,7 @@ async def fetch_pv_forecast(self): self.publish_pv_stats(pv_forecast_data, divide_by / 30.0, 30) self.pack_and_store_forecast(pv_forecast_minute, pv_forecast_minute10) self.update_success_timestamp() + self.last_fetched_timestamp = self.now_utc_exact else: self.log("Warn: No solar data has been configured.") + self.last_fetched_timestamp = self.now_utc_exact diff --git a/apps/predbat/tests/test_solcast.py b/apps/predbat/tests/test_solcast.py index 87d32ba6f..2cca5f9ae 100644 --- a/apps/predbat/tests/test_solcast.py +++ b/apps/predbat/tests/test_solcast.py @@ -33,6 +33,7 @@ def __init__(self): self.prefix = "predbat" self.local_tz = pytz.timezone("Europe/London") self.now_utc = datetime(2025, 6, 15, 12, 0, 0, tzinfo=pytz.utc) + self.now_utc_exact = datetime(2025, 6, 15, 12, 0, 0, tzinfo=pytz.utc) self.midnight_utc = datetime(2025, 6, 15, 0, 0, 0, tzinfo=pytz.utc) self.minutes_now = 12 * 60 # 12:00 self.forecast_days = 4 @@ -157,6 +158,10 @@ def cleanup(self): if self.mock_base.config_root and os.path.exists(self.mock_base.config_root): shutil.rmtree(self.mock_base.config_root) + def patch_now_utc_exact(self): + """Return a context manager that patches now_utc_exact to use mock_base value""" + return patch.object(type(self.solar), "now_utc_exact", new_callable=lambda: property(lambda self: self.base.now_utc_exact)) + def set_mock_response(self, url_substring, response, status_code=200): """Set a mock HTTP response for URLs containing the substring""" self.mock_responses[url_substring] = {"data": response, "status_code": status_code} @@ -1382,6 +1387,223 @@ def create_mock_session(*args, **kwargs): return failed +# ============================================================================ +# Run Function Tests +# ============================================================================ + + +def test_run_at_plan_interval(my_predbat): + """ + Test SolarAPI.run() calls fetch_pv_forecast when seconds matches plan_interval_minutes. + """ + print(" - test_run_at_plan_interval") + failed = False + + test_api = create_test_solar_api() + try: + # Setup: plan_interval_minutes = 5, so should fetch at seconds % (5*60) == 0 + test_api.mock_base.plan_interval_minutes = 5 + test_api.solar.last_fetched_timestamp = None + + # Patch now_utc_exact to return a fixed value from mock_base + with test_api.patch_now_utc_exact(): + # Mock fetch_pv_forecast to track if it was called + fetch_called = [] + + async def mock_fetch_pv_forecast(): + fetch_called.append(True) + + test_api.solar.fetch_pv_forecast = mock_fetch_pv_forecast + + # Test 1: seconds = 300 (5 minutes) should trigger fetch + result = run_async(test_api.solar.run(seconds=300, first=False)) + if not result: + print(f"ERROR: run() returned False, expected True") + failed = True + if len(fetch_called) != 1: + print(f"ERROR: fetch_pv_forecast should be called at seconds=300, call count: {len(fetch_called)}") + failed = True + + # Test 2: seconds = 150 (2.5 minutes) should NOT trigger fetch + # Set a recent timestamp so fetch_age is small + from datetime import timedelta + + test_api.solar.last_fetched_timestamp = test_api.mock_base.now_utc_exact - timedelta(minutes=1) + fetch_called.clear() + result = run_async(test_api.solar.run(seconds=150, first=False)) + if not result: + print(f"ERROR: run() returned False, expected True") + failed = True + if len(fetch_called) != 0: + print(f"ERROR: fetch_pv_forecast should NOT be called at seconds=150, call count: {len(fetch_called)}") + failed = True + + finally: + test_api.cleanup() + + return failed + + +def test_run_new_day_trigger(my_predbat): + """ + Test SolarAPI.run() fetches data when it's a new day. + """ + print(" - test_run_new_day_trigger") + failed = False + + test_api = create_test_solar_api() + try: + # Setup: plan_interval_minutes = 5 + test_api.mock_base.plan_interval_minutes = 5 + + with test_api.patch_now_utc_exact(): + # Mock fetch_pv_forecast to track if it was called + fetch_called = [] + + async def mock_fetch_pv_forecast(): + fetch_called.append(True) + + test_api.solar.fetch_pv_forecast = mock_fetch_pv_forecast + + # Set last_fetched_timestamp to yesterday + from datetime import timedelta + + test_api.solar.last_fetched_timestamp = test_api.mock_base.now_utc_exact - timedelta(days=1) + + # Test: seconds not at interval (e.g., 150), but new day should trigger fetch + result = run_async(test_api.solar.run(seconds=150, first=False)) + if not result: + print(f"ERROR: run() returned False, expected True") + failed = True + if len(fetch_called) != 1: + print(f"ERROR: fetch_pv_forecast should be called on new day, call count: {len(fetch_called)}") + failed = True + + finally: + test_api.cleanup() + + return failed + + +def test_run_data_older_than_60_minutes(my_predbat): + """ + Test SolarAPI.run() fetches data when last fetch was over 60 minutes ago. + """ + print(" - test_run_data_older_than_60_minutes") + failed = False + + test_api = create_test_solar_api() + try: + # Setup: plan_interval_minutes = 5 + test_api.mock_base.plan_interval_minutes = 5 + + with test_api.patch_now_utc_exact(): + # Mock fetch_pv_forecast to track if it was called + fetch_called = [] + + async def mock_fetch_pv_forecast(): + fetch_called.append(True) + + test_api.solar.fetch_pv_forecast = mock_fetch_pv_forecast + + # Set last_fetched_timestamp to 61 minutes ago (same day) + from datetime import timedelta + + test_api.solar.last_fetched_timestamp = test_api.mock_base.now_utc_exact - timedelta(minutes=61) + + # Test: seconds not at interval (e.g., 150), but data older than 60 min should trigger fetch + result = run_async(test_api.solar.run(seconds=150, first=False)) + if not result: + print(f"ERROR: run() returned False, expected True") + failed = True + if len(fetch_called) != 1: + print(f"ERROR: fetch_pv_forecast should be called when data > 60 min old, call count: {len(fetch_called)}") + failed = True + + finally: + test_api.cleanup() + + return failed + + +def test_run_no_fetch_when_recent(my_predbat): + """ + Test SolarAPI.run() does NOT fetch when data is recent and not at interval. + """ + print(" - test_run_no_fetch_when_recent") + failed = False + + test_api = create_test_solar_api() + try: + # Setup: plan_interval_minutes = 5 + test_api.mock_base.plan_interval_minutes = 5 + + with test_api.patch_now_utc_exact(): + # Mock fetch_pv_forecast to track if it was called + fetch_called = [] + + async def mock_fetch_pv_forecast(): + fetch_called.append(True) + + test_api.solar.fetch_pv_forecast = mock_fetch_pv_forecast + + # Set last_fetched_timestamp to 30 minutes ago (same day, within 60 min) + from datetime import timedelta + + test_api.solar.last_fetched_timestamp = test_api.mock_base.now_utc_exact - timedelta(minutes=30) + + # Test: seconds not at interval (e.g., 150), data is recent, should NOT trigger fetch + result = run_async(test_api.solar.run(seconds=150, first=False)) + if not result: + print(f"ERROR: run() returned False, expected True") + failed = True + if len(fetch_called) != 0: + print(f"ERROR: fetch_pv_forecast should NOT be called when data is recent, call count: {len(fetch_called)}") + failed = True + + finally: + test_api.cleanup() + + return failed + + +def test_run_first_fetch_when_no_timestamp(my_predbat): + """ + Test SolarAPI.run() fetches data when last_fetched_timestamp is None. + """ + print(" - test_run_first_fetch_when_no_timestamp") + failed = False + + test_api = create_test_solar_api() + try: + # Setup: plan_interval_minutes = 5 + test_api.mock_base.plan_interval_minutes = 5 + test_api.solar.last_fetched_timestamp = None + + with test_api.patch_now_utc_exact(): + # Mock fetch_pv_forecast to track if it was called + fetch_called = [] + + async def mock_fetch_pv_forecast(): + fetch_called.append(True) + + test_api.solar.fetch_pv_forecast = mock_fetch_pv_forecast + + # Test: seconds not at interval (e.g., 150), but no timestamp, should trigger fetch (fetch_age > 60) + result = run_async(test_api.solar.run(seconds=150, first=False)) + if not result: + print(f"ERROR: run() returned False, expected True") + failed = True + if len(fetch_called) != 1: + print(f"ERROR: fetch_pv_forecast should be called when last_fetched_timestamp is None, call count: {len(fetch_called)}") + failed = True + + finally: + test_api.cleanup() + + return failed + + # ============================================================================ # Main Test Runner # ============================================================================ @@ -1425,6 +1647,13 @@ def run_solcast_tests(my_predbat): # Pack and store tests failed |= test_pack_and_store_forecast(my_predbat) + # Run function tests + failed |= test_run_at_plan_interval(my_predbat) + failed |= test_run_new_day_trigger(my_predbat) + failed |= test_run_data_older_than_60_minutes(my_predbat) + failed |= test_run_no_fetch_when_recent(my_predbat) + failed |= test_run_first_fetch_when_no_timestamp(my_predbat) + # Integration tests (one per mode) failed |= test_fetch_pv_forecast_solcast_direct(my_predbat) failed |= test_fetch_pv_forecast_forecast_solar(my_predbat) diff --git a/apps/predbat/tests/test_temperature.py b/apps/predbat/tests/test_temperature.py index aa26b3ab4..5e83d96c3 100644 --- a/apps/predbat/tests/test_temperature.py +++ b/apps/predbat/tests/test_temperature.py @@ -24,6 +24,9 @@ from temperature import TemperatureAPI from datetime import datetime, timezone +from unittest.mock import patch, MagicMock, AsyncMock +import aiohttp +import asyncio class MockTemperatureAPI(TemperatureAPI): @@ -362,6 +365,647 @@ def _test_temperature_negative_timezone_offset(my_predbat): return 0 +def _test_fetch_temperature_data_success(my_predbat): + """Test successful fetch_temperature_data with valid API response""" + print(" Testing fetch_temperature_data with successful API response...") + + temp_component = MockTemperatureAPI( + temperature_latitude=51.5074, + temperature_longitude=-0.1278, + temperature_url="https://api.open-meteo.com/v1/forecast?latitude=LATITUDE&longitude=LONGITUDE&hourly=temperature_2m¤t=temperature_2m" + ) + + # Mock API response + mock_response_data = { + "latitude": 51.5, + "longitude": -0.12, + "utc_offset_seconds": 0, + "timezone": "GMT", + "current": { + "time": "2026-02-07T10:30", + "temperature_2m": 9.5 + }, + "hourly": { + "time": ["2026-02-07T00:00", "2026-02-07T01:00"], + "temperature_2m": [8.2, 8.5] + } + } + + async def run_test(): + # Create mock response + mock_response = MagicMock() + mock_response.status = 200 + mock_response.json = AsyncMock(return_value=mock_response_data) + + # Mock the context manager for response + mock_response.__aenter__ = AsyncMock(return_value=mock_response) + mock_response.__aexit__ = AsyncMock(return_value=None) + + # Mock session.get to return our mock response + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=mock_response) + + # Mock the context manager for session + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=None) + + with patch('aiohttp.ClientSession', return_value=mock_session): + result = await temp_component.fetch_temperature_data() + + if result is None: + print(" ERROR: fetch_temperature_data returned None") + return 1 + + if result != mock_response_data: + print(" ERROR: Incorrect data returned") + return 1 + + # Check that success timestamp was updated + if temp_component._last_updated_time is None: + print(" ERROR: Success timestamp not updated") + return 1 + + print(" PASS: Successful fetch returns correct data") + return 0 + + return asyncio.run(run_test()) + + +def _test_fetch_temperature_data_http_error_with_retry(my_predbat): + """Test fetch_temperature_data handles HTTP errors with retry logic""" + print(" Testing fetch_temperature_data with HTTP error and retry...") + + temp_component = MockTemperatureAPI( + temperature_latitude=51.5074, + temperature_longitude=-0.1278, + temperature_url="https://api.open-meteo.com/v1/forecast?latitude=LATITUDE&longitude=LONGITUDE&hourly=temperature_2m¤t=temperature_2m" + ) + + call_count = [0] + sleep_called = [False] + + async def mock_sleep(seconds): + sleep_called[0] = True + + def create_mock_get(): + def mock_get_fn(url): + call_count[0] += 1 + mock_response = MagicMock() + if call_count[0] < 2: + # First call fails with 500 + mock_response.status = 500 + else: + # Second call succeeds + mock_response.status = 200 + mock_response.json = AsyncMock(return_value={"current": {"temperature_2m": 10.0}}) + + mock_response.__aenter__ = AsyncMock(return_value=mock_response) + mock_response.__aexit__ = AsyncMock(return_value=None) + return mock_response + return mock_get_fn + + async def run_test(): + mock_session = MagicMock() + mock_session.get = MagicMock(side_effect=create_mock_get()) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=None) + + # Patch at module level where it's imported + with patch('temperature.aiohttp.ClientSession', return_value=mock_session): + with patch('temperature.asyncio.sleep', side_effect=mock_sleep): + result = await temp_component.fetch_temperature_data() + + if result is None: + print(" ERROR: fetch_temperature_data returned None after retry (call_count={})".format(call_count[0])) + return 1 + + if call_count[0] < 2: + print(" ERROR: Retry logic not triggered, call_count={}".format(call_count[0])) + return 1 + + if not sleep_called[0]: + print(" ERROR: asyncio.sleep not called during retry") + return 1 + + print(" PASS: HTTP error triggers retry and succeeds") + return 0 + + return asyncio.run(run_test()) + + +def _test_fetch_temperature_data_max_retries_exceeded(my_predbat): + """Test fetch_temperature_data returns None after max retries exceeded""" + print(" Testing fetch_temperature_data with max retries exceeded...") + + temp_component = MockTemperatureAPI( + temperature_latitude=51.5074, + temperature_longitude=-0.1278, + temperature_url="https://api.open-meteo.com/v1/forecast?latitude=LATITUDE&longitude=LONGITUDE&hourly=temperature_2m¤t=temperature_2m" + ) + + call_count = [0] + + def create_mock_response(): + call_count[0] += 1 + mock_response = MagicMock() + mock_response.status = 503 # Service unavailable + mock_response.__aenter__ = AsyncMock(return_value=mock_response) + mock_response.__aexit__ = AsyncMock(return_value=None) + return mock_response + + async def run_test(): + mock_session = MagicMock() + mock_session.get = MagicMock(side_effect=lambda url: create_mock_response()) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=None) + + initial_failures = temp_component.failures_total + + with patch('temperature.aiohttp.ClientSession', return_value=mock_session): + with patch('temperature.asyncio.sleep', new_callable=AsyncMock): + result = await temp_component.fetch_temperature_data() + + if result is not None: + print(" ERROR: Expected None after max retries, got data") + return 1 + + # Should have attempted 3 times + if call_count[0] != 3: + print(" ERROR: Expected 3 retry attempts, got {}".format(call_count[0])) + return 1 + + # Failures counter should increment + if temp_component.failures_total != initial_failures + 1: + print(" ERROR: failures_total not incremented correctly") + return 1 + + print(" PASS: Max retries exceeded returns None and increments failure counter") + return 0 + + return asyncio.run(run_test()) + + +def _test_fetch_temperature_data_network_error(my_predbat): + """Test fetch_temperature_data handles network errors with retry""" + print(" Testing fetch_temperature_data with network error...") + + temp_component = MockTemperatureAPI( + temperature_latitude=51.5074, + temperature_longitude=-0.1278, + temperature_url="https://api.open-meteo.com/v1/forecast?latitude=LATITUDE&longitude=LONGITUDE&hourly=temperature_2m¤t=temperature_2m" + ) + + call_count = [0] + + def create_mock_response(): + call_count[0] += 1 + if call_count[0] < 2: + # First call raises network error + raise aiohttp.ClientError("Connection refused") + else: + # Second call succeeds + mock_response = MagicMock() + mock_response.status = 200 + mock_response.json = AsyncMock(return_value={"current": {"temperature_2m": 12.0}}) + mock_response.__aenter__ = AsyncMock(return_value=mock_response) + mock_response.__aexit__ = AsyncMock(return_value=None) + return mock_response + + async def run_test(): + mock_session = MagicMock() + mock_session.get = MagicMock(side_effect=lambda url: create_mock_response()) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=None) + + with patch('temperature.aiohttp.ClientSession', return_value=mock_session): + with patch('temperature.asyncio.sleep', new_callable=AsyncMock): + result = await temp_component.fetch_temperature_data() + + if result is None: + print(" ERROR: Expected data after network error retry") + return 1 + + if call_count[0] < 2: + print(" ERROR: Retry not triggered after network error") + return 1 + + print(" PASS: Network error triggers retry and succeeds") + return 0 + + return asyncio.run(run_test()) + + +def _test_fetch_temperature_data_timeout_error(my_predbat): + """Test fetch_temperature_data handles timeout errors with retry""" + print(" Testing fetch_temperature_data with timeout error...") + + temp_component = MockTemperatureAPI( + temperature_latitude=51.5074, + temperature_longitude=-0.1278, + temperature_url="https://api.open-meteo.com/v1/forecast?latitude=LATITUDE&longitude=LONGITUDE&hourly=temperature_2m¤t=temperature_2m" + ) + + call_count = [0] + + def create_mock_response(): + call_count[0] += 1 + if call_count[0] < 2: + raise asyncio.TimeoutError("Request timed out") + else: + mock_response = MagicMock() + mock_response.status = 200 + mock_response.json = AsyncMock(return_value={"current": {"temperature_2m": 11.5}}) + mock_response.__aenter__ = AsyncMock(return_value=mock_response) + mock_response.__aexit__ = AsyncMock(return_value=None) + return mock_response + + async def run_test(): + mock_session = MagicMock() + mock_session.get = MagicMock(side_effect=lambda url: create_mock_response()) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=None) + + with patch('temperature.aiohttp.ClientSession', return_value=mock_session): + with patch('temperature.asyncio.sleep', new_callable=AsyncMock): + result = await temp_component.fetch_temperature_data() + + if result is None: + print(" ERROR: Expected data after timeout retry") + return 1 + + if call_count[0] < 2: + print(" ERROR: Retry not triggered after timeout") + return 1 + + print(" PASS: Timeout error triggers retry and succeeds") + return 0 + + return asyncio.run(run_test()) + + +def _test_fetch_temperature_data_missing_coordinates(my_predbat): + """Test fetch_temperature_data returns None when coordinates missing""" + print(" Testing fetch_temperature_data with missing coordinates...") + + # Initialize without coordinates + temp_component = MockTemperatureAPI( + temperature_latitude=None, + temperature_longitude=None, + temperature_url="https://api.open-meteo.com/v1/forecast?latitude=LATITUDE&longitude=LONGITUDE&hourly=temperature_2m¤t=temperature_2m" + ) + + # Don't set zone.home either + + async def run_test(): + result = await temp_component.fetch_temperature_data() + + if result is not None: + print(" ERROR: Expected None when coordinates missing") + return 1 + + print(" PASS: Returns None when coordinates missing") + return 0 + + return asyncio.run(run_test()) + + +def _test_fetch_temperature_data_exponential_backoff(my_predbat): + """Test that exponential backoff is used between retries""" + print(" Testing exponential backoff between retries...") + + temp_component = MockTemperatureAPI( + temperature_latitude=51.5074, + temperature_longitude=-0.1278, + temperature_url="https://api.open-meteo.com/v1/forecast?latitude=LATITUDE&longitude=LONGITUDE&hourly=temperature_2m¤t=temperature_2m" + ) + + sleep_times = [] + + async def mock_sleep(seconds): + sleep_times.append(seconds) + + call_count = [0] + + def create_mock_response(): + call_count[0] += 1 + raise aiohttp.ClientError("Network error") + + async def run_test(): + mock_session = MagicMock() + mock_session.get = MagicMock(side_effect=lambda url: create_mock_response()) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=None) + + with patch('temperature.aiohttp.ClientSession', return_value=mock_session): + with patch('temperature.asyncio.sleep', side_effect=mock_sleep): + result = await temp_component.fetch_temperature_data() + + # Should have 2 sleep calls (after 1st and 2nd attempts) + if len(sleep_times) != 2: + print(" ERROR: Expected 2 sleep calls, got {}".format(len(sleep_times))) + return 1 + + # Check exponential backoff: 2^0=1, 2^1=2 + if sleep_times[0] != 1: + print(" ERROR: First sleep should be 1 second, got {}".format(sleep_times[0])) + return 1 + + if sleep_times[1] != 2: + print(" ERROR: Second sleep should be 2 seconds, got {}".format(sleep_times[1])) + return 1 + + print(" PASS: Exponential backoff implemented correctly") + return 0 + + return asyncio.run(run_test()) + + +def _test_run_disabled(my_predbat=None): + """Test run method when temperature_enable is False""" + + def run_test(): + temp_api = MockTemperatureAPI( + temperature_latitude=51.5074, + temperature_longitude=-0.1278, + temperature_url="http://example.com/api" + ) + temp_api.temperature_enable = False + temp_api.fetch_temperature_data = MagicMock() + + result = asyncio.run(temp_api.run(seconds=0, first=True)) + + if not result: + print(" ERROR: run should return True when disabled") + return 1 + + if temp_api.fetch_temperature_data.called: + print(" ERROR: fetch_temperature_data should not be called when disabled") + return 1 + + print(" PASS: run returns True without fetching when disabled") + return 0 + + return run_test() + + +def _test_run_first_fetch(my_predbat=None): + """Test run method on first run (first=True)""" + + def run_test(): + async def async_test(): + temp_api = MockTemperatureAPI( + temperature_latitude=51.5074, + temperature_longitude=-0.1278, + temperature_url="http://example.com/api" + ) + temp_api.temperature_enable = True + temp_api.temperature_data = None + temp_api.publish_temperature_sensor = MagicMock() + temp_api.update_success_timestamp = MagicMock() + + # Mock fetch to return data + mock_data = {"temperature": 15.5, "forecast": []} + with patch.object(temp_api, 'fetch_temperature_data', new_callable=AsyncMock, return_value=mock_data): + result = await temp_api.run(seconds=1800, first=True) # Not at hourly boundary + + if not result: + print(" ERROR: run should return True") + return 1 + + if temp_api.temperature_data != mock_data: + print(" ERROR: temperature_data should be updated") + return 1 + + if temp_api.last_updated_timestamp is None: + print(" ERROR: last_updated_timestamp should be set") + return 1 + + if temp_api.publish_temperature_sensor.call_count != 2: + print(" ERROR: publish_temperature_sensor should be called twice (after fetch and after success)") + return 1 + + if not temp_api.update_success_timestamp.called: + print(" ERROR: update_success_timestamp should be called") + return 1 + + print(" PASS: first run fetches and publishes data") + return 0 + + return asyncio.run(async_test()) + + return run_test() + + +def _test_run_hourly_interval(my_predbat=None): + """Test run method at hourly interval (seconds % 3600 == 0)""" + + def run_test(): + async def async_test(): + temp_api = MockTemperatureAPI( + temperature_latitude=51.5074, + temperature_longitude=-0.1278, + temperature_url="http://example.com/api" + ) + temp_api.temperature_enable = True + temp_api.temperature_data = {"temperature": 10.0} + temp_api.publish_temperature_sensor = MagicMock() + temp_api.update_success_timestamp = MagicMock() + + mock_data = {"temperature": 15.5, "forecast": []} + with patch.object(temp_api, 'fetch_temperature_data', new_callable=AsyncMock, return_value=mock_data): + result = await temp_api.run(seconds=3600, first=False) # Exactly 1 hour + + if not result: + print(" ERROR: run should return True") + return 1 + + if temp_api.temperature_data != mock_data: + print(" ERROR: temperature_data should be updated at hourly interval") + return 1 + + print(" PASS: run fetches data at hourly interval") + return 0 + + return asyncio.run(async_test()) + + return run_test() + + +def _test_run_no_fetch_between_hours(my_predbat=None): + """Test run method does not fetch between hourly intervals""" + + def run_test(): + async def async_test(): + temp_api = MockTemperatureAPI( + temperature_latitude=51.5074, + temperature_longitude=-0.1278, + temperature_url="http://example.com/api" + ) + temp_api.temperature_enable = True + temp_api.temperature_data = {"temperature": 10.0} + temp_api.publish_temperature_sensor = MagicMock() + temp_api.update_success_timestamp = MagicMock() + + fetch_called = False + + async def mock_fetch(): + nonlocal fetch_called + fetch_called = True + return {"temperature": 20.0} + + with patch.object(temp_api, 'fetch_temperature_data', new_callable=AsyncMock, side_effect=mock_fetch): + result = await temp_api.run(seconds=1800, first=False) # 30 minutes + + if not result: + print(" ERROR: run should return True") + return 1 + + if fetch_called: + print(" ERROR: fetch should not be called between hourly intervals") + return 1 + + if not temp_api.publish_temperature_sensor.called: + print(" ERROR: publish_temperature_sensor should still be called for existing data") + return 1 + + if not temp_api.update_success_timestamp.called: + print(" ERROR: update_success_timestamp should be called for existing data") + return 1 + + print(" PASS: run does not fetch between hourly intervals but still publishes") + return 0 + + return asyncio.run(async_test()) + + return run_test() + + +def _test_run_fetch_returns_none(my_predbat=None): + """Test run method when fetch returns None""" + + def run_test(): + async def async_test(): + temp_api = MockTemperatureAPI( + temperature_latitude=51.5074, + temperature_longitude=-0.1278, + temperature_url="http://example.com/api" + ) + temp_api.temperature_enable = True + temp_api.temperature_data = None + temp_api.publish_temperature_sensor = MagicMock() + temp_api.update_success_timestamp = MagicMock() + + with patch.object(temp_api, 'fetch_temperature_data', new_callable=AsyncMock, return_value=None): + result = await temp_api.run(seconds=0, first=True) + + if not result: + print(" ERROR: run should return True even when fetch returns None") + return 1 + + if temp_api.temperature_data is not None: + print(" ERROR: temperature_data should remain None") + return 1 + + if temp_api.publish_temperature_sensor.called: + print(" ERROR: publish_temperature_sensor should not be called when no data") + return 1 + + if temp_api.update_success_timestamp.called: + print(" ERROR: update_success_timestamp should not be called when no data") + return 1 + + print(" PASS: run handles None return from fetch gracefully") + return 0 + + return asyncio.run(async_test()) + + return run_test() + + +def _test_run_exception_handling(my_predbat=None): + """Test run method exception handling""" + + def run_test(): + async def async_test(): + temp_api = MockTemperatureAPI( + temperature_latitude=51.5074, + temperature_longitude=-0.1278, + temperature_url="http://example.com/api" + ) + temp_api.temperature_enable = True + temp_api.temperature_data = {"temperature": 10.0} + temp_api.publish_temperature_sensor = MagicMock() + temp_api.update_success_timestamp = MagicMock() + temp_api.log = MagicMock() + + # Mock fetch to raise exception + with patch.object(temp_api, 'fetch_temperature_data', new_callable=AsyncMock, side_effect=Exception("API error")): + result = await temp_api.run(seconds=0, first=True) + + if not result: + print(" ERROR: run should return True even on exception") + return 1 + + if not temp_api.log.called: + print(" ERROR: log should be called with warning") + return 1 + + log_message = temp_api.log.call_args[0][0] + if "Exception in run loop" not in log_message: + print(" ERROR: log should mention exception in run loop") + return 1 + + if not temp_api.publish_temperature_sensor.called: + print(" ERROR: publish_temperature_sensor should be called to keep publishing old data") + return 1 + + print(" PASS: run handles exceptions gracefully and keeps publishing old data") + return 0 + + return asyncio.run(async_test()) + + return run_test() + + +def _test_run_exception_no_data(my_predbat=None): + """Test run method exception handling when no temperature data exists""" + + def run_test(): + async def async_test(): + temp_api = MockTemperatureAPI( + temperature_latitude=51.5074, + temperature_longitude=-0.1278, + temperature_url="http://example.com/api" + ) + temp_api.temperature_enable = True + temp_api.temperature_data = None + temp_api.publish_temperature_sensor = MagicMock() + temp_api.log = MagicMock() + + # Mock fetch to raise exception + with patch.object(temp_api, 'fetch_temperature_data', new_callable=AsyncMock, side_effect=Exception("API error")): + result = await temp_api.run(seconds=0, first=True) + + if not result: + print(" ERROR: run should return True even on exception with no data") + return 1 + + if not temp_api.log.called: + print(" ERROR: log should be called with warning") + return 1 + + if temp_api.publish_temperature_sensor.called: + print(" ERROR: publish_temperature_sensor should not be called when no data exists") + return 1 + + print(" PASS: run handles exception with no data gracefully") + return 0 + + return asyncio.run(async_test()) + + return run_test() + + def test_temperature(my_predbat=None): """ Comprehensive test suite for External Temperature API. @@ -385,6 +1029,20 @@ def test_temperature(my_predbat=None): ("sensor_creation", _test_temperature_sensor_creation, "Sensor creation with forecast data"), ("cache_persistence", _test_temperature_cache_persistence, "Cache persistence on failure"), ("negative_timezone", _test_temperature_negative_timezone_offset, "Negative timezone offset handling"), + ("fetch_success", _test_fetch_temperature_data_success, "fetch_temperature_data successful API call"), + ("fetch_http_error", _test_fetch_temperature_data_http_error_with_retry, "fetch_temperature_data HTTP error retry"), + ("fetch_max_retries", _test_fetch_temperature_data_max_retries_exceeded, "fetch_temperature_data max retries exceeded"), + ("fetch_network_error", _test_fetch_temperature_data_network_error, "fetch_temperature_data network error retry"), + ("fetch_timeout", _test_fetch_temperature_data_timeout_error, "fetch_temperature_data timeout retry"), + ("fetch_no_coords", _test_fetch_temperature_data_missing_coordinates, "fetch_temperature_data missing coordinates"), + ("fetch_backoff", _test_fetch_temperature_data_exponential_backoff, "fetch_temperature_data exponential backoff"), + ("run_disabled", _test_run_disabled, "run method when disabled"), + ("run_first", _test_run_first_fetch, "run method on first run"), + ("run_hourly", _test_run_hourly_interval, "run method at hourly interval"), + ("run_between_hours", _test_run_no_fetch_between_hours, "run method between hourly intervals"), + ("run_fetch_none", _test_run_fetch_returns_none, "run method when fetch returns None"), + ("run_exception", _test_run_exception_handling, "run method exception handling with data"), + ("run_exception_nodata", _test_run_exception_no_data, "run method exception handling without data"), ] print("\n" + "=" * 70) diff --git a/apps/predbat/web.py b/apps/predbat/web.py index 2ab56472e..155c00e71 100644 --- a/apps/predbat/web.py +++ b/apps/predbat/web.py @@ -2612,7 +2612,7 @@ def get_chart(self, chart): elif chart == "LoadMLPower": # Get historical load power load_power_hist = history_attribute(self.get_history_wrapper(self.prefix + ".load_power", 7, required=False)) - load_power = prune_today(load_power_hist, self.now_utc, self.midnight_utc, prune=False) + load_power = prune_today(load_power_hist, self.now_utc, self.midnight_utc, prune=True, prune_past_days=7) # Get ML predicted load energy (cumulative) and convert to power (kW) load_ml_forecast_energy = self.get_entity_results("sensor." + self.prefix + "_load_ml_forecast") @@ -2659,12 +2659,12 @@ def get_chart(self, chart): {"name": "Load Power (Actual)", "data": load_power, "opacity": "1.0", "stroke_width": "3", "stroke_curve": "smooth", "color": "#3291a8", "unit": "kW"}, {"name": "Load Power (ML Predicted Future)", "data": load_ml_forecast_power, "opacity": "0.5", "stroke_width": "3", "chart_type": "area", "stroke_curve": "smooth", "color": "#eb2323", "unit": "kW"}, {"name": "Load Power (Used)", "data": load_power_best, "opacity": "1.0", "stroke_width": "2", "stroke_curve": "smooth", "unit": "kW"}, - {"name": "Load Power ML History", "data": power_today, "opacity": "1.0", "stroke_width": "2", "stroke_curve": "smooth", "unit": "kW"}, - {"name": "Load Power ML History +1h", "data": power_today_h1, "opacity": "1.0", "stroke_width": "2", "stroke_curve": "smooth", "unit": "kW"}, - {"name": "Load Power ML History +8h", "data": power_today_h8, "opacity": "1.0", "stroke_width": "2", "stroke_curve": "smooth", "unit": "kW"}, + {"name": "Load Power ML History", "data": power_today, "opacity": "1.0", "stroke_width": "2", "stroke_curve": "smooth", "unit": "kW", "color": "#eb2323"}, + {"name": "Load Power ML History +1h", "data": power_today_h1, "opacity": "1.0", "stroke_width": "2", "stroke_curve": "smooth", "unit": "kW", "color": "#716d63"}, + {"name": "Load Power ML History +8h", "data": power_today_h8, "opacity": "1.0", "stroke_width": "2", "stroke_curve": "smooth", "unit": "kW", "color": "#a6a5a3"}, {"name": "PV Power (Actual)", "data": pv_power, "opacity": "1.0", "stroke_width": "3", "stroke_curve": "smooth", "color": "#f5c43d", "unit": "kW"}, {"name": "PV Power (Predicted)", "data": pv_power_best, "opacity": "0.7", "stroke_width": "2", "stroke_curve": "smooth", "chart_type": "area", "color": "#ffa500", "unit": "kW"}, - {"name": "Temperature", "data": temperature_forecast, "opacity": "1.0", "stroke_width": "2", "stroke_curve": "smooth", "color": "#ff6b6b", "unit": "°C"}, + {"name": "Temperature", "data": temperature_forecast, "opacity": "1.0", "stroke_width": "2", "stroke_curve": "smooth", "color": "#75ff6b", "unit": "°C"}, ] # Configure secondary axis for temperature