This commit is contained in:
1 parent
cd7ddd3cf6
commit
89621bc7c3
15 files changed
+289
-74
No files matched your search
+29
-5
@@ -68,13 +68,13 @@ class HistoryTests(unittest.TestCase):
|
||||
self.assertEqual(self.recover(), 0)
|
||||
self.assertEqual(len(self.client.requests), 2)
|
||||
|
||||
def test_cloud_energy_does_not_mark_boiler_or_meter_history_complete(self):
|
||||
def test_cloud_energy_does_not_mark_boiler_history_complete(self):
|
||||
self.assertEqual(self.recover(), 3)
|
||||
rows = self.store.coverage(self.source, "UTC", 1, self.when + timedelta(days=1))
|
||||
day = rows[1]
|
||||
self.assertEqual(day["energy_complete"], 3)
|
||||
self.assertEqual(day["boiler_complete"], 0)
|
||||
self.assertEqual(day["meter_complete"], 0)
|
||||
self.assertNotIn("meter_complete", day)
|
||||
self.assertEqual(day["complete"], 0)
|
||||
row = pq.ParquetFile(next(self.store.path.glob("date=*/samples.parquet"))).read().to_pylist()[0]
|
||||
self.assertEqual(row["record_kind"], "history")
|
||||
@@ -148,7 +148,7 @@ class HistoryTests(unittest.TestCase):
|
||||
|
||||
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)
|
||||
cutoff = datetime(2026, 10, 3, tzinfo=ZoneInfo("Europe/Zurich")).astimezone(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
|
||||
@@ -158,7 +158,31 @@ class HistoryTests(unittest.TestCase):
|
||||
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())
|
||||
self.assertEqual(min(day for _, _, day in self.client.requests), cutoff.astimezone(self.recovery.zone).date())
|
||||
|
||||
def test_oldest_retained_day_recovers_all_288_intervals_including_midnight(self):
|
||||
zone = ZoneInfo("Europe/Zurich")
|
||||
start = datetime(2026, 10, 3, tzinfo=zone)
|
||||
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(now=datetime(2026, 11, 2, 22, 30, tzinfo=zone)), 288)
|
||||
records = list(self.store.records())
|
||||
self.assertEqual(records[0]["observed_at"], utc_text(start))
|
||||
self.assertEqual(records[-1]["observed_at"], utc_text(end - timedelta(minutes=5)))
|
||||
|
||||
def test_retention_uses_the_configured_timezone_instead_of_utc(self):
|
||||
now = datetime(2026, 11, 2, 1, 30, tzinfo=timezone.utc)
|
||||
recovery = HistoryRecovery(self.config, self.source, self.store, timezone_name="America/New_York", client=self.client)
|
||||
recovery.recover(now=now)
|
||||
self.assertEqual(min(day for _, _, day in self.client.requests).isoformat(), "2026-10-02")
|
||||
|
||||
def test_thirty_calendar_day_manual_range_can_cross_a_25_hour_dst_day(self):
|
||||
zone = ZoneInfo("Europe/Zurich")
|
||||
self.client.points = {"production": {}, "consumption": {}}
|
||||
self.assertEqual(self.recovery.recover(utc_text(datetime(2026, 10, 3, tzinfo=zone)),
|
||||
utc_text(datetime(2026, 11, 2, tzinfo=zone)),
|
||||
now=datetime(2026, 11, 3, tzinfo=zone)), 0)
|
||||
|
||||
def test_automatic_recovery_checks_entirely_empty_days_and_archives(self):
|
||||
self.store.retention_days = 2
|
||||
@@ -176,7 +200,7 @@ class HistoryTests(unittest.TestCase):
|
||||
|
||||
def test_complete_retention_window_does_not_request_solarweb(self):
|
||||
now = self.when + timedelta(days=1)
|
||||
lower = int(self.when.timestamp())
|
||||
lower = int(datetime(2026, 10, 30, tzinfo=ZoneInfo("Europe/Zurich")).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))
|
||||
|
||||
+65
-2
@@ -5,10 +5,12 @@ from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pyarrow.parquet as pq
|
||||
|
||||
from solar_relay.cli import next_poll_at
|
||||
from solar_relay.config import retention_cutoff
|
||||
from solar_relay.storage import Store, StoreError
|
||||
from tests.test_status import observed
|
||||
|
||||
@@ -34,8 +36,8 @@ class ParquetTests(unittest.TestCase):
|
||||
self.assertEqual(json.loads(row["payload"]), value)
|
||||
self.assertEqual(self.store.status()["format"], "parquet")
|
||||
|
||||
def test_retention_uses_ack_time_and_never_deletes_pending(self):
|
||||
items = [observed(self.now - timedelta(days=90, minutes=index)) for index in range(4)]
|
||||
def test_retention_uses_ack_calendar_days_and_never_deletes_pending(self):
|
||||
items = [observed(self.now - timedelta(days=90 + index)) for index in range(4)]
|
||||
for item in items:
|
||||
self.store.add(item)
|
||||
self.store.acknowledge([items[0]["id"]], now=self.now - timedelta(days=31))
|
||||
@@ -47,6 +49,67 @@ class ParquetTests(unittest.TestCase):
|
||||
self.assertEqual(self.store.prune(now=self.now + timedelta(days=31)), 2)
|
||||
self.assertEqual(list(self.store.records()), [items[3]])
|
||||
|
||||
def test_retention_boundary_is_local_midnight_regardless_of_execution_time(self):
|
||||
zone = ZoneInfo("Europe/Zurich")
|
||||
for hour in (0, 12, 23):
|
||||
now = datetime(2026, 11, 2, hour, 30, tzinfo=zone)
|
||||
self.assertEqual(retention_cutoff(now, 30, zone.key), datetime(2026, 10, 2, 22, tzinfo=timezone.utc))
|
||||
with self.assertRaisesRegex(ValueError, "timezone"):
|
||||
retention_cutoff(datetime(2026, 11, 2), 30, zone.key)
|
||||
|
||||
def test_prune_uses_the_configured_timezone_at_the_date_boundary(self):
|
||||
store = Store(self.path, timezone_name="America/New_York")
|
||||
values = [observed(self.now - timedelta(days=60 + offset)) for offset in range(2)]
|
||||
for value in values:
|
||||
store.add(value)
|
||||
store.acknowledge([values[0]["id"]], now=datetime(2026, 10, 2, 3, 59, tzinfo=timezone.utc))
|
||||
store.acknowledge([values[1]["id"]], now=datetime(2026, 10, 2, 4, tzinfo=timezone.utc))
|
||||
self.assertEqual(store.prune(now=datetime(2026, 11, 2, 1, 30, tzinfo=timezone.utc)), 1)
|
||||
self.assertEqual(list(store.records()), [values[1]])
|
||||
|
||||
def test_prune_keeps_the_entire_cutoff_acknowledgement_day(self):
|
||||
zone = ZoneInfo("Europe/Zurich")
|
||||
start = datetime(2026, 10, 3, tzinfo=zone)
|
||||
values = [observed(start - timedelta(days=10) + timedelta(minutes=offset)) for offset in (0, 5)]
|
||||
for value, ack in zip(values, (start, start + timedelta(hours=23, minutes=55))):
|
||||
self.store.add(value)
|
||||
self.store.acknowledge([value["id"]], now=ack)
|
||||
self.assertEqual(self.store.prune(now=datetime(2026, 11, 2, 23, 59, tzinfo=zone)), 0)
|
||||
self.assertEqual(self.store.prune(now=datetime(2026, 11, 3, tzinfo=zone)), 2)
|
||||
|
||||
def test_prune_removes_whole_local_days_across_utc_partitions(self):
|
||||
zone = ZoneInfo("Europe/Zurich")
|
||||
first = datetime(2026, 10, 3, tzinfo=zone)
|
||||
expired = [observed(first - timedelta(hours=23, minutes=55)), observed(first - timedelta(minutes=5))]
|
||||
retained = [observed(first), observed(first + timedelta(hours=23, minutes=55))]
|
||||
for value in expired + retained:
|
||||
self.store.add(value)
|
||||
self.store.acknowledge([value["id"] for value in expired], now=first - timedelta(minutes=1))
|
||||
self.store.acknowledge([value["id"] for value in retained], now=first + timedelta(days=1))
|
||||
self.assertEqual(self.store.prune(now=datetime(2026, 11, 2, 22, tzinfo=zone)), 2)
|
||||
self.assertEqual(list(self.store.records()), retained)
|
||||
|
||||
def test_one_pending_record_protects_its_entire_local_day_across_partitions(self):
|
||||
zone = ZoneInfo("Europe/Zurich")
|
||||
day = datetime(2026, 10, 2, tzinfo=zone)
|
||||
values = [observed(day + timedelta(minutes=5)), observed(day + timedelta(hours=23, minutes=55))]
|
||||
for value in values:
|
||||
self.store.add(value)
|
||||
self.store.acknowledge([values[0]["id"]], now=day + timedelta(hours=23, minutes=59))
|
||||
self.assertEqual(self.store.prune(now=datetime(2026, 11, 2, 22, tzinfo=zone)), 0)
|
||||
self.assertEqual(list(self.store.records()), values)
|
||||
|
||||
def test_daylight_saving_retention_boundaries_preserve_complete_local_days(self):
|
||||
zone = ZoneInfo("Europe/Zurich")
|
||||
for day, expected_hours in [(datetime(2026, 3, 29, tzinfo=zone), 23),
|
||||
(datetime(2026, 10, 25, tzinfo=zone), 25)]:
|
||||
with self.subTest(day=day):
|
||||
now = day + timedelta(days=30, hours=12)
|
||||
cutoff = retention_cutoff(now, 30, zone.key)
|
||||
self.assertEqual(cutoff, day.astimezone(timezone.utc))
|
||||
next_day = (day + timedelta(days=1)).astimezone(timezone.utc)
|
||||
self.assertEqual((next_day - cutoff).total_seconds(), expected_hours * 3600)
|
||||
|
||||
def test_replay_cancels_expiry_until_the_next_acknowledgement(self):
|
||||
value = observed(self.now)
|
||||
self.store.add(value)
|
||||
|
||||
+88
-5
@@ -42,8 +42,56 @@ class CoverageTests(unittest.TestCase):
|
||||
self.assertEqual(yesterday["expected"], 288)
|
||||
self.assertEqual(yesterday["complete"], 288)
|
||||
self.assertEqual(yesterday["status"], "complete")
|
||||
self.assertEqual(result[0]["elapsed"], 0)
|
||||
self.assertTrue(all(mask == -1 for mask in result[0]["slots"]))
|
||||
self.assertEqual(result[0]["elapsed"], 1)
|
||||
self.assertEqual(result[0]["slots"][0], 3)
|
||||
self.assertTrue(all(mask == -1 for mask in result[0]["slots"][1:]))
|
||||
|
||||
def test_successful_2305_extraction_is_green_at_2308(self):
|
||||
zone = ZoneInfo("Europe/Zurich")
|
||||
now = datetime(2026, 11, 2, 23, 8, tzinfo=zone)
|
||||
stamp = round(now.replace(minute=5).timestamp() * 1000)
|
||||
day = daily_coverage([(stamp, stamp, 1, stamp, 1, stamp, 1)], zone.key, 1, now)[0]
|
||||
self.assertEqual(day["slots"][277], 3)
|
||||
self.assertEqual(day["slots"][278], -1)
|
||||
self.assertEqual(day["complete"], 1)
|
||||
self.assertEqual(day["energy_complete"], 1)
|
||||
self.assertEqual(day["boiler_complete"], 1)
|
||||
self.assertEqual(day["elapsed"], 278)
|
||||
self.assertEqual(day["missing"], 277)
|
||||
|
||||
def test_current_slot_without_extraction_stays_pending_until_it_ends(self):
|
||||
zone = ZoneInfo("Europe/Zurich")
|
||||
now = datetime(2026, 11, 2, 23, 8, tzinfo=zone)
|
||||
day = daily_coverage([], zone.key, 1, now)[0]
|
||||
self.assertEqual(day["slots"][277], -1)
|
||||
self.assertEqual(day["elapsed"], 277)
|
||||
self.assertEqual(day["missing"], 277)
|
||||
after = daily_coverage([], zone.key, 1, now.replace(minute=10))[0]
|
||||
self.assertEqual(after["slots"][277], 0)
|
||||
self.assertEqual(after["slots"][278], -1)
|
||||
self.assertEqual(after["missing"], 278)
|
||||
|
||||
def test_partial_and_failed_current_extractions_are_shown_immediately(self):
|
||||
zone = ZoneInfo("Europe/Zurich")
|
||||
now = datetime(2026, 11, 2, 23, 8, tzinfo=zone)
|
||||
stamp = round(now.replace(minute=5).timestamp() * 1000)
|
||||
for energy, boiler, expected_mask in [(1, 0, 1), (0, 1, 2), (0, 0, 0)]:
|
||||
with self.subTest(energy=energy, boiler=boiler):
|
||||
day = daily_coverage([(stamp, stamp, energy, stamp, 0, stamp, boiler)], zone.key, 1, now)[0]
|
||||
self.assertEqual(day["slots"][277], expected_mask)
|
||||
self.assertEqual(day["elapsed"], 278)
|
||||
self.assertEqual(day["complete"], 0)
|
||||
|
||||
def test_current_extraction_at_midnight_is_counted_without_waiting(self):
|
||||
zone = ZoneInfo("Europe/Zurich")
|
||||
now = datetime(2026, 11, 2, 0, 0, 10, tzinfo=zone)
|
||||
stamp = round(now.replace(second=0).timestamp() * 1000)
|
||||
day = daily_coverage([(stamp, stamp, 1, stamp, 0, stamp, 1)], zone.key, 1, now)[0]
|
||||
self.assertEqual(day["slots"][0], 3)
|
||||
self.assertEqual(day["elapsed"], 1)
|
||||
self.assertEqual(day["complete"], 1)
|
||||
self.assertEqual(day["missing"], 0)
|
||||
self.assertTrue(all(mask == -1 for mask in day["slots"][1:]))
|
||||
|
||||
def test_dst_days_expect_276_and_300_intervals(self):
|
||||
for date, expected in [("2026-03-29", 276), ("2026-10-25", 300)]:
|
||||
@@ -61,17 +109,27 @@ class CoverageTests(unittest.TestCase):
|
||||
self.assertEqual(yesterday["missing"], 3)
|
||||
self.assertEqual(yesterday["status"], "partial")
|
||||
|
||||
def test_energy_failure_does_not_erase_meter_or_boiler_coverage(self):
|
||||
def test_energy_failure_does_not_erase_boiler_coverage(self):
|
||||
rows, end = rows_for_day("2026-10-30")
|
||||
stamp = rows[1][0]
|
||||
rows[1] = (stamp, stamp, 0, stamp, 1, stamp, 1)
|
||||
yesterday = daily_coverage(rows, "Europe/Zurich", 1, end + timedelta(minutes=3))[1]
|
||||
self.assertEqual(yesterday["slots"][1], 6)
|
||||
self.assertEqual(yesterday["slots"][1], 2)
|
||||
self.assertEqual(yesterday["energy_complete"], 287)
|
||||
self.assertEqual(yesterday["meter_complete"], 288)
|
||||
self.assertNotIn("meter_complete", yesterday)
|
||||
self.assertEqual(yesterday["boiler_complete"], 288)
|
||||
self.assertEqual(yesterday["missing"], 1)
|
||||
|
||||
def test_missing_meter_readings_do_not_affect_visible_completeness(self):
|
||||
rows, end = rows_for_day("2026-10-30")
|
||||
rows = [(observed, power_ms, power_ok, meter_ms, 0, boiler_ms, boiler_ok)
|
||||
for observed, power_ms, power_ok, meter_ms, meter_ok, boiler_ms, boiler_ok in rows]
|
||||
yesterday = daily_coverage(rows, "Europe/Zurich", 1, end + timedelta(minutes=3))[1]
|
||||
self.assertEqual(yesterday["complete"], 288)
|
||||
self.assertEqual(yesterday["status"], "complete")
|
||||
self.assertEqual(set(yesterday["slots"]), {3})
|
||||
self.assertNotIn("meter_complete", yesterday)
|
||||
|
||||
def test_source_errors_stale_readings_and_false_boiler_state(self):
|
||||
when = datetime(2026, 10, 30, tzinfo=timezone.utc)
|
||||
value = observed(when)
|
||||
@@ -150,6 +208,31 @@ class StatusHttpTests(unittest.TestCase):
|
||||
self.assertEqual(result["collector"]["source_errors"], ["boiler"])
|
||||
self.assertNotIn("secret canary", json.dumps(result))
|
||||
|
||||
def test_meter_failure_does_not_change_current_status_or_last_success(self):
|
||||
value = observed(datetime.now(timezone.utc))
|
||||
value["errors"] = {"meter": "meter is unavailable"}
|
||||
value["readings"]["grid_import_total_wh"] = None
|
||||
value["readings"]["grid_export_total_wh"] = None
|
||||
self.store.add(value)
|
||||
result = json.loads(self.get("/api/status")[2])
|
||||
self.assertEqual(result["status"], "ok")
|
||||
self.assertEqual(result["collector"]["source_errors"], [])
|
||||
self.assertEqual(result["collector"]["last_success"], value["observed_at"])
|
||||
self.assertNotIn("meter_complete", result["days"][0])
|
||||
|
||||
def test_latest_success_and_current_grid_cell_agree(self):
|
||||
zone = ZoneInfo("Europe/Zurich")
|
||||
now = datetime(2026, 11, 2, 23, 8, tzinfo=zone)
|
||||
value = observed(now.replace(minute=5))
|
||||
self.store.add(value)
|
||||
with unittest.mock.patch("solar_relay.status.datetime", wraps=datetime) as dates:
|
||||
dates.now.return_value = now.astimezone(timezone.utc)
|
||||
result = json.loads(self.get("/api/status?days=1")[2])
|
||||
self.assertEqual(result["status"], "ok")
|
||||
self.assertEqual(result["collector"]["last_success"], value["observed_at"])
|
||||
self.assertEqual(result["days"][0]["slots"][277], 3)
|
||||
self.assertEqual(result["days"][0]["slots"][278], -1)
|
||||
|
||||
def test_only_explicit_read_only_routes_are_available(self):
|
||||
for path in ["/config.toml", "/.env", "/data/archive/date=2026-11-01/samples.parquet", "/../config.toml", "/solar-relay/.git/config"]:
|
||||
self.assertEqual(self.get(path)[0], 404, path)
|
||||
|
||||
Reference in new issue
Block a user