This commit is contained in:
1 parent
646ba3be81
commit
aae5b73581
14 files changed
+883
-142
No files matched your search
+142
-30
@@ -1,14 +1,16 @@
|
||||
from copy import deepcopy
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
import pyarrow.parquet as pq
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from solar_relay.config import HistoryConfig, utc_text
|
||||
from solar_relay.history import CHANNELS, HistoryRecovery, history_sample
|
||||
from solar_relay.history import CHANNELS, HistoryRecovery, chart_entries, history_sample
|
||||
from solar_relay.http import TransportError
|
||||
from solar_relay.solarweb import ChartDataError
|
||||
from solar_relay.storage import Store
|
||||
from tests.test_status import observed
|
||||
|
||||
@@ -22,13 +24,16 @@ def entry(when):
|
||||
for field, channel in CHANNELS.items()]}
|
||||
|
||||
|
||||
class FakeHistoryHttp:
|
||||
def __init__(self, entries):
|
||||
self.entries, self.requests = entries, []
|
||||
class FakeCharts:
|
||||
def __init__(self, when):
|
||||
self.points = {dataset: {int((when + timedelta(minutes=offset)).timestamp() * 1000): watts
|
||||
for offset in [0, 5, 10]}
|
||||
for dataset, watts in [("production", 1200), ("consumption", 1440)]}
|
||||
self.requests = []
|
||||
|
||||
def request(self, url, **kwargs):
|
||||
self.requests.append((url, kwargs))
|
||||
return {"pvSystemId": SYSTEM, "data": deepcopy(self.entries)}
|
||||
def get_chart(self, system_id, dataset, date):
|
||||
self.requests.append((system_id, dataset, date))
|
||||
return dict(self.points[dataset])
|
||||
|
||||
|
||||
class HistoryTests(unittest.TestCase):
|
||||
@@ -38,9 +43,9 @@ class HistoryTests(unittest.TestCase):
|
||||
self.store = Store(Path(self.directory.name) / "archive")
|
||||
self.when = datetime(2026, 10, 30, 10, tzinfo=timezone.utc)
|
||||
self.source = "test-house"
|
||||
self.http = FakeHistoryHttp([entry(self.when + timedelta(minutes=offset)) for offset in [0, 5, 10]])
|
||||
self.client = FakeCharts(self.when)
|
||||
self.config = HistoryConfig(enabled=True, pv_system_id=SYSTEM)
|
||||
self.recovery = HistoryRecovery(self.config, self.source, self.store, http=self.http, credentials=("id-test", "value-test"))
|
||||
self.recovery = HistoryRecovery(self.config, self.source, self.store, client=self.client)
|
||||
|
||||
def recover(self):
|
||||
return self.recovery.recover(utc_text(self.when), utc_text(self.when + timedelta(minutes=15)), now=self.when + timedelta(hours=4))
|
||||
@@ -56,12 +61,12 @@ class HistoryTests(unittest.TestCase):
|
||||
self.assertIsNone(recovered[0]["readings"]["boiler_enabled"])
|
||||
self.assertNotIn("boiler", recovered[0]["source_timestamps"])
|
||||
self.assertIn("boiler", recovered[0]["errors"])
|
||||
url, kwargs = self.http.requests[0]
|
||||
self.assertEqual(urlsplit(url).hostname, "swqapi.solarweb.com")
|
||||
self.assertEqual(kwargs["extra_headers"], {"AccessKeyId": "id-test", "AccessKeyValue": "value-test"})
|
||||
self.assertNotIn("value-test", url)
|
||||
self.assertEqual(self.client.requests, [(SYSTEM, dataset, self.when.date())
|
||||
for dataset in ("production", "consumption")])
|
||||
self.assertEqual(recovered[0]["raw"]["solarweb_history"]["chart_power_w"],
|
||||
{"production": 1200, "consumption": 1440})
|
||||
self.assertEqual(self.recover(), 0)
|
||||
self.assertEqual(len(self.http.requests), 1)
|
||||
self.assertEqual(len(self.client.requests), 2)
|
||||
|
||||
def test_cloud_energy_does_not_mark_boiler_or_meter_history_complete(self):
|
||||
self.assertEqual(self.recover(), 3)
|
||||
@@ -76,10 +81,11 @@ class HistoryTests(unittest.TestCase):
|
||||
self.assertEqual(row["interval_energy_wh"]["production"], 100)
|
||||
|
||||
def test_partial_cloud_records_remain_missing_for_retry(self):
|
||||
self.http.entries[1]["channels"][0]["value"] = None
|
||||
stamp = int((self.when + timedelta(minutes=5)).timestamp() * 1000)
|
||||
del self.client.points["production"][stamp]
|
||||
self.assertEqual(self.recover(), 2)
|
||||
self.assertEqual(self.recover(), 0)
|
||||
self.http.entries[1] = entry(self.when + timedelta(minutes=5))
|
||||
self.client.points["production"][stamp] = 1200
|
||||
self.assertEqual(self.recover(), 1)
|
||||
|
||||
def test_recovered_identifiers_are_stable_for_duplicate_delivery(self):
|
||||
@@ -99,23 +105,129 @@ class HistoryTests(unittest.TestCase):
|
||||
history_sample(value, self.source, SYSTEM)
|
||||
|
||||
def test_missing_private_credentials_fail_before_requests(self):
|
||||
with self.assertRaisesRegex(ValueError, "SOLARWEB_ACCESS_KEY"):
|
||||
with self.assertRaisesRegex(ValueError, "SOLARWEB_USERNAME and SOLARWEB_PASSWORD"):
|
||||
HistoryRecovery(self.config, self.source, self.store, credentials=("", ""))
|
||||
|
||||
def test_requests_are_split_at_utc_midnight_even_across_dst(self):
|
||||
self.http.entries = []
|
||||
def test_config_credentials_take_precedence_over_environment(self):
|
||||
config = replace(self.config, username="private@example.test", password="synthetic password é &")
|
||||
with patch("solar_relay.history.os.environ", {"SOLARWEB_USERNAME": "env@example.test", "SOLARWEB_PASSWORD": "env-password"}), \
|
||||
patch("solar_relay.history.SolarWebClient", return_value=self.client) as client:
|
||||
recovery = HistoryRecovery(config, self.source, self.store)
|
||||
self.assertEqual(recovery.recover(utc_text(self.when), utc_text(self.when + timedelta(minutes=15)),
|
||||
now=self.when + timedelta(hours=4)), 3)
|
||||
client.assert_called_once_with("private@example.test", "synthetic password é &", timeout=config.request_timeout_seconds)
|
||||
|
||||
def test_empty_config_credentials_use_environment_fallback(self):
|
||||
config = replace(self.config, username_env="BACKUP_USER", password_env="BACKUP_PASSWORD")
|
||||
with patch("solar_relay.history.os.environ", {"BACKUP_USER": "env@example.test", "BACKUP_PASSWORD": "env-password"}), \
|
||||
patch("solar_relay.history.SolarWebClient", return_value=self.client) as client:
|
||||
HistoryRecovery(config, self.source, self.store)
|
||||
client.assert_called_once_with("env@example.test", "env-password", timeout=config.request_timeout_seconds)
|
||||
|
||||
def test_partial_config_credentials_never_mix_with_environment(self):
|
||||
for changes in [{"username": "private@example.test"}, {"password": "private-password"}]:
|
||||
with self.subTest(changes=changes), \
|
||||
patch("solar_relay.history.os.environ", {"SOLARWEB_USERNAME": "env@example.test", "SOLARWEB_PASSWORD": "env-password"}), \
|
||||
patch("solar_relay.history.SolarWebClient") as client:
|
||||
with self.assertRaisesRegex(ValueError, "history.username and history.password"):
|
||||
HistoryRecovery(replace(self.config, **changes), self.source, self.store)
|
||||
client.assert_not_called()
|
||||
|
||||
def test_requests_use_local_dates_across_dst(self):
|
||||
self.recovery.recover("2026-10-24T22:00:00Z", "2026-10-26T00:00:00Z", now=datetime(2026, 10, 27, tzinfo=timezone.utc))
|
||||
self.assertEqual(len(self.http.requests), 2)
|
||||
for url, _ in self.http.requests:
|
||||
query = parse_qs(urlsplit(url).query)
|
||||
start, end = [datetime.fromisoformat(query[name][0]) for name in ("from", "to")]
|
||||
self.assertLessEqual((end - start).total_seconds(), 86400)
|
||||
self.assertEqual([date.isoformat() for _, dataset, date in self.client.requests if dataset == "production"],
|
||||
["2026-10-25", "2026-10-26"])
|
||||
|
||||
def test_recent_intervals_wait_for_the_hourly_cloud_upload(self):
|
||||
self.http.entries = []
|
||||
self.recovery.recover(utc_text(self.when), utc_text(self.when + timedelta(hours=4)), now=self.when + timedelta(hours=4))
|
||||
query = parse_qs(urlsplit(self.http.requests[0][0]).query)
|
||||
self.assertEqual(query["to"], [utc_text(self.when + timedelta(hours=2))])
|
||||
for points in self.client.points.values():
|
||||
points.clear()
|
||||
for offset in [0, 115, 120, 235]:
|
||||
points[int((self.when + timedelta(minutes=offset)).timestamp() * 1000)] = 1200
|
||||
self.assertEqual(self.recovery.recover(utc_text(self.when), utc_text(self.when + timedelta(hours=4)),
|
||||
now=self.when + timedelta(hours=4)), 2)
|
||||
|
||||
def test_automatic_recovery_checks_retention_before_first_observation_and_respects_cutoff(self):
|
||||
now = datetime(2026, 11, 2, 12, 2, 17, tzinfo=timezone.utc)
|
||||
cutoff = datetime(2026, 10, 3, 12, 5, tzinfo=timezone.utc)
|
||||
latest = datetime(2026, 11, 2, 10, tzinfo=timezone.utc)
|
||||
recoverable = [cutoff, now.replace(minute=0, second=0) - timedelta(days=25), latest - timedelta(minutes=5)]
|
||||
points = {int(when.timestamp() * 1000): 1200 for when in
|
||||
[cutoff - timedelta(minutes=5), *recoverable, latest]}
|
||||
self.client.points = {"production": points, "consumption": points}
|
||||
self.store.add(observed(now.replace(minute=0, second=0)))
|
||||
self.assertEqual(self.recovery.recover(now=now), 3)
|
||||
recovered = [record for record in self.store.records() if "history_interval" in record]
|
||||
self.assertEqual([record["observed_at"] for record in recovered], [utc_text(when) for when in recoverable])
|
||||
self.assertEqual(min(day for _, _, day in self.client.requests), cutoff.date())
|
||||
|
||||
def test_automatic_recovery_checks_entirely_empty_days_and_archives(self):
|
||||
self.store.retention_days = 2
|
||||
self.assertEqual(self.recovery.recover(now=self.when + timedelta(days=2)), 3)
|
||||
self.assertEqual({day for _, _, day in self.client.requests},
|
||||
{(self.when + timedelta(days=offset)).date() for offset in range(3)})
|
||||
|
||||
def test_automatic_recovery_supports_retention_longer_than_manual_limit(self):
|
||||
self.store.retention_days = 45
|
||||
now = self.when + timedelta(days=40)
|
||||
self.assertEqual(self.recovery.recover(now=now), 3)
|
||||
self.assertEqual(min(day for _, _, day in self.client.requests), (now - timedelta(days=45)).date())
|
||||
with self.assertRaisesRegex(ValueError, "at most 30 days"):
|
||||
self.recovery.recover(utc_text(now - timedelta(days=45)), utc_text(now), now=now)
|
||||
|
||||
def test_complete_retention_window_does_not_request_solarweb(self):
|
||||
now = self.when + timedelta(days=1)
|
||||
lower = int(self.when.timestamp())
|
||||
upper = int((now - timedelta(seconds=self.config.lag_seconds)).timestamp())
|
||||
store = Mock(retention_days=1)
|
||||
store.energy_slots.return_value = set(range(lower, upper, 300))
|
||||
recovery = HistoryRecovery(self.config, self.source, store, client=self.client)
|
||||
self.assertEqual(recovery.recover(now=now), 0)
|
||||
self.assertEqual(self.client.requests, [])
|
||||
store.add.assert_not_called()
|
||||
|
||||
def test_unavailable_cloud_day_does_not_block_newer_missing_days(self):
|
||||
self.store.retention_days = 2
|
||||
available = self.when + timedelta(days=1)
|
||||
self.client.points = {dataset: {int(available.timestamp() * 1000): 1200}
|
||||
for dataset in ("production", "consumption")}
|
||||
get_chart = self.client.get_chart
|
||||
|
||||
def charts(system, dataset, day):
|
||||
if day == self.when.date():
|
||||
raise ChartDataError("synthetic secret response must not be logged")
|
||||
return get_chart(system, dataset, day)
|
||||
|
||||
with patch.object(self.client, "get_chart", side_effect=charts), self.assertLogs("solar_relay.history", level="WARNING") as logs:
|
||||
self.assertEqual(self.recovery.recover(now=self.when + timedelta(days=2)), 1)
|
||||
self.assertNotIn("synthetic secret response", " ".join(logs.output))
|
||||
self.assertEqual(next(self.store.records())["observed_at"], utc_text(available))
|
||||
|
||||
def test_login_or_transport_failure_stops_the_scan_without_repeated_attempts(self):
|
||||
with patch.object(self.client, "get_chart", side_effect=TransportError("Login failed")) as client:
|
||||
with self.assertRaisesRegex(TransportError, "Login failed"):
|
||||
self.recovery.recover(now=self.when + timedelta(days=2))
|
||||
self.assertEqual(client.call_count, 1)
|
||||
|
||||
def test_chart_conversion_matches_solar_and_retains_provenance(self):
|
||||
for generated, consumed in [(1200, 1440), (2400, 1200), (0, 0)]:
|
||||
with self.subTest(generated=generated, consumed=consumed):
|
||||
stamp = int(self.when.timestamp() * 1000)
|
||||
record = next(chart_entries({stamp: generated}, {stamp: consumed}))
|
||||
values = history_sample(record, self.source, SYSTEM)["history_interval"]["energy_wh"]
|
||||
self.assertEqual(values, {"production": generated / 12, "consumption": consumed / 12,
|
||||
"self_consumption": min(generated, consumed) / 12,
|
||||
"bought": max(0, consumed - generated) / 12,
|
||||
"sold": max(0, generated - consumed) / 12})
|
||||
self.assertEqual(record["energy_method"], "constant_power_over_five_minutes")
|
||||
|
||||
def test_dst_days_recover_every_actual_five_minute_slot(self):
|
||||
for day, expected in [("2026-03-29", 276), ("2026-10-25", 300)]:
|
||||
with self.subTest(day=day):
|
||||
start = datetime.fromisoformat(day).replace(tzinfo=ZoneInfo("Europe/Zurich"))
|
||||
end = start + timedelta(days=1)
|
||||
points = {stamp: 1200 for stamp in range(int(start.timestamp() * 1000), int(end.timestamp() * 1000), 300_000)}
|
||||
self.client.points = {"production": points, "consumption": points}
|
||||
self.assertEqual(self.recovery.recover(utc_text(start), utc_text(end), now=end + timedelta(days=1)), expected)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in new issue
Block a user