power-compare/app/db/repositories.py
dlawler489 3d7c8d9ca0
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 24s
Add live power-flow diagram and net revenue to the dashboard
New /api/usage/live endpoint derives instantaneous-ish kW from the most
recent interval reading, shown as a solar -> house/grid flow diagram
that updates every 30s and flips direction/color between exporting and
importing. /api/usage/summary now also reports net revenue for the
selected period, reusing the existing costing engine against the
current plan.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 11:52:16 +10:00

125 lines
4.1 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_latest_reading(session: Session) -> IntervalReadingRow | None:
stmt = select(IntervalReadingRow).order_by(IntervalReadingRow.ts.desc()).limit(1)
return session.scalars(stmt).first()
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