This commit is contained in:
1 parent
e1d22a26ed
commit
9e0230731b
10 files changed
+118
-223
No files matched your search
+33
-60
@@ -8,7 +8,7 @@ import pyarrow.parquet as pq
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from solar_relay.config import HistoryConfig, utc_text
|
||||
from solar_relay.history import CHANNELS, HistoryRecovery, chart_entries, history_sample
|
||||
from solar_relay.history import HistoryRecovery, history_sample
|
||||
from solar_relay.http import TransportError
|
||||
from solar_relay.solarweb import ChartDataError
|
||||
from solar_relay.storage import Store
|
||||
@@ -17,12 +17,6 @@ from tests.test_status import observed
|
||||
SYSTEM = "73733052-6f10-47a5-9746-7f7e76ebcb8c"
|
||||
|
||||
|
||||
def entry(when):
|
||||
values = {"production": 100, "consumption": 120, "self_consumption": 80, "bought": 40, "sold": 20}
|
||||
return {"logDateTime": utc_text(when), "logDuration": 300,
|
||||
"channels": [{"channelName": channel, "channelType": "Energy", "unit": "Wh", "value": values[field]}
|
||||
for field, channel in CHANNELS.items()]}
|
||||
|
||||
|
||||
class FakeCharts:
|
||||
def __init__(self, when):
|
||||
@@ -55,9 +49,9 @@ class HistoryTests(unittest.TestCase):
|
||||
self.store.add(observed(self.when + timedelta(minutes=offset)))
|
||||
self.assertEqual(self.recover(), 1)
|
||||
records = list(self.store.records())
|
||||
recovered = [s for s in records if "history_interval" in s]
|
||||
recovered = [s for s in records if "solarweb_history" in s["raw"]]
|
||||
self.assertEqual(len(recovered), 1)
|
||||
self.assertEqual(recovered[0]["history_interval"]["energy_wh"]["production"], 100)
|
||||
self.assertEqual(recovered[0]["readings"]["production_ac_w"], 1200)
|
||||
self.assertIsNone(recovered[0]["readings"]["boiler_enabled"])
|
||||
self.assertNotIn("boiler", recovered[0]["source_timestamps"])
|
||||
self.assertIn("boiler", recovered[0]["errors"])
|
||||
@@ -78,41 +72,20 @@ class HistoryTests(unittest.TestCase):
|
||||
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")
|
||||
self.assertEqual(row["interval_energy_wh"]["production"], 100)
|
||||
self.assertEqual(row["production_ac_w"], 1200)
|
||||
self.assertEqual(row["consumption_w"], 1440)
|
||||
self.assertEqual(row["grid_import_w"], 240)
|
||||
self.assertEqual(row["grid_export_w"], 0)
|
||||
self.assertEqual(day["slots"][120:123], [1, 1, 1])
|
||||
|
||||
def test_old_archive_normalization_preserves_delivery_state_and_live_records(self):
|
||||
old = history_sample(entry(self.when), self.source, SYSTEM)
|
||||
old["readings"] = dict.fromkeys(old["readings"])
|
||||
old["source_timestamps"] = {}
|
||||
old.pop("poll_interval_seconds")
|
||||
live = observed(self.when + timedelta(minutes=5))
|
||||
self.store.add(old)
|
||||
self.store.add(live)
|
||||
self.store.acknowledge([old["id"]], now=self.when + timedelta(hours=1))
|
||||
path = next(self.store.path.glob("date=*/samples.parquet"))
|
||||
before = {row["id"]: row for row in pq.ParquetFile(path).read().to_pylist()}
|
||||
|
||||
self.assertEqual(self.store.normalize_history(), 1)
|
||||
self.assertEqual(self.store.normalize_history(), 0)
|
||||
after = {row["id"]: row for row in pq.ParquetFile(path).read().to_pylist()}
|
||||
self.assertEqual(after[live["id"]], before[live["id"]])
|
||||
row = after[old["id"]]
|
||||
for field in ("id", "observed_at", "recorded_ns", "uploaded_at"):
|
||||
self.assertEqual(row[field], before[old["id"]][field])
|
||||
self.assertEqual(row["production_ac_w"], 1200)
|
||||
self.assertEqual(row["consumption_w"], 1440)
|
||||
self.assertIsNone(row["production_w"])
|
||||
self.assertIsNone(row["boiler_enabled"])
|
||||
self.assertEqual(self.store.pending(100), [live])
|
||||
self.assertEqual(list(self.store.records())[0]["source_timestamps"]["powerflow"], utc_text(self.when))
|
||||
# Replaying the original legacy payload still identifies the same record.
|
||||
self.store.add(old)
|
||||
self.assertEqual(self.store.status()["samples"], 2)
|
||||
def test_recovered_power_uses_the_same_completeness_checks_as_live_power(self):
|
||||
sample = history_sample(int(self.when.timestamp() * 1000), 1200, 1440, self.source, SYSTEM)
|
||||
sample["readings"]["consumption_w"] = None
|
||||
self.store.add(sample)
|
||||
day = self.store.coverage(self.source, "UTC", 1, self.when + timedelta(days=1))[1]
|
||||
self.assertEqual(day["energy_complete"], 0)
|
||||
self.assertEqual(day["boiler_complete"], 0)
|
||||
self.assertEqual(day["slots"][120], 0)
|
||||
|
||||
def test_partial_cloud_records_remain_missing_for_retry(self):
|
||||
stamp = int((self.when + timedelta(minutes=5)).timestamp() * 1000)
|
||||
@@ -123,20 +96,23 @@ class HistoryTests(unittest.TestCase):
|
||||
self.assertEqual(self.recover(), 1)
|
||||
|
||||
def test_recovered_identifiers_are_stable_for_duplicate_delivery(self):
|
||||
first = history_sample(entry(self.when), self.source, SYSTEM)
|
||||
second = history_sample(entry(self.when), self.source, SYSTEM)
|
||||
stamp = int(self.when.timestamp() * 1000)
|
||||
first = history_sample(stamp, 1200, 1440, self.source, SYSTEM)
|
||||
second = history_sample(stamp, 1200, 1440, self.source, SYSTEM)
|
||||
self.assertEqual(first, second)
|
||||
self.store.add(first)
|
||||
self.store.add(second)
|
||||
self.assertEqual(self.store.status()["samples"], 1)
|
||||
|
||||
def test_non_five_minute_and_bad_units_are_rejected(self):
|
||||
for change in [{"logDuration": 599}, {"logDateTime": utc_text(self.when + timedelta(seconds=1))}]:
|
||||
value = entry(self.when)
|
||||
value.update(change)
|
||||
with self.assertRaises(ValueError):
|
||||
history_sample(value, self.source, SYSTEM)
|
||||
value = entry(self.when)
|
||||
value["channels"][0]["unit"] = "W"
|
||||
with self.assertRaises(ValueError):
|
||||
history_sample(value, self.source, SYSTEM)
|
||||
def test_unaligned_timestamps_and_invalid_power_are_rejected(self):
|
||||
stamp = int(self.when.timestamp() * 1000)
|
||||
for bad_stamp in (stamp + 1000, float(stamp), True):
|
||||
with self.subTest(stamp=bad_stamp), self.assertRaises(ValueError):
|
||||
history_sample(bad_stamp, 1200, 1440, self.source, SYSTEM)
|
||||
for invalid in (None, True, "1200", -1, float("nan"), float("inf"), 1e19):
|
||||
for generated, consumed in ((invalid, 1440), (1200, invalid)):
|
||||
with self.subTest(generated=generated, consumed=consumed), self.assertRaises(ValueError):
|
||||
history_sample(stamp, generated, consumed, self.source, SYSTEM)
|
||||
|
||||
def test_missing_private_credentials_fail_before_requests(self):
|
||||
with self.assertRaisesRegex(ValueError, "SOLARWEB_USERNAME and SOLARWEB_PASSWORD"):
|
||||
@@ -190,7 +166,7 @@ class HistoryTests(unittest.TestCase):
|
||||
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]
|
||||
recovered = [record for record in self.store.records() if "solarweb_history" in record["raw"]]
|
||||
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.astimezone(self.recovery.zone).date())
|
||||
|
||||
@@ -266,22 +242,19 @@ class HistoryTests(unittest.TestCase):
|
||||
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):
|
||||
def test_chart_watts_are_stored_directly_and_retain_provenance(self):
|
||||
for generated, consumed in [(1200, 1440), (2400, 1200), (0, 0), (1234.56789, 3456.78901)]:
|
||||
with self.subTest(generated=generated, consumed=consumed):
|
||||
stamp = int(self.when.timestamp() * 1000)
|
||||
record = next(chart_entries({stamp: generated}, {stamp: consumed}))
|
||||
sample = history_sample(record, self.source, SYSTEM)
|
||||
values = sample["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")
|
||||
sample = history_sample(stamp, generated, consumed, self.source, SYSTEM)
|
||||
self.assertEqual(sample["readings"]["production_ac_w"], generated)
|
||||
self.assertEqual(sample["readings"]["consumption_w"], consumed)
|
||||
self.assertEqual(sample["readings"]["grid_w"], consumed - generated)
|
||||
self.assertEqual(sample["readings"]["grid_import_w"], max(0, consumed - generated))
|
||||
self.assertEqual(sample["readings"]["grid_export_w"], max(0, generated - consumed))
|
||||
self.assertIsNone(sample["readings"]["production_w"])
|
||||
self.assertEqual(sample["raw"]["solarweb_history"]["chart_power_w"],
|
||||
{"production": generated, "consumption": consumed})
|
||||
|
||||
def test_dst_days_recover_every_actual_five_minute_slot(self):
|
||||
for day, expected in [("2026-03-29", 276), ("2026-10-25", 300)]:
|
||||
|
||||
Reference in new issue
Block a user