power-compare/app/db/repositories.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

120 lines
3.9 KiB
Python

from __future__ import annotations
import json
from datetime import datetime, timezone
from sqlalchemy import func, select
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from sqlalchemy.orm import Session
from app.costing.engine import IntervalReading
from app.costing.tariff_models import TariffPlan
from app.db.models import (
CurrentPlanConfigRow,
HouseholdConfigRow,
IntervalReadingRow,
PlanCacheRow,
SyncStateRow,
)
_INSERT_BATCH_SIZE = 500 # keeps well under SQLite's default SQL variable limit
def upsert_interval_readings(session: Session, rows: list[dict]) -> int:
"""Insert interval readings, ignoring rows that already exist for the
same (ts, source) so repeated polling / restarts never duplicate data."""
inserted = 0
for i in range(0, len(rows), _INSERT_BATCH_SIZE):
batch = rows[i : i + _INSERT_BATCH_SIZE]
stmt = sqlite_insert(IntervalReadingRow).values(batch)
stmt = stmt.on_conflict_do_nothing(index_elements=["ts", "source"])
result = session.execute(stmt)
inserted += result.rowcount or 0
return inserted
def get_interval_readings(session: Session, start: datetime, end: datetime) -> list[IntervalReading]:
stmt = (
select(IntervalReadingRow)
.where(IntervalReadingRow.ts >= start, IntervalReadingRow.ts < end)
.order_by(IntervalReadingRow.ts)
)
return [
IntervalReading(ts=row.ts, import_kwh=row.import_kwh, export_kwh=row.export_kwh)
for row in session.scalars(stmt)
]
def get_earliest_reading_ts(session: Session) -> datetime | None:
return session.scalar(select(func.min(IntervalReadingRow.ts)))
def get_usage_series(session: Session, start: datetime, end: datetime) -> list[IntervalReadingRow]:
stmt = (
select(IntervalReadingRow)
.where(IntervalReadingRow.ts >= start, IntervalReadingRow.ts < end)
.order_by(IntervalReadingRow.ts)
)
return list(session.scalars(stmt))
def upsert_plan(session: Session, plan: TariffPlan, raw_detail: dict, fuel_type: str = "ELECTRICITY") -> None:
row = session.get(PlanCacheRow, plan.plan_id)
values = dict(
retailer=plan.retailer,
plan_name=plan.plan_name,
fuel_type=fuel_type,
distributors_json=json.dumps(plan.distributors),
last_refreshed=datetime.now(timezone.utc),
raw_detail_json=json.dumps(raw_detail),
parsed_tariff_json=plan.model_dump_json(),
)
if row is None:
session.add(PlanCacheRow(plan_id=plan.plan_id, **values))
else:
for k, v in values.items():
setattr(row, k, v)
def get_cached_plans(session: Session) -> list[TariffPlan]:
return [TariffPlan.model_validate_json(row.parsed_tariff_json) for row in session.scalars(select(PlanCacheRow))]
def get_current_plan(session: Session) -> TariffPlan | None:
row = session.get(CurrentPlanConfigRow, 1)
if row is None:
return None
return TariffPlan.model_validate_json(row.tariff_json)
def set_current_plan(session: Session, retailer_name: str, plan_name: str, plan: TariffPlan) -> None:
row = session.get(CurrentPlanConfigRow, 1)
if row is None:
row = CurrentPlanConfigRow(id=1)
session.add(row)
row.retailer_name = retailer_name
row.plan_name = plan_name
row.tariff_json = plan.model_dump_json()
def get_household_config(session: Session) -> HouseholdConfigRow:
row = session.get(HouseholdConfigRow, 1)
if row is None:
row = HouseholdConfigRow(id=1)
session.add(row)
session.flush()
return row
def get_sync_state(session: Session, key: str) -> str | None:
row = session.get(SyncStateRow, key)
return row.value if row else None
def set_sync_state(session: Session, key: str, value: str) -> None:
row = session.get(SyncStateRow, key)
if row is None:
session.add(SyncStateRow(key=key, value=value))
else:
row.value = value