120 lines
6.5 KiB
Python
120 lines
6.5 KiB
Python
"""Recover missing five-minute power samples from signed-in Solar.web charts."""
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
import logging
|
|
import math
|
|
import os
|
|
from uuid import NAMESPACE_URL, uuid5
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from .config import retention_cutoff, utc_text
|
|
from .coverage import milliseconds
|
|
from .http import TransportError
|
|
from .solarweb import ChartDataError, SolarWebClient
|
|
|
|
LOG = logging.getLogger(__name__)
|
|
|
|
|
|
def history_sample(stamp, generated, consumed, source_id, system_id):
|
|
"""Store paired chart watts in the same readings as a live power snapshot."""
|
|
if isinstance(stamp, bool) or not isinstance(stamp, int) or stamp % 300_000:
|
|
raise ValueError("Historical record is not an aligned five-minute sample")
|
|
if any(isinstance(value, bool) or not isinstance(value, (int, float))
|
|
or not math.isfinite(value) or not 0 <= value <= 1e18 for value in (generated, consumed)):
|
|
raise ValueError("Historical chart power is missing or invalid")
|
|
when = utc_text(datetime.fromtimestamp(stamp / 1000, timezone.utc))
|
|
grid = consumed - generated
|
|
return {
|
|
"schema_version": 1,
|
|
# This immutable UUID name is part of the record ID format, never a request URL.
|
|
"id": str(uuid5(NAMESPACE_URL, f"https://swqapi.solarweb.com/{system_id}/{source_id}/{when}")),
|
|
"source_id": source_id, "observed_at": when, "poll_interval_seconds": 300,
|
|
"readings": {
|
|
"production_w": None, "production_ac_w": generated, "consumption_w": consumed,
|
|
"grid_w": grid, "grid_import_w": max(0, grid), "grid_export_w": max(0, -grid),
|
|
"production_ac_total_wh": None, "grid_import_total_wh": None, "grid_export_total_wh": None,
|
|
"boiler_enabled": None, "boiler_reason": None,
|
|
},
|
|
"source_timestamps": {"powerflow": when},
|
|
"errors": {"meter": "Cumulative meter readings are unavailable in recovered chart samples",
|
|
"boiler": "Historical boiler switch state is unavailable"},
|
|
"raw": {"solarweb_history": {"chart_power_w": {"production": generated, "consumption": consumed}}},
|
|
}
|
|
|
|
|
|
class HistoryRecovery:
|
|
def __init__(self, config, source_id, store, *, timezone_name="Europe/Zurich", client=None, credentials=None):
|
|
self.config, self.source_id, self.store = config, source_id, store
|
|
self.zone = ZoneInfo(timezone_name)
|
|
if client is None:
|
|
names = (config.username_env, config.password_env)
|
|
if credentials is not None:
|
|
values = credentials
|
|
elif config.username or config.password:
|
|
values = (config.username, config.password)
|
|
else:
|
|
values = [os.environ.get(name, "") for name in names]
|
|
if (len(values) != 2 or any(not isinstance(value, str) or not value for value in values)
|
|
or not values[0].strip()):
|
|
raise ValueError("Set history.username and history.password in config.toml, "
|
|
f"or {names[0]} and {names[1]} before enabling Solar.web recovery")
|
|
client = SolarWebClient(*values, timeout=config.request_timeout_seconds)
|
|
self.client = client
|
|
|
|
def recover(self, start=None, until=None, *, now=None, stop=None):
|
|
now = now or datetime.now(timezone.utc)
|
|
latest = int((now - timedelta(seconds=self.config.lag_seconds)).timestamp()) // 300 * 300
|
|
# Always include the entire oldest local day, even across a DST change.
|
|
earliest = int(retention_cutoff(now, self.store.retention_days, self.zone.key).timestamp())
|
|
lower = milliseconds(start) // 300_000 * 300 if start else earliest
|
|
upper = min(milliseconds(until) // 300_000 * 300, latest) if until else latest
|
|
if lower >= upper:
|
|
return 0
|
|
manual_limit = datetime.fromtimestamp(lower, self.zone) + timedelta(days=30)
|
|
if (start is not None or until is not None) and upper > manual_limit.timestamp():
|
|
raise ValueError("Recover at most 30 days at once")
|
|
stamp = lambda epoch: utc_text(datetime.fromtimestamp(epoch, timezone.utc))
|
|
present = self.store.energy_slots(self.source_id, stamp(lower), stamp(upper))
|
|
missing = set(range(lower, upper, 300)) - present
|
|
LOG.info("Checking %d historical energy intervals; %d are missing", (upper - lower) // 300, len(missing))
|
|
# Chart dates use the installation's local timezone, including 23/25-hour DST days.
|
|
days = {}
|
|
for epoch in sorted(missing):
|
|
local = datetime.fromtimestamp(epoch, self.zone)
|
|
days.setdefault(local.date(), []).append(local.strftime("%H:%M:%S"))
|
|
recovered = 0
|
|
for day in sorted(days):
|
|
if stop and stop.is_set():
|
|
break
|
|
LOG.info("Recovering Solar.web energy history for %s %s", day, ", ".join(days[day]))
|
|
try:
|
|
production = self.client.get_chart(self.config.pv_system_id, "production", day)
|
|
if stop and stop.is_set():
|
|
break
|
|
consumption = self.client.get_chart(self.config.pv_system_id, "consumption", day)
|
|
except ChartDataError:
|
|
# A day with no usable cloud data must not block recovery of newer gaps.
|
|
LOG.warning("Solar.web chart data unavailable for %s; leaving its gaps for the next check", day)
|
|
continue
|
|
for stamp in sorted(production.keys() & consumption.keys()):
|
|
epoch = stamp // 1000
|
|
if epoch not in missing or datetime.fromtimestamp(epoch, self.zone).date() != day:
|
|
continue
|
|
sample = history_sample(stamp, production[stamp], consumption[stamp],
|
|
self.source_id, self.config.pv_system_id)
|
|
self.store.add(sample)
|
|
missing.remove(epoch)
|
|
recovered += 1
|
|
LOG.info("History check finished: %d intervals recovered, %d still missing", recovered, len(missing))
|
|
return recovered
|
|
|
|
def run(self, stop):
|
|
while not stop.is_set():
|
|
try:
|
|
recovered = self.recover(stop=stop)
|
|
if recovered:
|
|
LOG.info("Recovered %d historical energy intervals from Solar.web", recovered)
|
|
except (TransportError, OSError, ValueError) as error:
|
|
LOG.warning("Historical recovery failed (%s); retrying on the next scheduled check", type(error).__name__)
|
|
stop.wait(self.config.check_interval_seconds)
|