diff --git a/apps/predbat/solcast.py b/apps/predbat/solcast.py index c24ca2838..9cc2f60da 100644 --- a/apps/predbat/solcast.py +++ b/apps/predbat/solcast.py @@ -511,7 +511,7 @@ def publish_pv_stats(self, pv_forecast_data, divide_by, period): power_now90 = 0 power_nowCL = 0 - point_gap = 30 + point_gap = period for entry in pv_forecast_data: if "period_start" not in entry: continue @@ -887,12 +887,40 @@ async def fetch_pv_forecast(self): # We want to divide the data into single minute slots divide_by = dp2(30 * factor) - if factor != 1.0 and factor != 2.0: + # Valid factor values: 1.0 = kWh per slot (any interval), 2.0 = kW per 30-min slot, 4.0 = kW per 15-min slot + if factor not in [1.0, 2.0, 4.0]: self.log("Warn: PV Forecast today adds up to {} kWh, but total sensors add up to {} kWh, this is unexpected and hence data maybe misleading (factor {})".format(pv_forecast_total_data, pv_forecast_total_sensor, factor)) else: self.log("PV Forecast today adds up to {} kWh, and total sensors add up to {} kWh, factor is {}".format(pv_forecast_total_data, pv_forecast_total_sensor, factor)) if pv_forecast_data: + # Detect the actual period of the forecast data (e.g. 15 or 30 minutes) + # by examining the time difference between consecutive entries. + # This ensures 15-minute resolution data is handled correctly. + period = 30 # Default period in minutes + if len(pv_forecast_data) >= 2: + try: + t0 = datetime.strptime(pv_forecast_data[0]["period_start"], TIME_FORMAT) + t1 = datetime.strptime(pv_forecast_data[1]["period_start"], TIME_FORMAT) + detected_period = int(abs((t1 - t0).total_seconds() / 60)) + # Sanity-check: only accept periods in the plausible range for forecast data. + # Values outside 5–60 minutes (e.g. 1440 if the first two entries span a day + # boundary when multiple sensor days are concatenated) are treated as invalid. + if 5 <= detected_period <= 60: + period = detected_period + except (ValueError, TypeError, KeyError): + pass + + # For the HA sensor path the divide_by was computed assuming 30-minute periods; + # recalculate it using the actual detected period so that the per-minute kWh + # values are correctly scaled regardless of the forecast resolution. + if not self.forecast_solar and not (self.solcast_host and self.solcast_api_key): + factor = divide_by / 30.0 + divide_by = dp2(period * factor) + + if period != 30: + self.log("PV Forecast data has {} minute resolution, adjusting calculations".format(period)) + pv_forecast_minute, _ = minute_data( pv_forecast_data, self.forecast_days, @@ -902,7 +930,7 @@ async def fetch_pv_forecast(self): backwards=False, divide_by=divide_by, scale=self.pv_scaling, - spreading=30, + spreading=period, ) pv_forecast_minute10, _ = minute_data( pv_forecast_data, @@ -913,12 +941,12 @@ async def fetch_pv_forecast(self): backwards=False, divide_by=divide_by, scale=self.pv_scaling, - spreading=30, + spreading=period, ) # Run calibration on the data - pv_forecast_minute, pv_forecast_minute10, pv_forecast_data = self.pv_calibration(pv_forecast_minute, pv_forecast_minute10, pv_forecast_data, create_pv10, divide_by / 30.0, max_kwh) - self.publish_pv_stats(pv_forecast_data, divide_by / 30.0, 30) + pv_forecast_minute, pv_forecast_minute10, pv_forecast_data = self.pv_calibration(pv_forecast_minute, pv_forecast_minute10, pv_forecast_data, create_pv10, divide_by / period, max_kwh) + self.publish_pv_stats(pv_forecast_data, divide_by / period, period) self.pack_and_store_forecast(pv_forecast_minute, pv_forecast_minute10) self.update_success_timestamp() 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 dad67e127..cb9703ffc 100644 --- a/apps/predbat/tests/test_solcast.py +++ b/apps/predbat/tests/test_solcast.py @@ -1387,6 +1387,188 @@ def create_mock_session(*args, **kwargs): return failed +# ============================================================================ +# 15-minute resolution tests +# ============================================================================ + + +def test_fetch_pv_forecast_ha_sensors_15min_kwh(my_predbat): + """ + Integration test: fetch_pv_forecast using HA sensors with 15-minute kWh resolution data. + Verifies that the energy totals are correct (not halved) when 15-minute data is used. + Each pv_estimate entry is in kWh for the 15-minute period. + """ + print(" - test_fetch_pv_forecast_ha_sensors_15min_kwh") + failed = False + + test_api = create_test_solar_api() + try: + test_api.solar.solcast_host = None + test_api.solar.solcast_api_key = None + test_api.solar.forecast_solar = None + test_api.solar.pv_forecast_today = "sensor.pv_forecast_today" + test_api.solar.pv_forecast_tomorrow = None + + # 15-minute resolution data - pv_estimate is kWh per 15-min slot + # 4 slots of 0.25 kWh each = 1.0 kWh total (matching sensor state) + forecast_data_15min = [ + {"period_start": "2025-06-15T10:00:00+0000", "pv_estimate": 0.25}, + {"period_start": "2025-06-15T10:15:00+0000", "pv_estimate": 0.25}, + {"period_start": "2025-06-15T10:30:00+0000", "pv_estimate": 0.25}, + {"period_start": "2025-06-15T10:45:00+0000", "pv_estimate": 0.25}, + ] + # Sensor state = total daily kWh = 1.0 + test_api.set_mock_ha_state( + "sensor.pv_forecast_today", + { + "state": "1.0", + "detailedForecast": forecast_data_15min, + }, + ) + + def create_mock_session(*args, **kwargs): + return test_api.mock_aiohttp_session() + + with patch("solcast.aiohttp.ClientSession", side_effect=create_mock_session): + run_async(test_api.solar.fetch_pv_forecast()) + + # Verify dashboard items were published + today_entity = f"sensor.{test_api.mock_base.prefix}_pv_today" + if today_entity not in test_api.dashboard_items: + print(f"ERROR: Expected {today_entity} to be published") + failed = True + else: + today_item = test_api.dashboard_items[today_entity] + total = today_item["attributes"].get("total", 0) + # Total should be 4 * 0.25 = 1.0 kWh, NOT 0.5 kWh (which would indicate the bug) + expected_total = 1.0 + if abs(total - expected_total) > 0.05: + print(f"ERROR: Expected today total ~{expected_total} kWh with 15-min data, got {total} kWh (possible 15-min handling bug)") + failed = True + else: + print(f" 15-min kWh data: total={total} kWh (expected {expected_total}) - correct!") + + # Verify forecast_raw entity was published + if f"sensor.{test_api.mock_base.prefix}_pv_forecast_raw" not in test_api.dashboard_items: + print(f"ERROR: Expected pv_forecast_raw entity to be published") + failed = True + + finally: + test_api.cleanup() + + return failed + + +def test_fetch_pv_forecast_ha_sensors_15min_kw(my_predbat): + """ + Integration test: fetch_pv_forecast using HA sensors with 15-minute kW resolution data. + Verifies that energy totals are correct when pv_estimate is in kW (new Solcast style) + with 15-minute period resolution. + """ + print(" - test_fetch_pv_forecast_ha_sensors_15min_kw") + failed = False + + test_api = create_test_solar_api() + try: + test_api.solar.solcast_host = None + test_api.solar.solcast_api_key = None + test_api.solar.forecast_solar = None + test_api.solar.pv_forecast_today = "sensor.pv_forecast_today" + test_api.solar.pv_forecast_tomorrow = None + + # 15-minute resolution data - pv_estimate is kW (power), not kWh + # 4 slots of 1.0 kW each = 4 * 0.25h * 1.0 kW = 1.0 kWh total + # sum(pv_estimates) = 4.0, sensor state = 1.0 kWh => factor = 4.0 + forecast_data_15min_kw = [ + {"period_start": "2025-06-15T10:00:00+0000", "pv_estimate": 1.0}, + {"period_start": "2025-06-15T10:15:00+0000", "pv_estimate": 1.0}, + {"period_start": "2025-06-15T10:30:00+0000", "pv_estimate": 1.0}, + {"period_start": "2025-06-15T10:45:00+0000", "pv_estimate": 1.0}, + ] + # Sensor state = total daily kWh = 1.0 + test_api.set_mock_ha_state( + "sensor.pv_forecast_today", + { + "state": "1.0", + "detailedForecast": forecast_data_15min_kw, + }, + ) + + def create_mock_session(*args, **kwargs): + return test_api.mock_aiohttp_session() + + with patch("solcast.aiohttp.ClientSession", side_effect=create_mock_session): + run_async(test_api.solar.fetch_pv_forecast()) + + today_entity = f"sensor.{test_api.mock_base.prefix}_pv_today" + if today_entity not in test_api.dashboard_items: + print(f"ERROR: Expected {today_entity} to be published") + failed = True + else: + today_item = test_api.dashboard_items[today_entity] + total = today_item["attributes"].get("total", 0) + # Total should be 1.0 kWh (4 slots * 1.0 kW * 0.25h), NOT 0.5 kWh + expected_total = 1.0 + if abs(total - expected_total) > 0.05: + print(f"ERROR: Expected today total ~{expected_total} kWh with 15-min kW data, got {total} kWh (possible 15-min handling bug)") + failed = True + else: + print(f" 15-min kW data: total={total} kWh (expected {expected_total}) - correct!") + + if f"sensor.{test_api.mock_base.prefix}_pv_forecast_raw" not in test_api.dashboard_items: + print(f"ERROR: Expected pv_forecast_raw entity to be published") + failed = True + + finally: + test_api.cleanup() + + return failed + + +def test_publish_pv_stats_15min_resolution(my_predbat): + """ + Test publish_pv_stats with 15-minute period data correctly uses point_gap=15 + and computes totals correctly. + """ + print(" - test_publish_pv_stats_15min_resolution") + failed = False + + test_api = create_test_solar_api() + try: + # 15-minute resolution data - divide_by=1.0, period=15 + # pv_estimate already in kWh per slot: 0.25 kWh each + pv_forecast_data = [ + {"period_start": "2025-06-15T06:00:00+0000", "pv_estimate": 0.25}, + {"period_start": "2025-06-15T06:15:00+0000", "pv_estimate": 0.25}, + {"period_start": "2025-06-15T06:30:00+0000", "pv_estimate": 0.25}, + {"period_start": "2025-06-15T06:45:00+0000", "pv_estimate": 0.25}, + {"period_start": "2025-06-15T12:00:00+0000", "pv_estimate": 0.5}, + {"period_start": "2025-06-15T12:15:00+0000", "pv_estimate": 0.5}, + ] + + test_api.solar.publish_pv_stats(pv_forecast_data, divide_by=1.0, period=15) + + today_entity = f"sensor.{test_api.mock_base.prefix}_pv_today" + if today_entity not in test_api.dashboard_items: + print(f"ERROR: Expected {today_entity} to be published") + failed = True + else: + today_item = test_api.dashboard_items[today_entity] + total = today_item["attributes"].get("total", 0) + # Total = 4*0.25 + 2*0.5 = 1.0 + 1.0 = 2.0 kWh + expected_total = 2.0 + if abs(total - expected_total) > 0.05: + print(f"ERROR: Expected today total ~{expected_total}, got {total}") + failed = True + else: + print(f" publish_pv_stats 15-min: total={total} kWh (expected {expected_total}) - correct!") + + finally: + test_api.cleanup() + + return failed + + # ============================================================================ # Run Function Tests # ============================================================================ @@ -1659,4 +1841,9 @@ def run_solcast_tests(my_predbat): failed |= test_fetch_pv_forecast_forecast_solar(my_predbat) failed |= test_fetch_pv_forecast_ha_sensors(my_predbat) + # 15-minute resolution tests + failed |= test_fetch_pv_forecast_ha_sensors_15min_kwh(my_predbat) + failed |= test_fetch_pv_forecast_ha_sensors_15min_kw(my_predbat) + failed |= test_publish_pv_stats_15min_resolution(my_predbat) + return failed