From 9e66a9a97d54fca8f7274b5af8d6959a178442f4 Mon Sep 17 00:00:00 2001 From: dlawler489 <104159223@student.swin.edu.au> Date: Wed, 9 Sep 2026 12:25:09 +1000 Subject: [PATCH] Add battery telemetry for plants with a hybrid inverter/ESS Confirmed against Sungrow's own docs and live-tested against a real battery plant: SOC and charge/discharge power live at the *device* level (getDeviceRealTimeData against the hybrid inverter's ps_key, device_type 14), not in the plant-aggregate points used elsewhere in this client -- querying them via the plant-level endpoint just returns null. Battery status is fetched once per live "today" poll (not backfilled historically) and stored in three new nullable columns, self-migrated on startup since there's no Alembic here. Also fixes the dashboard's self-consumption line to derive from load - import rather than generation - export, which overstated "solar -> house" for battery systems (a chunk of that gap was going into charging, not the house). The live flow diagram now shows a Battery node (SOC + charge/discharge) that stays hidden entirely for solar-only plants like Kallangur. Co-Authored-By: Claude Sonnet 5 --- app/api/usage.py | 3 ++ app/db/models.py | 7 ++++ app/db/session.py | 25 +++++++++++++- app/isolarcloud/oauth_client.py | 56 ++++++++++++++++++++++++++++++++ app/isolarcloud/schemas.py | 9 +++++ app/static/css/style.css | 2 ++ app/web/templates/dashboard.html | 51 ++++++++++++++++++++++++++++- 7 files changed, 151 insertions(+), 2 deletions(-) diff --git a/app/api/usage.py b/app/api/usage.py index 283eb25..5d3396a 100644 --- a/app/api/usage.py +++ b/app/api/usage.py @@ -122,4 +122,7 @@ def live_status() -> dict: "load_kw": _kw(row.load_kwh), "import_kw": _kw(row.import_kwh), "export_kw": _kw(row.export_kwh), + "battery_soc_percent": row.battery_soc_percent, + "battery_charge_w": row.battery_charge_w, + "battery_discharge_w": row.battery_discharge_w, } diff --git a/app/db/models.py b/app/db/models.py index 953bb68..b1f1c60 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -50,6 +50,13 @@ class IntervalReadingRow(Base): import_kwh: Mapped[float] = mapped_column(Float) export_kwh: Mapped[float] = mapped_column(Float) resolution_minutes: Mapped[int] = mapped_column(Integer) + # Only ever set for plants with a battery, and only on the live "today" + # poll (not backfilled/historical rows) -- see oauth_client.py's + # get_battery_status. Nullable so solar-only plants (and every row + # written before this column existed) are unaffected. + battery_soc_percent: Mapped[float | None] = mapped_column(Float, nullable=True) + battery_charge_w: Mapped[float | None] = mapped_column(Float, nullable=True) + battery_discharge_w: Mapped[float | None] = mapped_column(Float, nullable=True) class PlanCacheRow(Base): diff --git a/app/db/session.py b/app/db/session.py index dc7d984..170f81c 100644 --- a/app/db/session.py +++ b/app/db/session.py @@ -1,7 +1,7 @@ from collections.abc import Iterator from contextlib import contextmanager -from sqlalchemy import create_engine +from sqlalchemy import create_engine, inspect, text from sqlalchemy.orm import Session, sessionmaker from app.config import settings @@ -10,9 +10,32 @@ from app.db.models import Base engine = create_engine(settings.db_url, connect_args={"check_same_thread": False}) SessionLocal = sessionmaker(bind=engine, expire_on_commit=False) +# Columns added after the table already existed in a deployed database. +# `create_all` only creates missing *tables*, never adds columns to an +# existing one -- there's no Alembic here, so new nullable columns are +# self-migrated by hand on startup instead. Only ever append to this list. +_ADDED_COLUMNS: list[tuple[str, str, str]] = [ + # (table, column, DDL type) + ("interval_reading", "battery_soc_percent", "FLOAT"), + ("interval_reading", "battery_charge_w", "FLOAT"), + ("interval_reading", "battery_discharge_w", "FLOAT"), +] + + +def _apply_column_migrations() -> None: + inspector = inspect(engine) + if "interval_reading" not in inspector.get_table_names(): + return # fresh DB -- create_all already created the table with every current column + existing = {(table, col["name"]) for table in {t for t, _, _ in _ADDED_COLUMNS} for col in inspector.get_columns(table)} + with engine.begin() as conn: + for table, column, ddl_type in _ADDED_COLUMNS: + if (table, column) not in existing: + conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {column} {ddl_type}")) + def init_db() -> None: Base.metadata.create_all(engine) + _apply_column_migrations() @contextmanager diff --git a/app/isolarcloud/oauth_client.py b/app/isolarcloud/oauth_client.py index b113a83..52d57b0 100644 --- a/app/isolarcloud/oauth_client.py +++ b/app/isolarcloud/oauth_client.py @@ -81,9 +81,22 @@ PLANT_LIST_PATH = "/openapi/platform/queryPowerStationList" DEVICE_LIST_PATH = "/openapi/platform/getDeviceListByPsId" REALTIME_DATA_PATH = "/openapi/platform/getPowerStationRealTimeData" MINUTE_HISTORY_PATH = "/openapi/platform/getPowerStationPointMinuteDataList" +DEVICE_REALTIME_DATA_PATH = "/openapi/platform/getDeviceRealTimeData" MAX_HISTORY_WINDOW = timedelta(hours=3) # the endpoint's own documented limit +# Battery/hybrid-inverter telemetry (SOC, charge/discharge power) lives at +# the *device* level, not in the plant-aggregate points used elsewhere in +# this client -- confirmed against Sungrow's own docs ("Common Energy +# Storage Inverter Measuring Points") and live-tested against a real +# battery plant (querying these via the plant-level realtime endpoint +# just returns null). Device type 14 covers both pure hybrid inverters and +# combined inverter+battery "Energy Storage System" units. +ESS_DEVICE_TYPE = "14" +POINT_BATTERY_SOC = "13141" # fraction 0-1, not a percentage +POINT_BATTERY_CHARGE_W = "13126" +POINT_BATTERY_DISCHARGE_W = "13150" + class ISolarCloudAuthError(RuntimeError): pass @@ -179,6 +192,38 @@ class ISolarCloudOAuthClient: result = self._authenticated_post(DEVICE_LIST_PATH, {"ps_id": ps_id, "page": 1, "size": 100}) return result.get("pageList") or [] + def get_battery_status(self, ps_id: str) -> dict[str, float] | None: + """Live battery state for plants with a hybrid inverter/ESS device, + or None for solar-only plants that have no such device (e.g. a + plain string-inverter system).""" + ess_device = next( + (d for d in self.get_device_list(ps_id) if str(d.get("device_type")) == ESS_DEVICE_TYPE), None + ) + if ess_device is None or not ess_device.get("ps_key"): + return None + result = self._authenticated_post( + DEVICE_REALTIME_DATA_PATH, + { + "ps_key_list": [ess_device["ps_key"]], + "point_id_list": [POINT_BATTERY_SOC, POINT_BATTERY_CHARGE_W, POINT_BATTERY_DISCHARGE_W], + "device_type": ESS_DEVICE_TYPE, + }, + ) + points_list = result.get("device_point_list") or [] + if not points_list: + return None + point = points_list[0].get("device_point") or {} + soc = point.get(f"p{POINT_BATTERY_SOC}") + if soc is None: + return None + charge_w = point.get(f"p{POINT_BATTERY_CHARGE_W}") + discharge_w = point.get(f"p{POINT_BATTERY_DISCHARGE_W}") + return { + "soc_percent": round(float(soc) * 100, 1), + "charge_w": float(charge_w) if charge_w is not None else 0.0, + "discharge_w": float(discharge_w) if discharge_w is not None else 0.0, + } + def get_realtime_points(self, ps_id: str, point_ids: list[str]) -> dict[str, float]: result = self._authenticated_post( REALTIME_DATA_PATH, {"ps_id_list": [ps_id], "point_id_list": point_ids, "is_get_point_dict": "0"} @@ -286,6 +331,14 @@ class ISolarCloudOAuthClient: set_sync_state(session, "isolarcloud_oauth_last_export_wh", str(export_wh)) set_sync_state(session, "isolarcloud_oauth_last_poll_ts", now.isoformat()) + battery = None + try: + battery = self.get_battery_status(ps_id) + except Exception: + # A battery-status hiccup shouldn't cost the core solar + # reading for this poll -- just leave it unset this round. + _LOGGER.exception("Battery status fetch failed for ps_id=%s", ps_id) + step_hours = elapsed_minutes / 60 return [ IntervalDataPoint( @@ -295,5 +348,8 @@ class ISolarCloudOAuthClient: import_kwh=import_kwh, export_kwh=export_kwh, resolution_minutes=round(elapsed_minutes), + battery_soc_percent=battery["soc_percent"] if battery else None, + battery_charge_w=battery["charge_w"] if battery else None, + battery_discharge_w=battery["discharge_w"] if battery else None, ) ] diff --git a/app/isolarcloud/schemas.py b/app/isolarcloud/schemas.py index 8f8156a..d19aa61 100644 --- a/app/isolarcloud/schemas.py +++ b/app/isolarcloud/schemas.py @@ -10,6 +10,12 @@ class IntervalDataPoint(BaseModel): import_kwh: float export_kwh: float resolution_minutes: int + # Only populated for plants with a battery (from a live device-level + # poll, not backfillable historical data) -- None for solar-only plants + # and for every historical/backfilled point. + battery_soc_percent: float | None = None + battery_charge_w: float | None = None + battery_discharge_w: float | None = None def as_row(self, source: str) -> dict: return { @@ -20,4 +26,7 @@ class IntervalDataPoint(BaseModel): "import_kwh": self.import_kwh, "export_kwh": self.export_kwh, "resolution_minutes": self.resolution_minutes, + "battery_soc_percent": self.battery_soc_percent, + "battery_charge_w": self.battery_charge_w, + "battery_discharge_w": self.battery_discharge_w, } diff --git a/app/static/css/style.css b/app/static/css/style.css index 109f713..71c0efc 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -7,6 +7,7 @@ --accent: #2563eb; --good: #16a34a; --bad: #dc2626; + --battery: #d97706; --card-bg: #f9fafb; } @@ -19,6 +20,7 @@ --accent: #6ea8fe; --good: #4ade80; --bad: #f87171; + --battery: #fbbf24; --card-bg: #1a1d24; } } diff --git a/app/web/templates/dashboard.html b/app/web/templates/dashboard.html index 39a0611..c667e39 100644 --- a/app/web/templates/dashboard.html +++ b/app/web/templates/dashboard.html @@ -21,6 +21,12 @@ + + + + @@ -57,6 +63,18 @@ Grid — + + + @@ -110,7 +128,10 @@ async function loadLiveFlow() { const load = data.load_kw ?? 0; const importKw = data.import_kw ?? 0; const exportKw = data.export_kw ?? 0; - const selfConsumed = Math.max(0, generation - exportKw); + // From the house's own side of the meter, not generation's -- "generation + // minus export" overstates this for a battery system, since a chunk of + // that gap can be going into charging rather than the house. + const selfConsumed = Math.max(0, load - importKw); document.getElementById('live-generation').textContent = fmtKw(generation).replace('—', '—'); document.getElementById('live-load').textContent = fmtKw(load).replace('—', '—'); @@ -135,6 +156,34 @@ async function loadLiveFlow() { gridLine.style.display = 'none'; gridLabel.textContent = 'no grid flow'; } + + // Battery -- only plants with one report a SOC at all; the node stays + // hidden entirely (e.g. a solar-only plant) when it's null. + const batteryNode = document.getElementById('live-battery-node'); + const soc = data.battery_soc_percent; + if (soc === null || soc === undefined) { + batteryNode.style.display = 'none'; + document.getElementById('flow-solar-battery').style.display = 'none'; + document.getElementById('flow-battery-house').style.display = 'none'; + } else { + batteryNode.style.display = ''; + const chargeKw = (data.battery_charge_w ?? 0) / 1000; + const dischargeKw = (data.battery_discharge_w ?? 0) / 1000; + document.getElementById('flow-solar-battery').style.display = chargeKw > 0.05 ? '' : 'none'; + document.getElementById('flow-battery-house').style.display = dischargeKw > 0.05 ? '' : 'none'; + + const status = chargeKw > 0.05 ? `${fmtKw(chargeKw).replace('—', '—')} charging` + : dischargeKw > 0.05 ? `${fmtKw(dischargeKw).replace('—', '—')} discharging` + : 'idle'; + document.getElementById('live-battery').textContent = `${soc.toFixed(0)}% · ${status}`; + + // Fill rises from the bottom of the battery icon's body (y=14) as SOC + // increases, capped at a max height of 20 (near the icon's top). + const fillHeight = Math.max(0, Math.min(20, (soc / 100) * 20)); + const fill = document.getElementById('live-battery-fill'); + fill.setAttribute('y', 14 - fillHeight); + fill.setAttribute('height', fillHeight); + } } async function loadDashboard(days) {