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),
|
"load_kw": _kw(row.load_kwh),
|
||||||
"import_kw": _kw(row.import_kwh),
|
"import_kw": _kw(row.import_kwh),
|
||||||
"export_kw": _kw(row.export_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)
|
import_kwh: Mapped[float] = mapped_column(Float)
|
||||||
export_kwh: Mapped[float] = mapped_column(Float)
|
export_kwh: Mapped[float] = mapped_column(Float)
|
||||||
resolution_minutes: Mapped[int] = mapped_column(Integer)
|
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):
|
class PlanCacheRow(Base):
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine, inspect, text
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
from app.config import settings
|
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})
|
engine = create_engine(settings.db_url, connect_args={"check_same_thread": False})
|
||||||
SessionLocal = sessionmaker(bind=engine, expire_on_commit=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:
|
def init_db() -> None:
|
||||||
Base.metadata.create_all(engine)
|
Base.metadata.create_all(engine)
|
||||||
|
_apply_column_migrations()
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
|
|
|
||||||
|
|
@ -81,9 +81,22 @@ PLANT_LIST_PATH = "/openapi/platform/queryPowerStationList"
|
||||||
DEVICE_LIST_PATH = "/openapi/platform/getDeviceListByPsId"
|
DEVICE_LIST_PATH = "/openapi/platform/getDeviceListByPsId"
|
||||||
REALTIME_DATA_PATH = "/openapi/platform/getPowerStationRealTimeData"
|
REALTIME_DATA_PATH = "/openapi/platform/getPowerStationRealTimeData"
|
||||||
MINUTE_HISTORY_PATH = "/openapi/platform/getPowerStationPointMinuteDataList"
|
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
|
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):
|
class ISolarCloudAuthError(RuntimeError):
|
||||||
pass
|
pass
|
||||||
|
|
@ -179,6 +192,38 @@ class ISolarCloudOAuthClient:
|
||||||
result = self._authenticated_post(DEVICE_LIST_PATH, {"ps_id": ps_id, "page": 1, "size": 100})
|
result = self._authenticated_post(DEVICE_LIST_PATH, {"ps_id": ps_id, "page": 1, "size": 100})
|
||||||
return result.get("pageList") or []
|
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]:
|
def get_realtime_points(self, ps_id: str, point_ids: list[str]) -> dict[str, float]:
|
||||||
result = self._authenticated_post(
|
result = self._authenticated_post(
|
||||||
REALTIME_DATA_PATH, {"ps_id_list": [ps_id], "point_id_list": point_ids, "is_get_point_dict": "0"}
|
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_export_wh", str(export_wh))
|
||||||
set_sync_state(session, "isolarcloud_oauth_last_poll_ts", now.isoformat())
|
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
|
step_hours = elapsed_minutes / 60
|
||||||
return [
|
return [
|
||||||
IntervalDataPoint(
|
IntervalDataPoint(
|
||||||
|
|
@ -295,5 +348,8 @@ class ISolarCloudOAuthClient:
|
||||||
import_kwh=import_kwh,
|
import_kwh=import_kwh,
|
||||||
export_kwh=export_kwh,
|
export_kwh=export_kwh,
|
||||||
resolution_minutes=round(elapsed_minutes),
|
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
|
import_kwh: float
|
||||||
export_kwh: float
|
export_kwh: float
|
||||||
resolution_minutes: int
|
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:
|
def as_row(self, source: str) -> dict:
|
||||||
return {
|
return {
|
||||||
|
|
@ -20,4 +26,7 @@ class IntervalDataPoint(BaseModel):
|
||||||
"import_kwh": self.import_kwh,
|
"import_kwh": self.import_kwh,
|
||||||
"export_kwh": self.export_kwh,
|
"export_kwh": self.export_kwh,
|
||||||
"resolution_minutes": self.resolution_minutes,
|
"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;
|
--accent: #2563eb;
|
||||||
--good: #16a34a;
|
--good: #16a34a;
|
||||||
--bad: #dc2626;
|
--bad: #dc2626;
|
||||||
|
--battery: #d97706;
|
||||||
--card-bg: #f9fafb;
|
--card-bg: #f9fafb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -19,6 +20,7 @@
|
||||||
--accent: #6ea8fe;
|
--accent: #6ea8fe;
|
||||||
--good: #4ade80;
|
--good: #4ade80;
|
||||||
--bad: #f87171;
|
--bad: #f87171;
|
||||||
|
--battery: #fbbf24;
|
||||||
--card-bg: #1a1d24;
|
--card-bg: #1a1d24;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,12 @@
|
||||||
<!-- Solar -> Grid (export) / Grid -> House (import), same path, direction/color/arrow flipped by JS -->
|
<!-- 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"
|
<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>
|
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 -->
|
<!-- Solar node -->
|
||||||
<g transform="translate(300 55)">
|
<g transform="translate(300 55)">
|
||||||
|
|
@ -57,6 +63,18 @@
|
||||||
</g>
|
</g>
|
||||||
<text x="480" y="163" text-anchor="middle" class="live-label">Grid</text>
|
<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>
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -110,7 +128,10 @@ async function loadLiveFlow() {
|
||||||
const load = data.load_kw ?? 0;
|
const load = data.load_kw ?? 0;
|
||||||
const importKw = data.import_kw ?? 0;
|
const importKw = data.import_kw ?? 0;
|
||||||
const exportKw = data.export_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-generation').textContent = fmtKw(generation).replace('—', '—');
|
||||||
document.getElementById('live-load').textContent = fmtKw(load).replace('—', '—');
|
document.getElementById('live-load').textContent = fmtKw(load).replace('—', '—');
|
||||||
|
|
@ -135,6 +156,34 @@ async function loadLiveFlow() {
|
||||||
gridLine.style.display = 'none';
|
gridLine.style.display = 'none';
|
||||||
gridLabel.textContent = 'no grid flow';
|
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) {
|
async function loadDashboard(days) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue