"""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