"""Shared measure-point parsing for both the V1 and OAuth2 iSolarCloud clients -- the `p` 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`-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