Skip to content
11 changes: 11 additions & 0 deletions apps/predbat/solcast.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Comment on lines +65 to +74
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

self.last_fetched_timestamp is set using self.now_utc_exact (timezone-aware), but run() compares it to datetime.now() (naive). Subtracting/comparing these will raise a TypeError at runtime. Use a consistent, tz-aware clock for both values (e.g., now = self.now_utc_exact or datetime.now(self.local_tz)), and avoid mixing naive/aware datetimes.

Copilot uses AI. Check for mistakes.
return True
Comment on lines 61 to 75
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new midnight/age-based refresh logic in run() is not covered by existing tests (current tests exercise fetch_pv_forecast() directly but not the scheduler behavior). Add a unit test that simulates a day rollover and verifies run() triggers a fetch shortly after midnight (and does not refetch every minute once updated). This will also prevent regressions around timezone handling.

Copilot uses AI. Check for mistakes.

async def cache_get_url(self, url, params, max_age=8 * 60):
Expand Down Expand Up @@ -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
229 changes: 229 additions & 0 deletions apps/predbat/tests/test_solcast.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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
# ============================================================================
Expand Down Expand Up @@ -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)
Expand Down
Loading