- 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.
217 lines
8.0 KiB
Python
217 lines
8.0 KiB
Python
"""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
|