FastAPI app that pulls interval usage from Sungrow iSolarCloud (OAuth2), caches AER Consumer Data Right electricity plan data, and compares actual metered usage against every available plan to estimate real annual cost (TOU, tiered feed-in, and flat tariffs). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
90 lines
3.3 KiB
Python
90 lines
3.3 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)
|
|
|
|
|
|
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)
|