This commit is contained in:
1 parent
e1d22a26ed
commit
9e0230731b
10 files changed
+118
-223
No files matched your search
+26
-87
@@ -1,8 +1,4 @@
|
||||
"""Optional signed-in Solar.web chart recovery of missing five-minute energy.
|
||||
|
||||
GEN24's local Solar API exposes no archive. Use the same private account login,
|
||||
chart series and five-minute W-to-Wh conversion as the sibling solar website.
|
||||
"""
|
||||
"""Recover missing five-minute power samples from signed-in Solar.web charts."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import logging
|
||||
@@ -17,91 +13,33 @@ from .http import TransportError
|
||||
from .solarweb import ChartDataError, SolarWebClient
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
CHANNELS = {
|
||||
"production": "EnergyProductionTotal", "consumption": "EnergyConsumptionTotal",
|
||||
"self_consumption": "EnergySelfConsumptionTotal", "bought": "EnergyPurchased", "sold": "EnergyFeedIn",
|
||||
}
|
||||
|
||||
|
||||
def normalize_history_sample(sample):
|
||||
"""Expose recovered power in the same fields as live samples, including old archives."""
|
||||
if "history_interval" not in sample:
|
||||
return sample
|
||||
interval = sample["history_interval"]
|
||||
if interval["duration_seconds"] != 300:
|
||||
raise ValueError("Historical record is not a five-minute interval")
|
||||
energy = interval["energy_wh"]
|
||||
if any(isinstance(energy[field], bool) or not isinstance(energy[field], (int, float))
|
||||
or not math.isfinite(energy[field]) or not 0 <= energy[field] <= 1e18 for field in CHANNELS):
|
||||
raise ValueError("Historical energy is missing or invalid")
|
||||
power = {field: energy[field] * 12 for field in CHANNELS}
|
||||
chart = sample.get("raw", {}).get("solarweb_history", {}).get("chart_power_w")
|
||||
if chart is not None:
|
||||
# Retain the chart's original watts instead of a W -> Wh -> W round trip.
|
||||
generated, consumed = chart["production"], chart["consumption"]
|
||||
if any(isinstance(value, bool) or not isinstance(value, (int, float))
|
||||
or not math.isfinite(value) or value < 0 for value in (generated, consumed)):
|
||||
raise ValueError("Historical chart power is missing or invalid")
|
||||
if not all(math.isclose(value, power[field]) for field, value in
|
||||
(("production", generated), ("consumption", consumed))):
|
||||
raise ValueError("Historical chart power disagrees with interval energy")
|
||||
power = {"production": generated, "consumption": consumed,
|
||||
"bought": max(0, consumed - generated), "sold": max(0, generated - consumed)}
|
||||
return {
|
||||
**sample, "poll_interval_seconds": 300,
|
||||
"readings": {**sample["readings"],
|
||||
"production_ac_w": power["production"], "consumption_w": power["consumption"],
|
||||
"grid_w": power["bought"] - power["sold"],
|
||||
"grid_import_w": power["bought"], "grid_export_w": power["sold"]},
|
||||
"source_timestamps": {**sample.get("source_timestamps", {}), "powerflow": sample["observed_at"]},
|
||||
}
|
||||
|
||||
|
||||
def history_sample(entry, source_id, system_id):
|
||||
stamp = milliseconds(entry["logDateTime"])
|
||||
if stamp % 300_000 or entry.get("logDuration") != 300:
|
||||
raise ValueError("Historical record is not an aligned five-minute interval")
|
||||
channels = {}
|
||||
for channel in entry["channels"]:
|
||||
name, value = channel.get("channelName"), channel.get("value")
|
||||
if name not in CHANNELS.values():
|
||||
continue
|
||||
if name in channels or channel.get("unit") != "Wh" or channel.get("channelType") != "Energy":
|
||||
raise ValueError("Unexpected historical channel metadata")
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or not 0 <= value <= 1e18:
|
||||
raise ValueError("Historical energy is missing or invalid")
|
||||
channels[name] = value
|
||||
energy = {field: channels[name] for field, name in CHANNELS.items()}
|
||||
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))
|
||||
return normalize_history_sample({
|
||||
grid = consumed - generated
|
||||
return {
|
||||
"schema_version": 1,
|
||||
# Preserve IDs of intervals already recovered with the former Query API.
|
||||
# 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,
|
||||
"readings": dict.fromkeys([
|
||||
"production_w", "production_ac_w", "consumption_w", "grid_w", "grid_import_w", "grid_export_w",
|
||||
"production_ac_total_wh", "grid_import_total_wh", "grid_export_total_wh", "boiler_enabled", "boiler_reason",
|
||||
]),
|
||||
"source_timestamps": {},
|
||||
"errors": {"meter": "Cumulative meter readings are unavailable in recovered energy intervals",
|
||||
"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": entry},
|
||||
"history_interval": {"provider": "fronius-solarweb", "start": when, "duration_seconds": 300, "energy_wh": energy},
|
||||
})
|
||||
|
||||
|
||||
def chart_entries(production, consumption):
|
||||
"""Pair real chart samples; assume constant W over five minutes, as solar does."""
|
||||
for stamp in sorted(production.keys() & consumption.keys()):
|
||||
generated, consumed = production[stamp], consumption[stamp]
|
||||
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}
|
||||
yield {"logDateTime": utc_text(datetime.fromtimestamp(stamp / 1000, timezone.utc)), "logDuration": 300,
|
||||
"channels": [{"channelName": name, "channelType": "Energy", "unit": "Wh", "value": values[field]}
|
||||
for field, name in CHANNELS.items()],
|
||||
"chart_power_w": {"production": generated, "consumption": consumed},
|
||||
"energy_method": "constant_power_over_five_minutes"}
|
||||
"raw": {"solarweb_history": {"chart_power_w": {"production": generated, "consumption": consumed}}},
|
||||
}
|
||||
|
||||
|
||||
class HistoryRecovery:
|
||||
@@ -158,11 +96,12 @@ class HistoryRecovery:
|
||||
# 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 entry in chart_entries(production, consumption):
|
||||
epoch = milliseconds(entry["logDateTime"]) // 1000
|
||||
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(entry, self.source_id, self.config.pv_system_id)
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user