power-compare/app/db/models.py
dlawler489 84c4922bbc Initial commit: solar usage vs AER plan comparison app
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>
2026-09-09 11:07:01 +10:00

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)