Files
home-automation/app/api/routes/api/meter_costs.py
T

151 lines
6.6 KiB
Python

"""Authenticated API for the thermal 15-minute cost ledger."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.api.routes.api.deps import require_csrf, require_session
from app.dependencies import get_db
from app.models.energy import MeterCostPeriod
from app.schemas.meter_cost import (
MeterCostPeriodSchema,
MeterCostRecomputeResponse,
MeterCostsResponse,
ThermalCostSummaryResponse,
)
from app.services.auth import AuthenticatedSession
from app.services.meter_cost import recompute_range, summarize
from app.services.timezone import local_midnight_utc, local_now
router = APIRouter(prefix="/api/energy/meter-costs", tags=["api-energy"])
_LIMIT_MAX = 5000
_RECOMPUTE_MAX_DAYS = 31
_QUARTER = timedelta(minutes=15)
def _utc(value: datetime) -> datetime:
return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
def _decimal_strings(value: object) -> object:
if isinstance(value, dict):
return {str(key): _decimal_strings(item) for key, item in value.items()}
if isinstance(value, Decimal):
return format(value, "f")
return str(value) if isinstance(value, (int, float)) else value
def _row_schema(row: MeterCostPeriod) -> MeterCostPeriodSchema:
return MeterCostPeriodSchema(
commodity=row.commodity,
period_start=_utc(row.period_start), period_end=_utc(row.period_end),
meter_id=row.meter_id, source_binding_id=row.source_binding_id,
contract_version_id=row.contract_version_id, quantity=format(row.quantity, "f"),
cost=format(row.cost, "f"), currency=row.currency,
cost_breakdown=_decimal_strings(row.cost_breakdown),
pricing_snapshot=_decimal_strings(row.pricing_snapshot), quality=row.quality,
degraded=row.degraded, degraded_reason=row.degraded_reason,
)
@router.get("", response_model=MeterCostsResponse)
def get_meter_costs(
scope: Literal["thermal"] = Query("thermal"),
commodity: Literal["heating", "hot_water"] | None = None,
start: datetime | None = None,
end: datetime | None = None,
limit: int = Query(500, ge=1, le=_LIMIT_MAX),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
) -> MeterCostsResponse:
"""List thermal rows in a half-open time window with stable pagination."""
del scope
if start is not None and end is not None and _utc(end) <= _utc(start):
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "'end' must be after 'start'.")
clauses = []
if commodity is not None:
clauses.append(MeterCostPeriod.commodity == commodity)
if start is not None:
clauses.append(MeterCostPeriod.period_start >= _utc(start))
if end is not None:
clauses.append(MeterCostPeriod.period_start < _utc(end))
total = db.scalar(select(func.count()).select_from(MeterCostPeriod).where(*clauses)) or 0
rows = db.execute(
select(MeterCostPeriod).where(*clauses).order_by(MeterCostPeriod.period_start, MeterCostPeriod.id)
.offset(offset).limit(limit)
).scalars().all()
return MeterCostsResponse(items=[_row_schema(row) for row in rows], total=total)
@router.get("/summary", response_model=ThermalCostSummaryResponse)
def get_meter_cost_summary(
scope: Literal["thermal"] = Query("thermal"),
start: datetime | None = None,
end: datetime | None = None,
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
) -> ThermalCostSummaryResponse:
"""Summarize thermal variable and once-per-contract daily fixed costs."""
del scope
if start is None or end is None:
today = local_now().date()
start = start or local_midnight_utc(today)
end = end or local_midnight_utc(today + timedelta(days=1))
start, end = _utc(start), _utc(end)
if end <= start:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "'end' must be after 'start'.")
result = summarize(db, start, end)
breakdown = result["breakdown"]
fixed_breakdown = result["fixed_breakdown"]
fixed = result["fixed_cost"]
return ThermalCostSummaryResponse(
currency=result["currency"], heating=format(breakdown["heating"], "f"),
hot_water_heating=format(breakdown["hot_water_heating"], "f"),
hot_water=format(breakdown["hot_water"], "f"), hot_water_tax=format(breakdown["hot_water_tax"], "f"),
variable_subtotal=format(result["variable_cost"], "f"),
fixed_breakdown={key: format(value, "f") for key, value in fixed_breakdown.items()},
fixed_subtotal=format(fixed, "f"),
all_in=format(result["total_cost"], "f"), period_count=result["period_count"],
degraded_count=result["degraded_count"],
)
@router.post("/recompute", response_model=MeterCostRecomputeResponse)
def post_meter_cost_recompute(
scope: Literal["thermal"] = Query("thermal"),
start: datetime = Query(...), end: datetime = Query(...),
db: Session = Depends(get_db), _auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> MeterCostRecomputeResponse:
"""Atomically overwrite closed, UTC-quarter thermal rows in a bounded window."""
del scope
start, end = _utc(start), _utc(end)
if end <= start or end - start > timedelta(days=_RECOMPUTE_MAX_DAYS):
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "invalid or overlarge recompute window")
if start.minute % 15 or start.second or start.microsecond or end.minute % 15 or end.second or end.microsecond:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "start and end must align to UTC quarters")
if end > datetime.now(UTC):
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "recompute window must be closed")
try:
processed = recompute_range(db, start, end, commit=False)
# Ensure pending upserts participate in this transaction before the
# counts are read; a flush/query failure must still roll everything back.
db.flush()
rows = db.execute(select(MeterCostPeriod.degraded).where(
MeterCostPeriod.period_start >= start, MeterCostPeriod.period_start < end
)).scalars().all()
degraded = sum(bool(value) for value in rows)
db.commit()
except Exception:
db.rollback()
raise
return MeterCostRecomputeResponse(processed=processed, normal=len(rows) - degraded, degraded=degraded)