power-compare/app/isolarcloud/parsing.py
dlawler489 84c4922bbc Initial commit: solar usage vs AER plan comparison app
FastAPI app that pulls interval usage from Sungrow iSolarCloud (OAuth2),
caches AER Consumer Data Right electricity plan data, and compares actual
metered usage against every available plan to estimate real annual cost
(TOU, tiered feed-in, and flat tariffs).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 11:07:01 +10:00

98 lines
4.1 KiB
Python

"""Shared measure-point parsing for both the V1 and OAuth2 iSolarCloud
clients -- the `p<point_id>` frame shape and cumulative-energy diffing
logic are identical regardless of which auth style fetched the frames.
"""
from __future__ import annotations
from datetime import datetime
from zoneinfo import ZoneInfo
from app.isolarcloud.schemas import IntervalDataPoint
TS_FORMAT = "%Y%m%d%H%M%S"
# iSolarCloud's history/real-time endpoints report `time_stamp` in "the
# time zone where the plant is located" per their own docs, not UTC --
# for this app (QLD, no DST) that's a fixed Australia/Brisbane offset.
PLANT_TZ = ZoneInfo("Australia/Brisbane")
# Measure-point codes for a solar-only (no battery) household, confirmed
# against Sungrow's official developer-portal documentation (Appendix 10 /
# the Monitoring endpoint examples) -- shared across both device-level (V1)
# and plant-level (OAuth2) queries, since these are plain telemetry point
# definitions, not tied to which auth style an app uses.
#
# POINT_GENERATION was originally 83067 ("Total Active Power of PV"), which
# is a documented plant-level point but returned nothing at all for this
# real single-inverter residential plant (likely a microinverter/optimizer
# -specific point that a standard string inverter doesn't populate) --
# confirmed live against the real account that 83033 ("Plant Power") is
# what this plant actually reports, matching 83002 (Inverter AC Power)
# 1:1 for this single-inverter system.
POINT_GENERATION = "83033" # Plant Power (W, instantaneous)
POINT_LOAD = "83106" # Load Power (W, instantaneous)
POINT_IMPORT = "83105" # Total Purchased Energy (Wh, lifetime cumulative)
POINT_EXPORT = "83075" # Feed-in Energy Total (Wh, lifetime cumulative)
def extract_points(frame: dict) -> dict[str, float]:
values: dict[str, float] = {}
for key, value in frame.items():
if key == "time_stamp" or not key.startswith("p"):
continue
try:
values[key[1:]] = float(value)
except (TypeError, ValueError):
continue
return values
def merge_frames(*frame_lists: list[dict], resolution_minutes: int = 5) -> list[IntervalDataPoint]:
"""Combines one or more lists of `p<id>`-keyed frames (sharing the same
`time_stamp` format) into IntervalDataPoints, converting the
cumulative import/export counters into per-interval kWh by diffing
consecutive frames.
"""
by_ts: dict[datetime, dict[str, float]] = {}
for frames in frame_lists:
for frame in frames:
# Naive by construction (strptime); attach the plant's local
# tz so this is a well-defined instant, not a UTC-DateTime-column
# crash waiting to happen (see UTCDateTime in app/db/models.py).
ts = datetime.strptime(frame["time_stamp"], TS_FORMAT).replace(tzinfo=PLANT_TZ)
by_ts.setdefault(ts, {}).update(extract_points(frame))
points: list[IntervalDataPoint] = []
prev_import_wh: float | None = None
prev_export_wh: float | None = None
for ts in sorted(by_ts):
values = by_ts[ts]
generation_w = values.get(POINT_GENERATION, 0.0)
load_w = values.get(POINT_LOAD, 0.0)
import_wh = values.get(POINT_IMPORT)
export_wh = values.get(POINT_EXPORT)
import_kwh = (
max(0.0, (import_wh - prev_import_wh) / 1000) if prev_import_wh is not None and import_wh is not None else 0.0
)
export_kwh = (
max(0.0, (export_wh - prev_export_wh) / 1000) if prev_export_wh is not None and export_wh is not None else 0.0
)
if import_wh is not None:
prev_import_wh = import_wh
if export_wh is not None:
prev_export_wh = export_wh
step_hours = resolution_minutes / 60
points.append(
IntervalDataPoint(
ts=ts,
generation_kwh=generation_w * step_hours / 1000,
load_kwh=load_w * step_hours / 1000,
import_kwh=import_kwh,
export_kwh=export_kwh,
resolution_minutes=resolution_minutes,
)
)
return points