Files
home-automation/app/schemas/energy.py
T
tliu93 57f2459f60 M6-T09: add Energy data API (prices/costs/summary/dsmr-latest/recompute/tibber-test)
- routes/api/energy.py + schemas/energy.py: GET prices (tibber points or manual
  tariff, buy/sell consistent with the pricing strategies), GET costs (time
  window + limit, ascending), GET costs/summary (summarize passthrough), GET
  dsmr/latest, POST costs/recompute (idempotent, <=366d guard, CSRF), POST
  tibber/test (three-state, token never echoed, CSRF).
- main.py registers router; OpenAPI re-exported; tests for all endpoints.
2026-06-23 23:05:45 +02:00

217 lines
8.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Pydantic schemas for the Energy data API (M6-T09).
Covers six endpoint groups under /api/energy:
- GET /prices — price curve (tibber 15min points or manual tariff)
- GET /costs — energy_cost_period rows (time-range, paginated)
- GET /costs/summary — aggregated metered + standing charges credits
- GET /dsmr/latest — most recent dsmr_reading blob
- POST /costs/recompute — explicit idempotent recompute (returns count)
- POST /tibber/test — three-state Tibber connection test
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, Field
# ---------------------------------------------------------------------------
# GET /api/energy/prices
# ---------------------------------------------------------------------------
class PricePointSchema(BaseModel):
"""A single 15-minute price point (tibber) or placeholder entry."""
starts_at: datetime
buy: float = Field(description="All-in buy price in EUR/kWh (including taxes).")
sell: float = Field(description="Net sell price in EUR/kWh.")
level: str | None = Field(
default=None,
description="Tibber price level (CHEAP / NORMAL / EXPENSIVE); null for manual.",
)
class ManualTariffSchema(BaseModel):
"""Fixed-tariff breakdown for manual contracts.
Prices are the *effective* buy prices as used by the billing engine
(energy_buy_x + energy_tax + ode) and the raw sell prices.
"""
buy_dal: float = Field(description="Effective buy price, low-tariff / dal (EUR/kWh).")
buy_normal: float = Field(description="Effective buy price, normal / high-tariff (EUR/kWh).")
sell_dal: float = Field(description="Sell price, low-tariff / dal (EUR/kWh).")
sell_normal: float = Field(description="Sell price, normal / high-tariff (EUR/kWh).")
class PricesResponse(BaseModel):
"""Response for GET /api/energy/prices.
``kind`` mirrors the active contract kind:
- ``"tibber"`` → ``points`` has actual 15-min price entries; ``tariff`` is null.
- ``"manual"`` → ``points`` is empty; ``tariff`` carries the fixed-rate table.
- ``None`` → no active contract; both ``points`` and ``tariff`` are empty/null.
``currency`` comes from the active contract (or "EUR" fallback).
``points`` is always ascending by ``starts_at``.
"""
kind: str | None = Field(
description="Active contract kind ('tibber' or 'manual'), or null if no active contract."
)
currency: str = Field(description="ISO 4217 currency code.")
points: list[PricePointSchema] = Field(
description=(
"15-minute price points for tibber contracts (ascending by starts_at). "
"Empty for manual contracts or when no active contract exists."
)
)
tariff: ManualTariffSchema | None = Field(
default=None,
description=(
"Fixed tariff table for manual contracts. "
"Null for tibber contracts and when no active contract exists."
),
)
# ---------------------------------------------------------------------------
# GET /api/energy/costs
# ---------------------------------------------------------------------------
class CostPeriodSchema(BaseModel):
"""One 15-minute billing record from the energy_cost_period table."""
period_start: datetime
d1_kwh: float = Field(description="Delivered low-tariff kWh for this period.")
d2_kwh: float = Field(description="Delivered normal-tariff kWh for this period.")
r1_kwh: float = Field(description="Returned low-tariff kWh for this period.")
r2_kwh: float = Field(description="Returned normal-tariff kWh for this period.")
import_cost: float = Field(description="Cost of electricity drawn from grid (EUR).")
export_revenue: float = Field(description="Revenue from electricity fed to grid (EUR).")
net_cost: float = Field(description="import_cost export_revenue (EUR).")
currency: str = Field(description="ISO 4217 currency code.")
degraded: bool = Field(
description="True when the period was computed with incomplete data."
)
contract_version_id: int | None = Field(
default=None,
description="FK to the contract version used for this billing period (null when degraded).",
)
model_config = {"from_attributes": True}
class CostsResponse(BaseModel):
"""Response for GET /api/energy/costs."""
items: list[CostPeriodSchema]
total: int = Field(description="Number of items returned.")
# ---------------------------------------------------------------------------
# GET /api/energy/costs/summary
# ---------------------------------------------------------------------------
class SummaryResponse(BaseModel):
"""Response for GET /api/energy/costs/summary.
All monetary values are in ``currency``.
``total_payable = metered_net + fixed_costs credits``
"""
currency: str
metered_import: float = Field(description="Σ import_cost for non-degraded periods.")
metered_export: float = Field(description="Σ export_revenue for non-degraded periods.")
metered_net: float = Field(description="Σ net_cost for non-degraded periods.")
fixed_costs: float = Field(
description="Standing charges (network_fee + management_fee) apportioned over the interval."
)
credits: float = Field(
description="Energy-tax credit (heffingskorting) apportioned over the interval."
)
total_payable: float = Field(
description="metered_net + fixed_costs credits (actual amount owed)."
)
period_count: int = Field(description="Number of non-degraded billing periods in range.")
degraded_count: int = Field(description="Number of degraded billing periods in range.")
days: float = Field(description="Interval length in days.")
# ---------------------------------------------------------------------------
# GET /api/energy/dsmr/latest
# ---------------------------------------------------------------------------
class DsmrLatestResponse(BaseModel):
"""Response for GET /api/energy/dsmr/latest.
``found`` is False when no dsmr_reading rows exist yet. The front-end
should check ``found`` before reading ``recorded_at`` or ``payload``.
"""
found: bool
recorded_at: datetime | None = None
payload: dict[str, Any] | None = None
# ---------------------------------------------------------------------------
# POST /api/energy/costs/recompute
# ---------------------------------------------------------------------------
class RecomputeResponse(BaseModel):
"""Response for POST /api/energy/costs/recompute."""
recomputed: int = Field(
description=(
"Number of 15-minute periods for which a billing record was written "
"(inserted or updated). Periods skipped due to missing contract or "
"missing Tibber price are not counted."
)
)
# ---------------------------------------------------------------------------
# POST /api/energy/tibber/test
# ---------------------------------------------------------------------------
class TibberTestPriceSchema(BaseModel):
"""Current Tibber price point returned on a successful test.
Carries enough fields for the front-end to confirm the API is working and
display the live price. The API token is **never** included.
"""
starts_at: datetime
total: float
energy: float
tax: float
currency: str
level: str | None = None
class TibberTestResponse(BaseModel):
"""Three-state response for POST /api/energy/tibber/test.
Possible ``result`` values:
- ``"success"`` — Tibber API responded with a valid price.
- ``"config-error"`` — Token is missing or not configured.
- ``"failed"`` — API call failed (auth rejected, network error, timeout, etc.).
``price`` is populated only on ``"success"``; it is null otherwise.
``message`` always contains a human-readable explanation.
"""
result: Literal["success", "config-error", "failed"]
message: str
price: TibberTestPriceSchema | None = None