power-compare/app/db/models.py
dlawler489 9e66a9a97d
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 24s
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 <noreply@anthropic.com>
2026-09-09 12:25:09 +10:00

97 lines
3.8 KiB
Python

from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import DateTime, Float, Integer, String, UniqueConstraint
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy.types import TypeDecorator
class UTCDateTime(TypeDecorator):
"""SQLite has no real timezone-aware datetime type: SQLAlchemy's
`DateTime(timezone=True)` silently stores and returns *naive* datetimes
on this backend, which then get misinterpreted as system-local time by
`.astimezone()` anywhere they're read back (e.g. every time-of-use
match in the costing engine) -- a correctness bug that only shows up
when the host's local timezone isn't UTC. This type makes the
round-trip explicit: always stores naive-UTC, always returns
timezone-aware UTC.
"""
impl = DateTime
cache_ok = True
def process_bind_param(self, value: datetime | None, dialect):
if value is None:
return None
if value.tzinfo is None:
raise ValueError("UTCDateTime requires a timezone-aware datetime")
return value.astimezone(timezone.utc).replace(tzinfo=None)
def process_result_value(self, value: datetime | None, dialect):
if value is None:
return None
return value.replace(tzinfo=timezone.utc)
class Base(DeclarativeBase):
pass
class IntervalReadingRow(Base):
__tablename__ = "interval_reading"
__table_args__ = (UniqueConstraint("ts", "source", name="uq_interval_reading_ts_source"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
ts: Mapped[datetime] = mapped_column(UTCDateTime, index=True)
source: Mapped[str] = mapped_column(String) # 'isolarcloud' | 'mock'
generation_kwh: Mapped[float] = mapped_column(Float)
load_kwh: Mapped[float] = mapped_column(Float)
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):
__tablename__ = "plan_cache"
plan_id: Mapped[str] = mapped_column(String, primary_key=True)
retailer: Mapped[str] = mapped_column(String)
plan_name: Mapped[str] = mapped_column(String)
fuel_type: Mapped[str] = mapped_column(String)
distributors_json: Mapped[str] = mapped_column(String) # JSON list
last_refreshed: Mapped[datetime] = mapped_column(UTCDateTime)
raw_detail_json: Mapped[str] = mapped_column(String)
parsed_tariff_json: Mapped[str] = mapped_column(String)
class CurrentPlanConfigRow(Base):
__tablename__ = "current_plan_config"
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
retailer_name: Mapped[str] = mapped_column(String)
plan_name: Mapped[str] = mapped_column(String)
tariff_json: Mapped[str] = mapped_column(String)
class HouseholdConfigRow(Base):
__tablename__ = "household_config"
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
distributor: Mapped[str] = mapped_column(String, default="Energex")
postcode: Mapped[str] = mapped_column(String, default="")
isolarcloud_ps_id: Mapped[str] = mapped_column(String, default="")
class SyncStateRow(Base):
__tablename__ = "sync_state"
key: Mapped[str] = mapped_column(String, primary_key=True)
value: Mapped[str] = mapped_column(String)