Reshape boiler data
Deploy Solar Relay / deploy (push) Successful in 1m7s

This commit is contained in:
franzz committed 2026-11-03 01:48:01 +01:00
1 parent 9e0230731b
commit e7905c667d
10 files changed
+224 -27

No files matched your search

+16
View File
@@ -27,12 +27,16 @@ class ParquetTests(unittest.TestCase):
def test_real_parquet_has_typed_readings_and_original_payload(self):
value = observed(self.now)
value["readings"]["boiler_enabled"] = False
value["readings"]["boiler_threshold_w"] = 2600
value["readings"]["boiler_threshold_source"] = "config"
self.store.add(value)
path = next(self.path.glob("date=*/samples.parquet"))
self.assertEqual(path.read_bytes()[:4], b"PAR1")
row = pq.ParquetFile(path).read().to_pylist()[0]
self.assertEqual(row["production_ac_w"], 2000)
self.assertIs(row["boiler_enabled"], False)
self.assertEqual(row["boiler_threshold_w"], 2600)
self.assertEqual(row["boiler_threshold_source"], "config")
self.assertIsNone(row["uploaded_at"])
self.assertEqual(json.loads(row["payload"]), value)
self.assertEqual(self.store.status()["format"], "parquet")
@@ -77,6 +81,18 @@ class ParquetTests(unittest.TestCase):
with self.assertRaises(StoreError):
self.store.pending(100)
def test_pre_threshold_archive_is_readable_and_upgraded_on_write(self):
value = observed(self.now)
self.store.add(value)
path = next(self.path.glob("date=*/samples.parquet"))
legacy = pq.ParquetFile(path).read().drop_columns(["boiler_threshold_w", "boiler_threshold_source"])
pq.write_table(legacy, path)
self.assertEqual(self.store.pending(100), [value])
self.assertEqual(list(self.store.records()), [value])
self.store.acknowledge([value["id"]], now=self.now)
self.assertTrue(pq.ParquetFile(path).schema_arrow.equals(SCHEMA, check_metadata=True))
self.assertIsNone(pq.ParquetFile(path).read().to_pylist()[0]["boiler_threshold_w"])
def test_retention_boundary_is_local_midnight_regardless_of_execution_time(self):
zone = ZoneInfo("Europe/Zurich")
for hour in (0, 12, 23):
+81 -1
View File
@@ -14,7 +14,7 @@ from unittest.mock import Mock, patch
from urllib.parse import urlsplit
from solar_relay.config import InverterConfig, UploadConfig, date_bound, load_config
from solar_relay.fronius import FroniusClient, powerflow_readings
from solar_relay.fronius import FroniusClient, boiler_threshold, powerflow_readings
from solar_relay.http import JsonHttp, TransportError
from solar_relay.storage import Store
from solar_relay.upload import Uploader, UploadError
@@ -36,10 +36,12 @@ METER = api({"0": {
"EnergyReal_WAC_Sum_Consumed": 50000, "EnergyReal_WAC_Sum_Produced": 80000,
}})
BOILER = {"pin1": {"state": True, "reason": "PSTR_EMRS_REASON_3"}}
BOILER_CONFIG = {"rules": {"pin1": {"mode": "pgrid", "thresholdOn": -2000, "thresholdOff": -500}}}
RESPONSES = {
"/solar_api/v1/GetPowerFlowRealtimeData.fcgi": POWER,
"/solar_api/v1/GetMeterRealtimeData.cgi": METER,
"/api/status/emrs": BOILER,
"/api/config/emrs": BOILER_CONFIG,
}
@@ -138,6 +140,8 @@ class NormalizationTests(unittest.TestCase):
self.assertEqual(readings["grid_export_w"], 0)
self.assertEqual(readings["grid_import_total_wh"], 50000)
self.assertTrue(readings["boiler_enabled"])
self.assertEqual(readings["boiler_threshold_w"], 2000)
self.assertEqual(readings["boiler_threshold_source"], "api")
self.assertEqual(result["errors"], {})
self.assertEqual(result["raw"]["powerflow"], POWER)
self.assertEqual(result["source_timestamps"]["powerflow"], "2026-11-01T12:00:00.000Z")
@@ -167,6 +171,27 @@ class NormalizationTests(unittest.TestCase):
self.assertIs(result["readings"]["boiler_enabled"], False)
self.assertNotIn("boiler", result["errors"])
def test_configured_threshold_is_used_when_settings_require_authentication(self):
responses = deepcopy(RESPONSES)
responses["/api/config/emrs"] = TransportError("HTTP 401")
result = FroniusClient(InverterConfig(min_request_interval_seconds=0, boiler_threshold_w=2500), http=FakeHttp(responses)).collect("test")
self.assertTrue(result["readings"]["boiler_enabled"])
self.assertEqual(result["readings"]["boiler_threshold_w"], 2500)
self.assertEqual(result["readings"]["boiler_threshold_source"], "config")
self.assertEqual(result["errors"], {})
def test_settings_are_matched_to_the_configured_pin_and_bad_values_use_fallback(self):
for value in (None, 0, True, "2000", float("nan"), 100_000_000):
with self.subTest(value=value):
responses = deepcopy(RESPONSES)
responses["/api/config/emrs"]["rules"]["pin1"]["thresholdOn"] = value
result = FroniusClient(InverterConfig(min_request_interval_seconds=0), http=FakeHttp(responses)).collect("test")
self.assertEqual(result["readings"]["boiler_threshold_w"], 2000)
self.assertEqual(result["readings"]["boiler_threshold_source"], "config")
settings = {"rules": {"pin1": {"mode": "pgrid", "thresholdOn": -1500},
"pin2": {"mode": "ppv", "thresholdOn": 3200}}}
self.assertEqual(boiler_threshold(settings, "pin2"), 3200)
def test_solar_api_application_error_does_not_accept_old_values(self):
responses = deepcopy(RESPONSES)
responses["/solar_api/v1/GetPowerFlowRealtimeData.fcgi"]["Head"]["Status"]["Code"] = 12
@@ -265,6 +290,53 @@ class DeliveryTests(unittest.TestCase):
uploader.upload_once()
self.assertEqual(self.store.status()["pending"], 1)
def test_solar_json_result_envelope_acknowledges_the_samples(self):
with server() as (httpd, base):
reading = sample()
self.store.add(reading)
httpd.ack_override = {"result": "success", "data": {"accepted_ids": [reading["id"]]},
"desc_lang_id": "", "desc_lang_params": []}
uploader = Uploader(UploadConfig(url=base, token="test-token"), self.store)
self.assertEqual(uploader.upload_once(), 1)
self.assertEqual(self.store.status()["pending"], 0)
def test_failed_or_malformed_solar_result_never_clears_the_queue(self):
with server() as (httpd, base):
reading = sample()
self.store.add(reading)
uploader = Uploader(UploadConfig(url=base, token="test-token"), self.store)
accepted = {"accepted_ids": [reading["id"]]}
for response in [
{"result": "error", "data": accepted},
{"result": "error", **accepted},
{"result": True, "data": accepted},
{"result": "success", "data": None},
{"result": "success", "data": []},
{"result": "success", "data": {}, **accepted},
]:
with self.subTest(response=response):
httpd.ack_override = response
with self.assertRaises(UploadError):
uploader.upload_once()
self.assertEqual(self.store.status()["pending"], 1)
def test_legacy_samples_receive_config_fallback_without_replacing_recorded_thresholds(self):
legacy = sample()
del legacy["readings"]["boiler_threshold_w"]
del legacy["readings"]["boiler_threshold_source"]
current = sample()
current["readings"]["boiler_threshold_w"] = 3100
self.store.add(legacy)
self.store.add(current)
with server() as (httpd, base):
uploader = Uploader(UploadConfig(url=base, token="test-token"), self.store, boiler_threshold_w=2500)
self.assertEqual(uploader.upload_once(), 2)
delivered = {s["id"]: s for s in httpd.requests[0]["samples"]}
self.assertEqual(delivered[legacy["id"]]["readings"]["boiler_threshold_w"], 2500)
self.assertEqual(delivered[legacy["id"]]["readings"]["boiler_threshold_source"], "config")
self.assertEqual(delivered[current["id"]]["readings"]["boiler_threshold_w"], 3100)
self.assertEqual(list(self.store.records()), [legacy, current])
def test_partial_ack_only_marks_the_accepted_sample(self):
with server() as (httpd, base):
first, second = sample(), sample()
@@ -376,6 +448,14 @@ class ConfigTests(unittest.TestCase):
self.assertEqual(config.collector.poll_interval_seconds, 300)
self.assertEqual(config.collector.retention_days, 30)
self.assertEqual(config.upload.url, "")
self.assertEqual(config.inverter.boiler_threshold_w, 2000)
def test_boiler_threshold_fallback_is_configurable_and_must_be_positive_watts(self):
config, _ = self.load('\n[inverter]\nboiler_threshold_w=2750\n')
self.assertEqual(config.inverter.boiler_threshold_w, 2750)
for value in ('0', '-100', 'true', '"2000"', 'nan', 'inf', '100000000'):
with self.subTest(value=value), self.assertRaises(ValueError):
self.load(f'\n[inverter]\nboiler_threshold_w={value}\n')
def test_remote_upload_requires_https(self):
with self.assertRaisesRegex(ValueError, "HTTPS"):
+15 -3
View File
@@ -17,6 +17,7 @@ from tests.test_schedule import Clock
POWER_PATH = "/solar_api/v1/GetPowerFlowRealtimeData.fcgi"
METER_PATH = "/solar_api/v1/GetMeterRealtimeData.cgi"
BOILER_PATH = "/api/status/emrs"
BOILER_CONFIG_PATH = "/api/config/emrs"
class ScriptedHttp:
@@ -47,7 +48,7 @@ class RetryTests(unittest.TestCase):
sample = client.collect("test-house")
self.assertEqual(sample["errors"], {})
self.assertEqual(sample["readings"]["production_ac_w"], 2000)
self.assertEqual(httpd.get_counts, {POWER_PATH: retries + 1, METER_PATH: 1, BOILER_PATH: 1})
self.assertEqual(httpd.get_counts, {POWER_PATH: retries + 1, METER_PATH: 1, BOILER_PATH: 1, BOILER_CONFIG_PATH: 1})
self.assertEqual(len(messages.output), retries)
def test_giving_up_after_three_retries_preserves_other_sources(self):
@@ -57,7 +58,7 @@ class RetryTests(unittest.TestCase):
client = FroniusClient(InverterConfig(base_url=base, min_request_interval_seconds=0))
with self.assertLogs("solar_relay.fronius", level="WARNING"):
sample = client.collect("test-house")
self.assertEqual(httpd.get_counts, {POWER_PATH: 1, METER_PATH: 1, BOILER_PATH: 4})
self.assertEqual(httpd.get_counts, {POWER_PATH: 1, METER_PATH: 1, BOILER_PATH: 4, BOILER_CONFIG_PATH: 1})
self.assertEqual(len(httpd.get_failures[BOILER_PATH]), 1)
self.assertEqual(sample["errors"], {"boiler": f"HTTP {failures[3]}"})
self.assertIsNone(sample["readings"]["boiler_enabled"])
@@ -73,7 +74,18 @@ class RetryTests(unittest.TestCase):
self.assertLogs("solar_relay.fronius", level="WARNING"):
result = client.collect("test-house")
self.assertEqual(result["errors"], {})
self.assertEqual([stamp for stamp, _ in http.requests], [0, 4, 8, 12, 16, 20])
self.assertEqual([stamp for stamp, _ in http.requests], [0, 4, 8, 12, 16, 20, 24])
def test_protected_threshold_settings_use_fallback_without_retries_or_losing_state(self):
with server() as (httpd, base):
httpd.get_failures[BOILER_CONFIG_PATH] = [401, 401]
client = FroniusClient(InverterConfig(base_url=base, min_request_interval_seconds=0, boiler_threshold_w=2400))
result = client.collect("test")
self.assertEqual(httpd.get_counts[BOILER_CONFIG_PATH], 1)
self.assertEqual(result["errors"], {})
self.assertTrue(result["readings"]["boiler_enabled"])
self.assertEqual(result["readings"]["boiler_threshold_w"], 2400)
self.assertEqual(result["readings"]["boiler_threshold_source"], "config")
def test_retries_preserve_clock_boundaries_and_one_saved_observation_per_poll(self):
origin = datetime(2026, 11, 1, 12, tzinfo=timezone.utc).timestamp()