Add battery telemetry for plants with a hybrid inverter/ESS
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 24s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 24s
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 <noreply@anthropic.com>
This commit is contained in:
parent
3d7c8d9ca0
commit
9e66a9a97d
7 changed files with 151 additions and 2 deletions
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@
|
|||
<!-- Solar -> Grid (export) / Grid -> House (import), same path, direction/color/arrow flipped by JS -->
|
||||
<path id="flow-solar-grid" d="M 340 70 C 400 130, 420 150, 450 175" fill="none" stroke="var(--good)" stroke-width="3"
|
||||
stroke-dasharray="6 6" marker-end="url(#arrow)" class="flow-line"></path>
|
||||
<!-- Solar -> Battery (charging) -- only shown for plants with a battery -->
|
||||
<path id="flow-solar-battery" d="M 300 90 L 300 161" fill="none" stroke="var(--battery)" stroke-width="3"
|
||||
stroke-dasharray="6 6" marker-end="url(#arrow)" class="flow-line" style="display:none;"></path>
|
||||
<!-- Battery -> House (discharging), reverse of the above -->
|
||||
<path id="flow-battery-house" d="M 266 195 L 155 195" fill="none" stroke="var(--battery)" stroke-width="3"
|
||||
stroke-dasharray="6 6" marker-end="url(#arrow)" class="flow-line" style="display:none;"></path>
|
||||
|
||||
<!-- Solar node -->
|
||||
<g transform="translate(300 55)">
|
||||
|
|
@ -57,6 +63,18 @@
|
|||
</g>
|
||||
<text x="480" y="163" text-anchor="middle" class="live-label">Grid</text>
|
||||
<text id="live-grid" x="480" y="230" text-anchor="middle" class="live-value">—</text>
|
||||
|
||||
<!-- Battery node -- hidden entirely for plants with no battery -->
|
||||
<g id="live-battery-node" style="display:none;">
|
||||
<g transform="translate(300 195)">
|
||||
<circle r="34" fill="var(--card-bg)" stroke="var(--border)" stroke-width="1.5"></circle>
|
||||
<path d="M -10,-14 L 10,-14 L 10,-9 L 13,-9 L 13,14 L -13,14 L -13,-9 L -10,-9 Z"
|
||||
fill="none" stroke="var(--battery)" stroke-width="2" stroke-linejoin="round"></path>
|
||||
<rect id="live-battery-fill" x="-10" y="8" width="20" height="0" fill="var(--battery)"></rect>
|
||||
</g>
|
||||
<text x="300" y="163" text-anchor="middle" class="live-label">Battery</text>
|
||||
<text id="live-battery" x="300" y="230" text-anchor="middle" class="live-value">—</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue