Compare commits
15
Commits
e3d0d17cac
...
v1.4.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d07a083e03 | ||
|
|
b65f700d56 | ||
|
|
f4cea3874b | ||
|
|
134f0abb5f | ||
|
|
3e04b15656 | ||
|
|
f2e8f6a8e7 | ||
|
|
90a03e7fd6 | ||
|
|
d3fc90b320 | ||
|
|
d4acfc438a | ||
|
|
c26160b10b | ||
|
|
efbe36d7c0 | ||
|
|
f663981cdb | ||
|
|
da05fd2f09 | ||
|
|
188168b16a | ||
|
|
f1e7ce3133 |
@@ -0,0 +1,104 @@
|
|||||||
|
"""add uuid column to meter table
|
||||||
|
|
||||||
|
Adds a stable ``uuid`` (UUID v4 string) column to the ``meter`` table so that
|
||||||
|
each meter epoch has a durable identity anchor suitable for use as an HA
|
||||||
|
Discovery ``unique_id``.
|
||||||
|
|
||||||
|
**Migration strategy (SQLite-safe)**:
|
||||||
|
|
||||||
|
SQLite does not support adding a NOT NULL + UNIQUE column to a non-empty table
|
||||||
|
in a single ``ALTER TABLE ADD COLUMN`` statement (adding a NOT NULL column
|
||||||
|
without a default value is rejected if the table already has rows). The
|
||||||
|
safe approach used here is:
|
||||||
|
|
||||||
|
1. Add ``uuid`` as a **nullable** column (SQLite allows this).
|
||||||
|
2. **Back-fill** every existing ``meter`` row with a distinct ``str(uuid4())``
|
||||||
|
value. Each row gets its *own* random UUID — not a shared value — so the
|
||||||
|
subsequent UNIQUE constraint is satisfied.
|
||||||
|
3. Use ``batch_alter_table`` (which re-creates the table under the hood in
|
||||||
|
SQLite) to alter the column to ``NOT NULL`` and add a UNIQUE constraint.
|
||||||
|
|
||||||
|
**Idempotency**: only rows where ``uuid IS NULL`` are back-filled; rows that
|
||||||
|
already have a uuid (e.g. from a repeated upgrade after a partial failure) are
|
||||||
|
left untouched.
|
||||||
|
|
||||||
|
**Audit**: after back-fill, the count of rows with ``uuid IS NULL`` must be
|
||||||
|
exactly zero; if not, the migration raises ``RuntimeError`` and rolls back.
|
||||||
|
|
||||||
|
**Data safety**: this migration is additive only — no existing rows are deleted
|
||||||
|
or overwritten; it only adds a new column and fills it in.
|
||||||
|
|
||||||
|
Revision ID: 20260625_14_meter_uuid
|
||||||
|
Revises: 20260625_13_meter_table
|
||||||
|
Create Date: 2026-06-25 00:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid as _uuid
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "20260625_14_meter_uuid"
|
||||||
|
down_revision: Union[str, None] = "20260625_13_meter_table"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# 1. Add uuid as a nullable column. #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
with op.batch_alter_table("meter", schema=None) as batch_op:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column("uuid", sa.String(length=36), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# 2. Back-fill: assign a distinct UUID to every row that has #
|
||||||
|
# uuid IS NULL. Each row gets its own random value so that the #
|
||||||
|
# subsequent UNIQUE constraint is satisfied. #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
rows = conn.execute(sa.text("SELECT id FROM meter WHERE uuid IS NULL")).fetchall()
|
||||||
|
for (meter_id,) in rows:
|
||||||
|
new_uuid = str(_uuid.uuid4())
|
||||||
|
conn.execute(
|
||||||
|
sa.text("UPDATE meter SET uuid = :uuid WHERE id = :mid"),
|
||||||
|
{"uuid": new_uuid, "mid": meter_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# 3. Audit: verify no rows remain with uuid IS NULL. #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
null_count_row = conn.execute(
|
||||||
|
sa.text("SELECT COUNT(*) FROM meter WHERE uuid IS NULL")
|
||||||
|
).fetchone()
|
||||||
|
null_count: int = null_count_row[0] if null_count_row else 0
|
||||||
|
|
||||||
|
if null_count != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"meter.uuid back-fill audit failed: {null_count} meter row(s) still have "
|
||||||
|
"uuid IS NULL after back-fill. Migration aborted to protect data integrity."
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# 4. Alter column to NOT NULL + UNIQUE (requires batch on SQLite). #
|
||||||
|
# batch_alter_table re-creates the table, so the UNIQUE constraint #
|
||||||
|
# and NOT NULL are applied atomically. #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
with op.batch_alter_table("meter", schema=None) as batch_op:
|
||||||
|
batch_op.alter_column(
|
||||||
|
"uuid",
|
||||||
|
existing_type=sa.String(length=36),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
batch_op.create_unique_constraint("uq_meter_uuid", ["uuid"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Drop the UNIQUE constraint and the uuid column (batch on SQLite).
|
||||||
|
with op.batch_alter_table("meter", schema=None) as batch_op:
|
||||||
|
batch_op.drop_constraint("uq_meter_uuid", type_="unique")
|
||||||
|
batch_op.drop_column("uuid")
|
||||||
@@ -169,7 +169,7 @@ def get_prices(
|
|||||||
|
|
||||||
Response ``points`` carries per-slot:
|
Response ``points`` carries per-slot:
|
||||||
- ``buy = total`` (Tibber all-inclusive price)
|
- ``buy = total`` (Tibber all-inclusive price)
|
||||||
- ``sell = total − energy_tax − sell_adjust`` (from active version values)
|
- ``sell = total − energy_tax − sell_fee − sell_adjust`` (from active version values)
|
||||||
- ``level`` (Tibber price level, may be null)
|
- ``level`` (Tibber price level, may be null)
|
||||||
|
|
||||||
``tariff`` is null.
|
``tariff`` is null.
|
||||||
@@ -222,7 +222,8 @@ def get_prices(
|
|||||||
)
|
)
|
||||||
rows = list(reversed(db.execute(stmt).scalars().all()))
|
rows = list(reversed(db.execute(stmt).scalars().all()))
|
||||||
|
|
||||||
# Derive sell price per-point using version values (energy_tax + sell_adjust).
|
# Derive sell price per-point using version values
|
||||||
|
# (energy_tax + sell_fee + sell_adjust).
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
def _d(v: Any) -> Decimal:
|
def _d(v: Any) -> Decimal:
|
||||||
@@ -230,12 +231,13 @@ def get_prices(
|
|||||||
|
|
||||||
energy = version.values.get("energy", {}) if version.values else {}
|
energy = version.values.get("energy", {}) if version.values else {}
|
||||||
energy_tax = _d(energy.get("energy_tax", 0))
|
energy_tax = _d(energy.get("energy_tax", 0))
|
||||||
|
sell_fee = _d(energy.get("sell_fee", 0))
|
||||||
sell_adjust = _d(energy.get("sell_adjust", 0))
|
sell_adjust = _d(energy.get("sell_adjust", 0))
|
||||||
|
|
||||||
points = []
|
points = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
total = _d(row.total)
|
total = _d(row.total)
|
||||||
sell = float(total - energy_tax - sell_adjust)
|
sell = float(total - energy_tax - sell_fee - sell_adjust)
|
||||||
points.append(
|
points.append(
|
||||||
PricePointSchema(
|
PricePointSchema(
|
||||||
starts_at=_as_utc(row.starts_at),
|
starts_at=_as_utc(row.starts_at),
|
||||||
|
|||||||
@@ -79,6 +79,24 @@ router = APIRouter(prefix="/api/energy", tags=["api-energy-meters"])
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _trigger_discovery_republish(session: Session) -> None:
|
||||||
|
"""Call publish_discovery after a meter write operation (best-effort).
|
||||||
|
|
||||||
|
No-op if MQTT / discovery is not enabled or the broker is not connected
|
||||||
|
(publish_discovery guards internally). All errors are swallowed so that a
|
||||||
|
discovery failure never breaks the API response.
|
||||||
|
|
||||||
|
Must be called **after** db.commit() so that publish_discovery sees the
|
||||||
|
final committed state of the meter table when it rebuilds the catalog.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from app.services.ha_discovery import publish_discovery
|
||||||
|
|
||||||
|
publish_discovery(session)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("_trigger_discovery_republish: publish_discovery raised an error")
|
||||||
|
|
||||||
|
|
||||||
def _get_meter_or_404(db: Session, meter_id: int) -> Meter:
|
def _get_meter_or_404(db: Session, meter_id: int) -> Meter:
|
||||||
"""Return the meter with the given id or raise 404."""
|
"""Return the meter with the given id or raise 404."""
|
||||||
meter: Optional[Meter] = db.get(Meter, meter_id)
|
meter: Optional[Meter] = db.get(Meter, meter_id)
|
||||||
@@ -185,9 +203,10 @@ def declare_energy_meter(
|
|||||||
|
|
||||||
**Retroactive recompute**: if ``started_at`` is in the past, billing
|
**Retroactive recompute**: if ``started_at`` is in the past, billing
|
||||||
records from that point forward are re-judged via ``recompute_range`` to
|
records from that point forward are re-judged via ``recompute_range`` to
|
||||||
reflect the new meter attribution. The response includes the count of
|
reflect the new meter attribution. The recompute is transparent — the
|
||||||
recomputed periods in ``recomputed_periods`` (not part of ``MeterResponse``
|
response body is the created meter (``MeterResponse``) only and does **not**
|
||||||
— the recompute is transparent; callers should re-fetch costs if needed).
|
include a recompute count; callers should re-fetch costs if they need the
|
||||||
|
updated totals.
|
||||||
"""
|
"""
|
||||||
started_at_utc = _localize_started_at(body.started_at)
|
started_at_utc = _localize_started_at(body.started_at)
|
||||||
|
|
||||||
@@ -216,6 +235,11 @@ def declare_energy_meter(
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(new_meter)
|
db.refresh(new_meter)
|
||||||
|
|
||||||
|
# Trigger HA discovery re-publish so the new active meter's energy-cost
|
||||||
|
# device/sensor configuration is pushed to Home Assistant. Best-effort:
|
||||||
|
# failures are logged and swallowed; the API response is not affected.
|
||||||
|
_trigger_discovery_republish(db)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"POST /api/energy/meters: declared %r meter id=%d label=%r started_at=%s",
|
"POST /api/energy/meters: declared %r meter id=%d label=%r started_at=%s",
|
||||||
body.commodity,
|
body.commodity,
|
||||||
@@ -293,6 +317,11 @@ def patch_energy_meter(
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(meter)
|
db.refresh(meter)
|
||||||
|
|
||||||
|
# Trigger HA discovery re-publish so label renames on the active meter
|
||||||
|
# propagate to the HA device name. Best-effort: failures are logged and
|
||||||
|
# swallowed; the API response is not affected.
|
||||||
|
_trigger_discovery_republish(db)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"PATCH /api/energy/meters/%d: updated meter label=%r started_at=%s",
|
"PATCH /api/energy/meters/%d: updated meter label=%r started_at=%s",
|
||||||
meter_id,
|
meter_id,
|
||||||
|
|||||||
@@ -488,6 +488,7 @@ def test_read(
|
|||||||
device.port,
|
device.port,
|
||||||
device.unit_id,
|
device.unit_id,
|
||||||
[{"start": b.start, "count": b.count} for b in profile.blocks],
|
[{"start": b.start, "count": b.count} for b in profile.blocks],
|
||||||
|
function_code=profile.function_code,
|
||||||
)
|
)
|
||||||
payload: dict[str, Any] = decode_profile(profile, registers)
|
payload: dict[str, Any] = decode_profile(profile, registers)
|
||||||
return ModbusTestReadResponse(ok=True, payload=payload)
|
return ModbusTestReadResponse(ok=True, payload=payload)
|
||||||
|
|||||||
+73
-15
@@ -27,6 +27,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import timedelta
|
||||||
from typing import Any, Callable, Optional, Protocol
|
from typing import Any, Callable, Optional, Protocol
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -369,16 +370,44 @@ register_provider(_modbus_provider)
|
|||||||
# Energy Cost provider
|
# Energy Cost provider
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# *_today 当日窗口翻天的宽限:本地午夜后 5 秒才切到新的一天,避免在 00:00:0x 把
|
||||||
|
# 归零后的值发出去、被慢几秒的 HA 钟记成前一天的 23:59:59(归错小时桶)。
|
||||||
|
_TODAY_RESET_GRACE = timedelta(seconds=5)
|
||||||
|
|
||||||
|
|
||||||
def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
||||||
"""Enumerate ExposableEntity objects for the energy cost subsystem.
|
"""Enumerate ExposableEntity objects for the energy cost subsystem.
|
||||||
|
|
||||||
Produces 4 sensor entities grouped under a single HA device "Energy Cost":
|
Produces 6 sensor entities grouped under a single HA device whose identity
|
||||||
|
is anchored to the **current active electricity meter**:
|
||||||
|
|
||||||
- ``buy_price_now`` — current effective buy price (EUR/kWh or local currency).
|
- ``buy_price_now`` — current effective buy price (EUR/kWh or local currency).
|
||||||
- ``sell_price_now`` — current effective sell price (EUR/kWh or local currency).
|
- ``sell_price_now`` — current effective sell price (EUR/kWh or local currency).
|
||||||
- ``import_cost_total`` — cumulative import cost (total_increasing, monetary).
|
- ``import_cost_total`` — cumulative import cost (total, monetary).
|
||||||
- ``export_revenue_total`` — cumulative export revenue (total_increasing, monetary).
|
- ``export_revenue_total`` — cumulative export revenue (total, monetary).
|
||||||
|
- ``import_cost_today`` — today's import cost (total_increasing, monetary).
|
||||||
|
- ``export_revenue_today`` — today's export revenue (total_increasing, monetary).
|
||||||
|
|
||||||
|
Active meter requirement
|
||||||
|
------------------------
|
||||||
|
**If no active electricity meter exists, the provider returns ``[]``.**
|
||||||
|
No energy-cost entities are exposed to HA until a meter has been declared.
|
||||||
|
This prevents spurious sensor creation with an undefined device identity.
|
||||||
|
|
||||||
|
HA device identity (换表 → 新 sensor)
|
||||||
|
--------------------------------------
|
||||||
|
``identifiers[1]`` is set to the active meter's **uuid** (not the fixed
|
||||||
|
string ``"energy-cost"``). ``ha_discovery.py`` uses ``identifiers[1]`` as
|
||||||
|
the MQTT node_id and as part of the ``unique_id`` for every entity.
|
||||||
|
Declaring a new active electricity meter produces a new uuid → new node_id /
|
||||||
|
unique_id → HA creates a brand-new sensor, cleanly isolating post-swap data.
|
||||||
|
|
||||||
|
Entity key stability
|
||||||
|
--------------------
|
||||||
|
Entity keys remain the fixed stable strings (``"energy.buy_price_now"`` etc.),
|
||||||
|
**not** derived from the meter uuid. The ``exposed_entity_toggle`` table uses
|
||||||
|
keys as its primary handle; keeping them stable means toggled-on entities stay
|
||||||
|
enabled after a meter swap without requiring the user to re-tick them.
|
||||||
|
|
||||||
Current-price algorithm (source-agnostic, with fallback)
|
Current-price algorithm (source-agnostic, with fallback)
|
||||||
---------------------------------------------------------
|
---------------------------------------------------------
|
||||||
@@ -399,8 +428,9 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
|||||||
Cumulative totals
|
Cumulative totals
|
||||||
-----------------
|
-----------------
|
||||||
``SUM(import_cost)`` and ``SUM(export_revenue)`` over **all non-degraded**
|
``SUM(import_cost)`` and ``SUM(export_revenue)`` over **all non-degraded**
|
||||||
``energy_cost_period`` rows. Degraded rows carry 0 costs and are excluded
|
``energy_cost_period`` rows within the current meter's window. Degraded rows
|
||||||
to avoid double-counting when they are later overwritten by real values.
|
carry 0 costs and are excluded to avoid double-counting when they are later
|
||||||
|
overwritten by real values.
|
||||||
|
|
||||||
Currency
|
Currency
|
||||||
--------
|
--------
|
||||||
@@ -414,16 +444,37 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
|||||||
- ``"energy.sell_price_now"``
|
- ``"energy.sell_price_now"``
|
||||||
- ``"energy.import_cost_total"``
|
- ``"energy.import_cost_total"``
|
||||||
- ``"energy.export_revenue_total"``
|
- ``"energy.export_revenue_total"``
|
||||||
|
- ``"energy.import_cost_today"``
|
||||||
|
- ``"energy.export_revenue_today"``
|
||||||
|
|
||||||
DeviceInfo identifiers
|
DeviceInfo identifiers
|
||||||
----------------------
|
----------------------
|
||||||
**Two-element tuple** ``("energy-cost", "energy-cost")`` so that
|
**Two-element tuple** ``("energy-cost", meter.uuid)`` so that
|
||||||
``ha_discovery.py``'s ``entity.device.identifiers[1]`` is always valid
|
``ha_discovery.py``'s ``entity.device.identifiers[1]`` resolves to the
|
||||||
(the service uses index [1] as the node_id throughout).
|
meter uuid (used as the MQTT node_id and unique_id seed throughout).
|
||||||
"""
|
"""
|
||||||
from app.models.energy import EnergyCostPeriod # local import to avoid circular
|
from app.models.energy import EnergyCostPeriod, Meter # local import to avoid circular
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
# --- Determine currency and representative pricing from the latest non-degraded row ---
|
# --- Require an active electricity meter; return [] if none exists ---
|
||||||
|
# Using an inline query (ended_at IS NULL) rather than a service-layer helper
|
||||||
|
# to avoid a new public dependency and remain consistent with the value_getter
|
||||||
|
# implementations below (which use the same inline pattern).
|
||||||
|
active_meter: Meter | None = session.execute(
|
||||||
|
select(Meter)
|
||||||
|
.where(
|
||||||
|
Meter.commodity == "electricity",
|
||||||
|
Meter.ended_at.is_(None),
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if active_meter is None:
|
||||||
|
# No active electricity meter → do not expose any energy-cost entities.
|
||||||
|
# HA will not see these sensors until a meter is declared.
|
||||||
|
return []
|
||||||
|
|
||||||
|
# --- Determine currency from the latest non-degraded row ---
|
||||||
|
|
||||||
latest_period: EnergyCostPeriod | None = (
|
latest_period: EnergyCostPeriod | None = (
|
||||||
session.query(EnergyCostPeriod)
|
session.query(EnergyCostPeriod)
|
||||||
@@ -436,13 +487,15 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
|||||||
if latest_period is not None and latest_period.currency:
|
if latest_period is not None and latest_period.currency:
|
||||||
currency = latest_period.currency
|
currency = latest_period.currency
|
||||||
|
|
||||||
# --- Shared DeviceInfo (2-element identifiers — required by ha_discovery.py [1] access) ---
|
# --- Shared DeviceInfo anchored to the active meter's uuid ---
|
||||||
|
# identifiers[1] = meter.uuid drives the MQTT node_id and unique_id in
|
||||||
|
# ha_discovery.py. Swapping the meter produces a new uuid → new HA sensor.
|
||||||
# provides_availability=False: the energy-cost device has only sensors and no
|
# provides_availability=False: the energy-cost device has only sensors and no
|
||||||
# online/offline heartbeat, so its entities must be "always available" in HA.
|
# online/offline heartbeat, so its entities must be "always available" in HA.
|
||||||
# (Otherwise HA shows them unavailable despite state being published.)
|
# (Otherwise HA shows them unavailable despite state being published.)
|
||||||
device_info = DeviceInfo(
|
device_info = DeviceInfo(
|
||||||
identifiers=("energy-cost", "energy-cost"),
|
identifiers=("energy-cost", active_meter.uuid),
|
||||||
name="Energy Cost",
|
name=active_meter.label,
|
||||||
provides_availability=False,
|
provides_availability=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -687,7 +740,11 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# Today's window in UTC, using monkeypatch-safe module attribute calls.
|
# Today's window in UTC, using monkeypatch-safe module attribute calls.
|
||||||
today_local = _tz_mod.local_now().date()
|
# Grace: subtract _TODAY_RESET_GRACE so that in the first 5 seconds after
|
||||||
|
# local midnight the getter still returns yesterday's window. This prevents
|
||||||
|
# a "归零后的值" from being published while HA's clock (which may lag a few
|
||||||
|
# seconds) would stamp it as 23:59:59 of the previous day.
|
||||||
|
today_local = (_tz_mod.local_now() - _TODAY_RESET_GRACE).date()
|
||||||
tomorrow_local = today_local + _td(days=1)
|
tomorrow_local = today_local + _td(days=1)
|
||||||
today_start_utc = _tz_mod.local_midnight_utc(today_local)
|
today_start_utc = _tz_mod.local_midnight_utc(today_local)
|
||||||
tomorrow_start_utc = _tz_mod.local_midnight_utc(tomorrow_local)
|
tomorrow_start_utc = _tz_mod.local_midnight_utc(tomorrow_local)
|
||||||
@@ -724,7 +781,8 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
|||||||
if not versions:
|
if not versions:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
today_local = _tz_mod.local_now().date()
|
# Grace: same logic as import_cost_today — see that getter's comment.
|
||||||
|
today_local = (_tz_mod.local_now() - _TODAY_RESET_GRACE).date()
|
||||||
tomorrow_local = today_local + _td(days=1)
|
tomorrow_local = today_local + _td(days=1)
|
||||||
today_start_utc = _tz_mod.local_midnight_utc(today_local)
|
today_start_utc = _tz_mod.local_midnight_utc(today_local)
|
||||||
tomorrow_start_utc = _tz_mod.local_midnight_utc(tomorrow_local)
|
tomorrow_start_utc = _tz_mod.local_midnight_utc(tomorrow_local)
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
"""Modbus TCP driver — thin wrapper around pymodbus.
|
"""Modbus TCP driver — thin wrapper around pymodbus.
|
||||||
|
|
||||||
This module provides a single public function ``read_blocks`` that performs
|
This module provides a single public function ``read_blocks`` that performs
|
||||||
one or more FC04 (Read Input Registers) block reads against a Modbus TCP
|
one or more block reads against a Modbus TCP gateway — using either FC04
|
||||||
gateway and returns a flat ``dict[register_address -> 16-bit_value]`` map.
|
(Read Input Registers) or FC03 (Read Holding Registers), selected per call
|
||||||
|
via the ``function_code`` argument — and returns a flat
|
||||||
|
``dict[register_address -> 16-bit_value]`` map. The function code comes from
|
||||||
|
the device profile (e.g. SDM120 uses FC04, DDSU666 uses FC03).
|
||||||
|
|
||||||
Design decisions
|
Design decisions
|
||||||
----------------
|
----------------
|
||||||
@@ -80,9 +83,10 @@ def read_blocks(
|
|||||||
unit_id: int,
|
unit_id: int,
|
||||||
blocks: Sequence[Block],
|
blocks: Sequence[Block],
|
||||||
*,
|
*,
|
||||||
|
function_code: int = 4,
|
||||||
timeout: float = 3.0,
|
timeout: float = 3.0,
|
||||||
) -> dict[int, int]:
|
) -> dict[int, int]:
|
||||||
"""Read one or more contiguous register blocks via FC04 (input registers).
|
"""Read one or more contiguous register blocks via FC03 or FC04.
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
@@ -96,6 +100,11 @@ def read_blocks(
|
|||||||
Sequence of ``{"start": int, "count": int}`` dicts describing the
|
Sequence of ``{"start": int, "count": int}`` dicts describing the
|
||||||
contiguous register ranges to read. ``count`` is the number of
|
contiguous register ranges to read. ``count`` is the number of
|
||||||
16-bit registers (not bytes).
|
16-bit registers (not bytes).
|
||||||
|
function_code:
|
||||||
|
Modbus read function code: ``4`` for FC04 (Read Input Registers,
|
||||||
|
default — SDM120) or ``3`` for FC03 (Read Holding Registers —
|
||||||
|
DDSU666 and other devices that expose measurements as holding
|
||||||
|
registers). Comes from the device profile's ``function_code`` field.
|
||||||
timeout:
|
timeout:
|
||||||
TCP connect/read timeout in seconds (default 3 s).
|
TCP connect/read timeout in seconds (default 3 s).
|
||||||
|
|
||||||
@@ -107,12 +116,21 @@ def read_blocks(
|
|||||||
|
|
||||||
Raises
|
Raises
|
||||||
------
|
------
|
||||||
|
ModbusDriverError
|
||||||
|
If ``function_code`` is neither 3 nor 4 (validated before any
|
||||||
|
connection is attempted).
|
||||||
ModbusConnectionError
|
ModbusConnectionError
|
||||||
If the TCP connection to the gateway fails.
|
If the TCP connection to the gateway fails.
|
||||||
ModbusResponseError
|
ModbusResponseError
|
||||||
If the gateway returns a Modbus exception frame or an unexpected
|
If the gateway returns a Modbus exception frame or an unexpected
|
||||||
number of registers.
|
number of registers.
|
||||||
"""
|
"""
|
||||||
|
if function_code not in (3, 4):
|
||||||
|
raise ModbusDriverError(
|
||||||
|
f"Unsupported read function code FC{function_code:02d} "
|
||||||
|
f"(only FC03 holding-register and FC04 input-register reads are supported)"
|
||||||
|
)
|
||||||
|
|
||||||
client = ModbusTcpClient(host, port=port, timeout=timeout)
|
client = ModbusTcpClient(host, port=port, timeout=timeout)
|
||||||
try:
|
try:
|
||||||
connected = client.connect()
|
connected = client.connect()
|
||||||
@@ -125,7 +143,7 @@ def read_blocks(
|
|||||||
for block in blocks:
|
for block in blocks:
|
||||||
start: int = block["start"]
|
start: int = block["start"]
|
||||||
count: int = block["count"]
|
count: int = block["count"]
|
||||||
_read_block(client, unit_id, start, count, registers)
|
_read_block(client, unit_id, start, count, registers, function_code=function_code)
|
||||||
|
|
||||||
return registers
|
return registers
|
||||||
|
|
||||||
@@ -145,9 +163,18 @@ def _read_block(
|
|||||||
start: int,
|
start: int,
|
||||||
count: int,
|
count: int,
|
||||||
result: dict[int, int],
|
result: dict[int, int],
|
||||||
|
*,
|
||||||
|
function_code: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Read one block and merge into *result*. Raises on any error."""
|
"""Read one block and merge into *result*. Raises on any error.
|
||||||
|
|
||||||
|
``function_code`` is assumed already validated to be 3 or 4 by the caller
|
||||||
|
(``read_blocks``); 3 dispatches FC03 (holding) and 4 dispatches FC04 (input).
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
|
if function_code == 3:
|
||||||
|
response = client.read_holding_registers(start, count=count, device_id=unit_id)
|
||||||
|
else: # function_code == 4 (input registers)
|
||||||
response = client.read_input_registers(start, count=count, device_id=unit_id)
|
response = client.read_input_registers(start, count=count, device_id=unit_id)
|
||||||
except ConnectionException as exc:
|
except ConnectionException as exc:
|
||||||
raise ModbusConnectionError(
|
raise ModbusConnectionError(
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
name: ddsu666
|
||||||
|
description: CHINT DDSU666 single-phase smart meter
|
||||||
|
function_code: 3 # holding registers (FC03) — DDSU666 has NO input registers (no FC04)
|
||||||
|
word_order: big # high register first — ASSUMED; verify with a known voltage reading
|
||||||
|
byte_order: big # high byte first within each register (confirmed by manual CRC example)
|
||||||
|
|
||||||
|
# NOTE: the manual gives no worked float-decode example, so word_order is a best-guess
|
||||||
|
# (standard big-endian, high register first, matching sdm120). After wiring the meter,
|
||||||
|
# read 0x2000 (voltage) — it should decode to ~230 V. If it decodes to garbage, the
|
||||||
|
# device uses the opposite word order and this profile (and the decoder) need adjusting.
|
||||||
|
# Byte order is confirmed big-endian from the manual (Appendix A, Table A.4: 0x1388 -> 13 88).
|
||||||
|
|
||||||
|
blocks:
|
||||||
|
# Instantaneous quantities 0x2000–0x200F: voltage, current, P, Q, (rsv), PF, (rsv), Freq.
|
||||||
|
# 16 contiguous registers — single bulk read. (DDSU666 manual Table 9.)
|
||||||
|
- { start: 0x2000, count: 0x0010 }
|
||||||
|
# Active energy — import (0x4000) and export (0x400A) read as two small blocks rather
|
||||||
|
# than one span, to avoid touching the undocumented/reserved 0x4002–0x4009 gap.
|
||||||
|
- { start: 0x4000, count: 0x0002 }
|
||||||
|
- { start: 0x400A, count: 0x0002 }
|
||||||
|
|
||||||
|
metrics:
|
||||||
|
# Addresses are the raw Modbus protocol addresses (hex) from DDSU666 manual Table 9,
|
||||||
|
# read via FC03. Each float32 occupies two consecutive 16-bit registers.
|
||||||
|
|
||||||
|
- key: voltage
|
||||||
|
address: 0x2000 # U — Voltage (V)
|
||||||
|
type: float32
|
||||||
|
unit: "V"
|
||||||
|
device_class: voltage
|
||||||
|
ha_component: sensor
|
||||||
|
|
||||||
|
- key: current
|
||||||
|
address: 0x2002 # I — Current (A)
|
||||||
|
type: float32
|
||||||
|
unit: "A"
|
||||||
|
device_class: current
|
||||||
|
ha_component: sensor
|
||||||
|
|
||||||
|
- key: active_power
|
||||||
|
address: 0x2004 # P — Active power. Manual unit is kW (NOT W like sdm120).
|
||||||
|
type: float32
|
||||||
|
unit: "kW"
|
||||||
|
device_class: power
|
||||||
|
ha_component: sensor
|
||||||
|
|
||||||
|
- key: reactive_power
|
||||||
|
address: 0x2006 # Q — Reactive power (kvar)
|
||||||
|
type: float32
|
||||||
|
unit: "kvar"
|
||||||
|
device_class: reactive_power
|
||||||
|
ha_component: sensor
|
||||||
|
|
||||||
|
- key: power_factor
|
||||||
|
address: 0x200A # PF — Power factor (dimensionless)
|
||||||
|
type: float32
|
||||||
|
unit: ""
|
||||||
|
device_class: power_factor
|
||||||
|
ha_component: sensor
|
||||||
|
|
||||||
|
- key: frequency
|
||||||
|
address: 0x200E # Freq — Frequency (Hz)
|
||||||
|
type: float32
|
||||||
|
unit: "Hz"
|
||||||
|
device_class: frequency
|
||||||
|
ha_component: sensor
|
||||||
|
|
||||||
|
- key: import_energy
|
||||||
|
address: 0x4000 # Ep — positive/forward active energy (kWh)
|
||||||
|
type: float32
|
||||||
|
unit: "kWh"
|
||||||
|
device_class: energy
|
||||||
|
state_class: total_increasing
|
||||||
|
ha_component: sensor
|
||||||
|
|
||||||
|
- key: export_energy
|
||||||
|
address: 0x400A # -Ep — reverse active energy (kWh)
|
||||||
|
type: float32
|
||||||
|
unit: "kWh"
|
||||||
|
device_class: energy
|
||||||
|
state_class: total_increasing
|
||||||
|
ha_component: sensor
|
||||||
@@ -123,6 +123,7 @@ class ManualProfile(BaseModel):
|
|||||||
class TibberEnergySpec(BaseModel):
|
class TibberEnergySpec(BaseModel):
|
||||||
source: str # must be "tibber_api"
|
source: str # must be "tibber_api"
|
||||||
energy_tax: FieldSpec # subtracted from total to derive sell price
|
energy_tax: FieldSpec # subtracted from total to derive sell price
|
||||||
|
sell_fee: FieldSpec # verkoopvergoeding (feed-in fee); always subtracted from sell; default 0.0248
|
||||||
sell_adjust: FieldSpec # additional sell-price adjustment; default 0
|
sell_adjust: FieldSpec # additional sell-price adjustment; default 0
|
||||||
|
|
||||||
|
|
||||||
@@ -268,10 +269,13 @@ def _fill_defaults_manual(values: dict[str, Any], profile: ManualProfile) -> dic
|
|||||||
|
|
||||||
|
|
||||||
def _fill_defaults_tibber(values: dict[str, Any], profile: TibberProfile) -> dict[str, Any]:
|
def _fill_defaults_tibber(values: dict[str, Any], profile: TibberProfile) -> dict[str, Any]:
|
||||||
"""Return a copy of *values* with sell_adjust and management_fee defaults applied."""
|
"""Return a copy of *values* with sell_fee, sell_adjust and management_fee defaults applied."""
|
||||||
filled = dict(values)
|
filled = dict(values)
|
||||||
|
|
||||||
energy = dict(filled.get("energy", {}))
|
energy = dict(filled.get("energy", {}))
|
||||||
|
# Apply default for sell_fee (default=0.0248) if absent.
|
||||||
|
if "sell_fee" not in energy and profile.energy.sell_fee.default is not None:
|
||||||
|
energy["sell_fee"] = profile.energy.sell_fee.default
|
||||||
# Apply default for sell_adjust (default=0) if absent.
|
# Apply default for sell_adjust (default=0) if absent.
|
||||||
if "sell_adjust" not in energy and profile.energy.sell_adjust.default is not None:
|
if "sell_adjust" not in energy and profile.energy.sell_adjust.default is not None:
|
||||||
energy["sell_adjust"] = profile.energy.sell_adjust.default
|
energy["sell_adjust"] = profile.energy.sell_adjust.default
|
||||||
@@ -347,6 +351,7 @@ def _validate_tibber_values(values: dict[str, Any], profile: TibberProfile) -> d
|
|||||||
|
|
||||||
# Required energy fields.
|
# Required energy fields.
|
||||||
_require_numeric("energy", "energy_tax", energy)
|
_require_numeric("energy", "energy_tax", energy)
|
||||||
|
_require_numeric("energy", "sell_fee", energy)
|
||||||
_require_numeric("energy", "sell_adjust", energy)
|
_require_numeric("energy", "sell_adjust", energy)
|
||||||
# Required standing fields.
|
# Required standing fields.
|
||||||
_require_numeric("standing", "management_fee", standing)
|
_require_numeric("standing", "management_fee", standing)
|
||||||
@@ -361,9 +366,10 @@ def validate_values(kind: str, values: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"""Validate a contract-values dict against the named profile structure.
|
"""Validate a contract-values dict against the named profile structure.
|
||||||
|
|
||||||
Fields that carry a ``default`` in the profile (e.g. ``ode``,
|
Fields that carry a ``default`` in the profile (e.g. ``ode``,
|
||||||
``sell_adjust``, tibber ``management_fee``) are silently filled in when
|
``sell_fee``, ``sell_adjust``, tibber ``management_fee``) are silently
|
||||||
absent from *values*. Fields with no default that are absent, or fields
|
filled in when absent from *values*. Fields with no default that are
|
||||||
whose value is not a number, cause a ``ProfileValidationError``.
|
absent, or fields whose value is not a number, cause a
|
||||||
|
``ProfileValidationError``.
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ kind: tibber
|
|||||||
label: Tibber 动态电价(15 分钟)
|
label: Tibber 动态电价(15 分钟)
|
||||||
|
|
||||||
energy:
|
energy:
|
||||||
source: tibber_api # buy = total (from API); sell = total − energy_tax − sell_adjust
|
source: tibber_api # buy = total (from API); sell = total − energy_tax − sell_fee − sell_adjust
|
||||||
energy_tax: { unit: EUR/kWh } # subtracted from total to derive sell price (incl. VAT)
|
energy_tax: { unit: EUR/kWh } # subtracted from total to derive sell price (incl. VAT)
|
||||||
sell_adjust: { unit: EUR/kWh, default: 0 } # additional sell-price adjustment (residual spread)
|
sell_fee: { unit: EUR/kWh, default: 0.0248 } # verkoopvergoeding (feed-in fee, incl. VAT); always subtracted from sell
|
||||||
|
sell_adjust: { unit: EUR/kWh, default: 0 } # manual sell-price adjustment; net-metering: set = −energy_tax to refund the tax
|
||||||
|
|
||||||
standing: # fixed charges; UI fills per month, engine prorates to days
|
standing: # fixed charges; UI fills per month, engine prorates to days
|
||||||
management_fee: { unit: EUR/month, default: 5.99 }
|
management_fee: { unit: EUR/month, default: 5.99 }
|
||||||
|
|||||||
@@ -209,12 +209,23 @@ def _tibber_strategy(
|
|||||||
query on ``starts_at``.
|
query on ``starts_at``.
|
||||||
|
|
||||||
Formula (§3.4):
|
Formula (§3.4):
|
||||||
- ``buy = total`` (Tibber's all-inclusive price, already includes tax)
|
- ``buy = total`` (Tibber's all-inclusive price; already includes energy
|
||||||
- ``sell = total − energy_tax − sell_adjust``
|
tax, VAT and the buy-side ``inkoopvergoeding``)
|
||||||
|
- ``sell = total − energy_tax − sell_fee − sell_adjust``
|
||||||
- ``import_cost = (Δd1 + Δd2) × buy``
|
- ``import_cost = (Δd1 + Δd2) × buy``
|
||||||
- ``export_revenue = (Δr1 + Δr2) × sell``
|
- ``export_revenue = (Δr1 + Δr2) × sell``
|
||||||
- ``net_cost = import_cost − export_revenue``
|
- ``net_cost = import_cost − export_revenue``
|
||||||
|
|
||||||
|
``sell_fee`` models Tibber's per-kWh **verkoopvergoeding** (feed-in fee,
|
||||||
|
€0.0248/kWh incl. VAT since 2026-01-01). It is always deducted from the
|
||||||
|
feed-in payout: even under the net-metering (saldering) scheme, Tibber pays
|
||||||
|
``total − verkoopvergoeding`` per returned kWh (Tibber NL: "€0,28 − €0,0248
|
||||||
|
= €0,2552"). ``total`` already contains the equal buy-side
|
||||||
|
``inkoopvergoeding``, so the two fees do **not** cancel — the feed-in price
|
||||||
|
sits ``sell_fee`` below the buy price. ``sell_adjust`` is a separate manual
|
||||||
|
correction: under net metering it carries back the refunded energy tax
|
||||||
|
(``sell_adjust = −energy_tax``), leaving ``sell = total − sell_fee``.
|
||||||
|
|
||||||
Tibber does not differentiate tariff slots (dal vs normal) — the 15-minute
|
Tibber does not differentiate tariff slots (dal vs normal) — the 15-minute
|
||||||
API price applies to the full delivered/returned volume.
|
API price applies to the full delivered/returned volume.
|
||||||
|
|
||||||
@@ -248,11 +259,12 @@ def _tibber_strategy(
|
|||||||
|
|
||||||
energy = values.get("energy", {})
|
energy = values.get("energy", {})
|
||||||
energy_tax = _to_decimal(energy.get("energy_tax", 0))
|
energy_tax = _to_decimal(energy.get("energy_tax", 0))
|
||||||
|
sell_fee = _to_decimal(energy.get("sell_fee", 0))
|
||||||
sell_adjust = _to_decimal(energy.get("sell_adjust", 0))
|
sell_adjust = _to_decimal(energy.get("sell_adjust", 0))
|
||||||
|
|
||||||
total = _to_decimal(price_row.total)
|
total = _to_decimal(price_row.total)
|
||||||
buy = total
|
buy = total
|
||||||
sell = total - energy_tax - sell_adjust
|
sell = total - energy_tax - sell_fee - sell_adjust
|
||||||
|
|
||||||
total_delivered = deltas.d1 + deltas.d2
|
total_delivered = deltas.d1 + deltas.d2
|
||||||
total_returned = deltas.r1 + deltas.r2
|
total_returned = deltas.r1 + deltas.r2
|
||||||
@@ -269,6 +281,7 @@ def _tibber_strategy(
|
|||||||
"buy": str(buy),
|
"buy": str(buy),
|
||||||
"sell": str(sell),
|
"sell": str(sell),
|
||||||
"energy_tax": str(energy_tax),
|
"energy_tax": str(energy_tax),
|
||||||
|
"sell_fee": str(sell_fee),
|
||||||
"sell_adjust": str(sell_adjust),
|
"sell_adjust": str(sell_adjust),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,20 +30,39 @@ logger = logging.getLogger(__name__)
|
|||||||
_TIBBER_API_URL = "https://api.tibber.com/v1-beta/gql"
|
_TIBBER_API_URL = "https://api.tibber.com/v1-beta/gql"
|
||||||
_DEFAULT_TIMEOUT = 15.0
|
_DEFAULT_TIMEOUT = 15.0
|
||||||
|
|
||||||
# GraphQL query to fetch a range of 15-minute price nodes.
|
# GraphQL query to fetch the forward-looking today + tomorrow price curve at
|
||||||
# ``priceInfoRange(resolution: QUARTER_HOURLY, first: 96)`` fetches up to
|
# 15-minute resolution.
|
||||||
# 96 quarter-hourly slots which covers today + tomorrow (2 × 24 × 4 = 192 max,
|
#
|
||||||
# but the Tibber API typically starts from the current slot and returns at
|
# ``priceInfo(resolution: QUARTER_HOURLY)`` returns two node lists:
|
||||||
# most the remaining hours of today plus tomorrow, so 96 is a good cap for
|
# * ``today`` — always the full current local day (96 quarter-hourly slots,
|
||||||
# "today + tomorrow").
|
# 00:00 → 23:45 local), regardless of the current time.
|
||||||
|
# * ``tomorrow`` — the full next local day (96 slots) once Tibber publishes the
|
||||||
|
# day-ahead prices (around 13:00–15:00 local); empty before that.
|
||||||
|
#
|
||||||
|
# This is deliberately NOT ``priceInfoRange``: that field is a historical cursor
|
||||||
|
# connection whose range ends at "now" (it never returns future slots), so it
|
||||||
|
# cannot supply upcoming prices. ``priceInfo`` is forward-looking, so every
|
||||||
|
# 15-minute slot's price is present in the DB *before* the slot closes — which is
|
||||||
|
# what makes per-slot billing accurate (each period finds its own exact slot
|
||||||
|
# instead of falling back to a stale earlier price) and keeps the live current-
|
||||||
|
# price entity fresh. The hourly refresh job re-runs this query, so tomorrow's
|
||||||
|
# prices are picked up within an hour of publication without a restart.
|
||||||
_PRICE_RANGE_QUERY = """
|
_PRICE_RANGE_QUERY = """
|
||||||
{
|
{
|
||||||
viewer {
|
viewer {
|
||||||
homes {
|
homes {
|
||||||
id
|
id
|
||||||
currentSubscription {
|
currentSubscription {
|
||||||
priceInfoRange(resolution: QUARTER_HOURLY, first: 96) {
|
priceInfo(resolution: QUARTER_HOURLY) {
|
||||||
nodes {
|
today {
|
||||||
|
startsAt
|
||||||
|
total
|
||||||
|
energy
|
||||||
|
tax
|
||||||
|
currency
|
||||||
|
level
|
||||||
|
}
|
||||||
|
tomorrow {
|
||||||
startsAt
|
startsAt
|
||||||
total
|
total
|
||||||
energy
|
energy
|
||||||
@@ -230,11 +249,15 @@ def fetch_price_range(
|
|||||||
*,
|
*,
|
||||||
timeout: float = _DEFAULT_TIMEOUT,
|
timeout: float = _DEFAULT_TIMEOUT,
|
||||||
) -> list[PricePoint]:
|
) -> list[PricePoint]:
|
||||||
"""Fetch a range of 15-minute price nodes from the Tibber API.
|
"""Fetch the forward-looking today + tomorrow 15-minute price curve from Tibber.
|
||||||
|
|
||||||
Sends the ``priceInfoRange(resolution: QUARTER_HOURLY, first: 96)`` query
|
Sends the ``priceInfo(resolution: QUARTER_HOURLY) { today tomorrow }`` query
|
||||||
and parses every returned node into a ``PricePoint``. The number of nodes
|
and parses every node from both lists (today first, then tomorrow) into a
|
||||||
is not assumed — all returned nodes are parsed regardless of count.
|
``PricePoint``. ``priceInfo`` is forward-looking — ``today`` is always the
|
||||||
|
full current local day and ``tomorrow`` is populated once Tibber publishes the
|
||||||
|
day-ahead prices — so upcoming slots are returned, unlike ``priceInfoRange``
|
||||||
|
which only reaches "now". ``tomorrow`` may be empty (before publication); the
|
||||||
|
number of nodes is not assumed and all returned nodes are parsed.
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
@@ -268,10 +291,15 @@ def fetch_price_range(
|
|||||||
home = _pick_home(homes, home_id)
|
home = _pick_home(homes, home_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
nodes = home["currentSubscription"]["priceInfoRange"]["nodes"]
|
price_info = home["currentSubscription"]["priceInfo"]
|
||||||
|
today = price_info["today"]
|
||||||
|
tomorrow = price_info["tomorrow"]
|
||||||
except (KeyError, TypeError) as exc:
|
except (KeyError, TypeError) as exc:
|
||||||
raise TibberError("Tibber API response missing priceInfoRange nodes") from exc
|
raise TibberError("Tibber API response missing priceInfo today/tomorrow") from exc
|
||||||
|
|
||||||
|
# tomorrow is null/empty until Tibber publishes the day-ahead prices; treat
|
||||||
|
# a missing list as empty so we still return today's slots.
|
||||||
|
nodes = list(today or []) + list(tomorrow or [])
|
||||||
return [_parse_node(node, "QUARTER_HOURLY") for node in nodes]
|
return [_parse_node(node, "QUARTER_HOURLY") for node in nodes]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+27
@@ -7,6 +7,7 @@ from fastapi import FastAPI, HTTPException, Request
|
|||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
from apscheduler.triggers.interval import IntervalTrigger
|
from apscheduler.triggers.interval import IntervalTrigger
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ from app.services.modbus_poll import poll_all_enabled_devices, BASE_POLL_TICK_SE
|
|||||||
from app.services.ha_discovery import publish_discovery, publish_states
|
from app.services.ha_discovery import publish_discovery, publish_states
|
||||||
from app.services.tibber_prices import refresh_prices
|
from app.services.tibber_prices import refresh_prices
|
||||||
from app.services.energy_cost import compute_closed_periods
|
from app.services.energy_cost import compute_closed_periods
|
||||||
|
from app.services.timezone import local_tz
|
||||||
from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db
|
from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -159,6 +161,20 @@ def _run_scheduled_ha_state_publish() -> None:
|
|||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _run_midnight_state_publish() -> None:
|
||||||
|
"""本地午夜后不久专门发布一次状态,让 *_today 的每日归零稳稳落在午夜之后
|
||||||
|
(对 HA 钟慢几秒鲁棒)。best-effort:失败仅记日志,不影响调度器。"""
|
||||||
|
session_local = get_session_local()
|
||||||
|
session = session_local()
|
||||||
|
try:
|
||||||
|
from app.services.ha_discovery import publish_states
|
||||||
|
publish_states(session)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("_run_midnight_state_publish: failed (non-fatal)")
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
def ensure_auth_db_ready() -> None:
|
def ensure_auth_db_ready() -> None:
|
||||||
session_local = get_session_local()
|
session_local = get_session_local()
|
||||||
session: Session = session_local()
|
session: Session = session_local()
|
||||||
@@ -233,6 +249,17 @@ async def lifespan(_: FastAPI):
|
|||||||
max_instances=1,
|
max_instances=1,
|
||||||
coalesce=True,
|
coalesce=True,
|
||||||
)
|
)
|
||||||
|
# Dedicated midnight publish: fire at local 00:00:10 so *_today grace (5 s) has
|
||||||
|
# already elapsed and the day-rolled value is pushed to HA immediately, rather
|
||||||
|
# than waiting for the next 60-second ha-state-publish sweep.
|
||||||
|
scheduler.add_job(
|
||||||
|
_run_midnight_state_publish,
|
||||||
|
trigger=CronTrigger(hour=0, minute=0, second=10, timezone=local_tz()),
|
||||||
|
id="midnight-today-publish",
|
||||||
|
replace_existing=True,
|
||||||
|
max_instances=1,
|
||||||
|
coalesce=True,
|
||||||
|
)
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
|
|
||||||
# MQTT: connect using DB-merged runtime settings so broker configured via UI
|
# MQTT: connect using DB-merged runtime settings so broker configured via UI
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ Six tables:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid as _uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String
|
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String
|
||||||
@@ -20,6 +21,10 @@ from sqlalchemy.types import JSON
|
|||||||
from app.db import Base
|
from app.db import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _uuid4_str() -> str:
|
||||||
|
return str(_uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
class Meter(Base):
|
class Meter(Base):
|
||||||
"""One physical electricity meter's installation epoch.
|
"""One physical electricity meter's installation epoch.
|
||||||
|
|
||||||
@@ -47,6 +52,11 @@ class Meter(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
|
||||||
|
# Stable internal identity — used as HA Discovery unique_id anchor.
|
||||||
|
uuid: Mapped[str] = mapped_column(
|
||||||
|
String(36), unique=True, nullable=False, default=_uuid4_str
|
||||||
|
)
|
||||||
|
|
||||||
# Human-readable label for this physical meter (e.g. address, serial, tariff zone).
|
# Human-readable label for this physical meter (e.g. address, serial, tariff zone).
|
||||||
label: Mapped[str] = mapped_column(String(255), nullable=False)
|
label: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
|
||||||
|
|||||||
+73
-26
@@ -123,6 +123,10 @@ _READING_MAX_STALENESS = timedelta(minutes=_PERIOD_MINUTES)
|
|||||||
# degraded to prevent negative costs or grossly inflated charges.
|
# degraded to prevent negative costs or grossly inflated charges.
|
||||||
_MAX_DELTA_KWH = Decimal("100")
|
_MAX_DELTA_KWH = Decimal("100")
|
||||||
|
|
||||||
|
# 每日固定费/税补在"本地午夜后多久"才结算入账。延后到 01:05 是为了让累计成本的
|
||||||
|
# 整天阶跃落在新一天、且避开 01:00 整点(HA 长期统计的小时桶边界)。
|
||||||
|
_SETTLEMENT_OFFSET = timedelta(hours=1, minutes=5)
|
||||||
|
|
||||||
# DSMR payload register keys (cumulative kWh, JSON string values).
|
# DSMR payload register keys (cumulative kWh, JSON string values).
|
||||||
_KEY_D1 = "electricity_delivered_1" # delivered low-tariff (dal / _1)
|
_KEY_D1 = "electricity_delivered_1" # delivered low-tariff (dal / _1)
|
||||||
_KEY_D2 = "electricity_delivered_2" # delivered high-tariff (normal / _2)
|
_KEY_D2 = "electricity_delivered_2" # delivered high-tariff (normal / _2)
|
||||||
@@ -678,15 +682,33 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
|
|||||||
+ fixed_costs -- per-day standing charges, cross-version
|
+ fixed_costs -- per-day standing charges, cross-version
|
||||||
- credits -- per-day heffingskorting, cross-version
|
- credits -- per-day heffingskorting, cross-version
|
||||||
|
|
||||||
**Fixed-cost / credit counting — Principle C**:
|
**Fixed-cost / credit counting — Principle C (symmetric begin/end)**:
|
||||||
Only *already-elapsed* whole local calendar days are counted. For each
|
Both the local calendar day in which *start* falls and the local calendar
|
||||||
local calendar date D in the window ``[start_local_date, min(end_local_date,
|
day in which *end* falls are counted as full days. Fixed charges
|
||||||
tomorrow_local))``, the contract version whose rate covers D (the one whose
|
(network_fee, management_fee) and the energy-tax credit (heffingskorting)
|
||||||
effective_from local-date ≤ D < next version's effective_from local-date) is
|
are assessed on a "service-is-active" basis — if the meter was online on a
|
||||||
used. Days that have not yet started in local time (D > today_local) are
|
given calendar day, the full day's charge/credit applies, regardless of
|
||||||
never counted. This is cross-version: if the active contract has V1 from
|
whether the window starts at midnight or mid-morning.
|
||||||
June 1 and V2 from June 25, querying June 1–30 uses V1 for days 1-24 and V2
|
|
||||||
for day 25. Switching versions never resets the counter.
|
For each local calendar date D in the range
|
||||||
|
``[local_date(start), min(local_date(end), today_local)]``:
|
||||||
|
- D ≤ today_local (only elapsed / today days count as "whole days").
|
||||||
|
- The contract version whose effective_from local-date ≤ D is used.
|
||||||
|
- This is cross-version: if V1 is from June 1 and V2 from June 25,
|
||||||
|
querying June 1–30 uses V1 for days 1-24 and V2 for day 25.
|
||||||
|
|
||||||
|
The end-day (last_counted) is included when the end's local midnight falls
|
||||||
|
strictly before end_utc; combined with the always-counted start day this
|
||||||
|
makes the begin/end handling symmetric. A short same-day window therefore
|
||||||
|
counts its single local day. A window contributes 0 days only when the
|
||||||
|
counted range is empty (first_counted > last_counted) — e.g. a window lying
|
||||||
|
entirely in the future, since last_counted is capped at today_local.
|
||||||
|
|
||||||
|
Daily getters (``*_today``) use windows exactly aligned to local midnight,
|
||||||
|
so their ``first_counted`` is always today — unaffected by this fix.
|
||||||
|
|
||||||
|
Days that have not yet started in local time (D > today_local) are
|
||||||
|
never counted. Switching versions never resets the counter.
|
||||||
|
|
||||||
**Timezone note**: the ``days`` field in the returned dict still represents
|
**Timezone note**: the ``days`` field in the returned dict still represents
|
||||||
the window length in calendar days (total_seconds / 86400), for backward
|
the window length in calendar days (total_seconds / 86400), for backward
|
||||||
@@ -746,29 +768,46 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
|
|||||||
days = _to_decimal(str(total_seconds)) / _to_decimal("86400")
|
days = _to_decimal(str(total_seconds)) / _to_decimal("86400")
|
||||||
|
|
||||||
# --- Fixed costs and credits: Principle C cross-version whole-day counting ---
|
# --- Fixed costs and credits: Principle C cross-version whole-day counting ---
|
||||||
today_local: _date = local_now().date()
|
_now_local = local_now()
|
||||||
|
today_local: _date = _now_local.date()
|
||||||
|
|
||||||
# --- Compute [first_counted, last_counted] local date range (inclusive) ---
|
# --- Compute [first_counted, last_counted] local date range (inclusive) ---
|
||||||
#
|
#
|
||||||
# We count local calendar day D if its LOCAL MIDNIGHT falls within [start_utc, end_utc)
|
# Both the window-start day and the window-end day are counted as complete
|
||||||
# AND D ≤ today_local (only elapsed / today days count as "whole days").
|
# local calendar days, regardless of whether the window starts/ends at midnight.
|
||||||
#
|
#
|
||||||
# Semantics: "A day D is counted when its local midnight has arrived (D ≤ today)
|
# Principle C (symmetric begin/end):
|
||||||
# AND the local midnight is within the billing window."
|
# • first_counted = local calendar date of start_utc (start day always counted)
|
||||||
|
# • last_counted = local calendar date of end_utc (end day counted if its
|
||||||
|
# local midnight is strictly before end_utc)
|
||||||
|
# • Both are then capped at today_local (only elapsed / today days count).
|
||||||
#
|
#
|
||||||
# This means a sub-day window [10:00, 10:30) UTC that doesn't contain any
|
# Why symmetric? Fixed charges (network_fee, management_fee) and energy-tax credits
|
||||||
# local midnight contributes 0 days, while a window [22:00 UTC, 23:00 UTC) that
|
# (heffingskorting) are assessed on a "service-is-active" basis, not on how many
|
||||||
# contains CEST midnight (= 22:00 UTC) contributes 1 day.
|
# hours the service was actually running within that calendar day. If the meter
|
||||||
|
# anchor (started_at) falls at 09:18 on June 24, the full June 24 standing charge
|
||||||
|
# and credit still apply because the service was online for that day.
|
||||||
#
|
#
|
||||||
# Implementation: compute the first and last local date whose midnight is in range.
|
# Previous asymmetric behaviour: a start_utc that was *later* than the local
|
||||||
|
# midnight of local_start_date caused first_counted to be bumped to the *next*
|
||||||
|
# day, silently dropping the anchor day's charges/credits. This was incorrect
|
||||||
|
# for cumulative entities (import_cost_total / export_revenue_total) whose anchor
|
||||||
|
# is often an above-midnight started_at. The end-side had always been symmetric
|
||||||
|
# (counted if local midnight < end_utc), creating an inconsistency.
|
||||||
|
#
|
||||||
|
# Daily getters (window = [local today 00:00, local tomorrow 00:00)) are
|
||||||
|
# unaffected: local_date(local_midnight_utc(today)) == today, so first_counted
|
||||||
|
# is still today regardless of the fix.
|
||||||
|
#
|
||||||
|
# A short same-day window now counts its single local day (the start day is
|
||||||
|
# always counted, symmetric with the end side). A window contributes 0 days
|
||||||
|
# only when the counted range is empty (first_counted > last_counted) — e.g. a
|
||||||
|
# window lying entirely in the future, where last_counted is capped at
|
||||||
|
# today_local while first_counted is later.
|
||||||
from app.services.timezone import local_midnight_utc as _lmu
|
from app.services.timezone import local_midnight_utc as _lmu
|
||||||
|
|
||||||
local_start_date: _date = local_date(start_utc)
|
# Start side: always count the local calendar day in which start_utc falls.
|
||||||
# Is the midnight of local_start_date ≥ start_utc? If not, first midnight is next day.
|
first_counted: _date = local_date(start_utc)
|
||||||
if _lmu(local_start_date) >= start_utc:
|
|
||||||
first_counted: _date = local_start_date
|
|
||||||
else:
|
|
||||||
first_counted = local_start_date + _td(days=1)
|
|
||||||
|
|
||||||
local_end_date: _date = local_date(end_utc)
|
local_end_date: _date = local_date(end_utc)
|
||||||
# Is the midnight of local_end_date < end_utc? If yes, that day's midnight is in range.
|
# Is the midnight of local_end_date < end_utc? If yes, that day's midnight is in range.
|
||||||
@@ -777,8 +816,16 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
|
|||||||
else:
|
else:
|
||||||
last_counted = local_end_date - _td(days=1)
|
last_counted = local_end_date - _td(days=1)
|
||||||
|
|
||||||
# Cap: only elapsed or today's days.
|
# Settlement cap: "today" is only counted once the local clock has passed the
|
||||||
last_counted = min(last_counted, today_local)
|
# settlement offset since midnight (01:05). This defers the daily standing-charge
|
||||||
|
# step from 00:00 to 01:05, ensuring the cumulative-cost adiabatic jump lands
|
||||||
|
# inside the new calendar day and avoids the HA 01:00 hourly-bucket boundary.
|
||||||
|
_today_midnight_utc = _lmu(today_local)
|
||||||
|
if _now_local >= _today_midnight_utc + _SETTLEMENT_OFFSET:
|
||||||
|
settled_cap = today_local
|
||||||
|
else:
|
||||||
|
settled_cap = today_local - _td(days=1)
|
||||||
|
last_counted = min(last_counted, settled_cap)
|
||||||
|
|
||||||
# If the range is empty (first_counted > last_counted), no days are counted.
|
# If the range is empty (first_counted > last_counted), no days are counted.
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ def poll_device(session: Session, device: ModbusDevice) -> ModbusReading | None:
|
|||||||
device.port,
|
device.port,
|
||||||
device.unit_id,
|
device.unit_id,
|
||||||
[{"start": b.start, "count": b.count} for b in profile.blocks],
|
[{"start": b.start, "count": b.count} for b in profile.blocks],
|
||||||
|
function_code=profile.function_code,
|
||||||
)
|
)
|
||||||
payload = profiles.decode(profile, registers)
|
payload = profiles.decode(profile, registers)
|
||||||
|
|
||||||
|
|||||||
@@ -118,8 +118,9 @@ credits:
|
|||||||
kind: tibber
|
kind: tibber
|
||||||
label: Tibber 动态电价(15 分钟)
|
label: Tibber 动态电价(15 分钟)
|
||||||
energy:
|
energy:
|
||||||
source: tibber_api # buy = total; sell = total − energy_tax − sell_adjust
|
source: tibber_api # buy = total; sell = total − energy_tax − sell_fee − sell_adjust
|
||||||
energy_tax: { unit: EUR/kWh }
|
energy_tax: { unit: EUR/kWh }
|
||||||
|
sell_fee: { unit: EUR/kWh, default: 0.0248 } # verkoopvergoeding, always subtracted
|
||||||
sell_adjust: { unit: EUR/kWh, default: 0 }
|
sell_adjust: { unit: EUR/kWh, default: 0 }
|
||||||
standing:
|
standing:
|
||||||
management_fee: { unit: EUR/month, default: 5.99 }
|
management_fee: { unit: EUR/month, default: 5.99 }
|
||||||
@@ -164,7 +165,7 @@ credits:
|
|||||||
1. 取各寄存器在 `t0`/`t1` 的值(`recorded_at ≤ 边界` 的最后一行,Decimal),算 **per-register 差**:`Δd1,Δd2,Δr1,Δr2`。
|
1. 取各寄存器在 `t0`/`t1` 的值(`recorded_at ≤ 边界` 的最后一行,Decimal),算 **per-register 差**:`Δd1,Δd2,Δr1,Δr2`。
|
||||||
2. 取 active 合同**在 t0 生效的版本** + 其 strategy:
|
2. 取 active 合同**在 t0 生效的版本** + 其 strategy:
|
||||||
- `manual`:`import_cost = Δd1×(buy_dal) + Δd2×(buy_normal)`(`buy_x = energy_buy_x + energy_tax + ode`);`export_revenue = Δr1×sell_dal + Δr2×sell_normal`。
|
- `manual`:`import_cost = Δd1×(buy_dal) + Δd2×(buy_normal)`(`buy_x = energy_buy_x + energy_tax + ode`);`export_revenue = Δr1×sell_dal + Δr2×sell_normal`。
|
||||||
- `tibber`:取覆盖 t0 的 `tibber_price`(`starts_at ≤ t0` 最近一条);`buy = total`、`sell = total − energy_tax − sell_adjust`;`import_cost = (Δd1+Δd2)×buy`、`export_revenue = (Δr1+Δr2)×sell`。
|
- `tibber`:取覆盖 t0 的 `tibber_price`(`starts_at ≤ t0` 最近一条);`buy = total`、`sell = total − energy_tax − sell_fee − sell_adjust`(`sell_fee`=verkoopvergoeding,默认 0.0248,见下修正说明);`import_cost = (Δd1+Δd2)×buy`、`export_revenue = (Δr1+Δr2)×sell`。
|
||||||
3. `net_cost = import_cost − export_revenue`;**upsert** `energy_cost_period`,**快照**当时用的价 + `contract_version_id`。
|
3. `net_cost = import_cost − export_revenue`;**upsert** `energy_cost_period`,**快照**当时用的价 + `contract_version_id`。
|
||||||
- 缺价/缺数据:跳过或标 `degraded`,留待重算。**不做净计量**(进出口分开累加)。
|
- 缺价/缺数据:跳过或标 `degraded`,留待重算。**不做净计量**(进出口分开累加)。
|
||||||
|
|
||||||
@@ -240,7 +241,7 @@ credits:
|
|||||||
|
|
||||||
1. **两层电价模型**:profile YAML 定结构(仓库、固定、UI 不可编辑)+ `EnergyContract`(+版本) 存数值(UI 填、版本化)+ strategy 按 kind 出价。仿 M5。
|
1. **两层电价模型**:profile YAML 定结构(仓库、固定、UI 不可编辑)+ `EnergyContract`(+版本) 存数值(UI 填、版本化)+ strategy 按 kind 出价。仿 M5。
|
||||||
2. **kind 不叫 "fixed"**:`manual`(人工填、可双费率、可带时段)/ `tibber`(API 动态);合同 `name` UI 自由填。
|
2. **kind 不叫 "fixed"**:`manual`(人工填、可双费率、可带时段)/ `tibber`(API 动态);合同 `name` UI 自由填。
|
||||||
3. **买价**:tibber = API `total`(全包,已证 total=energy+tax);manual = `energy_buy_档 + energy_tax`。**卖价**:tibber = `total − energy_tax − sell_adjust`;manual = `sell_档`(回送价,无能源税)。均含 VAT。
|
3. **买价**:tibber = API `total`(全包,已证 total=energy+tax;含 inkoopvergoeding);manual = `energy_buy_档 + energy_tax`。**卖价**:tibber = `total − energy_tax − sell_fee − sell_adjust`(`sell_fee`=verkoopvergoeding 默认 0.0248);manual = `sell_档`(回送价,无能源税)。均含 VAT。
|
||||||
4. **双费率**:manual 用 `delivered_1/2`、`returned_1/2` 分 dal/normal 计价(`_1`=dal/低、`_2`=normal/高);tibber 求和、15min 价不分档。
|
4. **双费率**:manual 用 `delivered_1/2`、`returned_1/2` 分 dal/normal 计价(`_1`=dal/低、`_2`=normal/高);tibber 求和、15min 价不分档。
|
||||||
5. **两层费用**:每 15min `energy_cost_period` 只算计量电费(不可变、快照价);日/月/年汇总再加固定费(按月→天)− heffingskorting(按年→天)。
|
5. **两层费用**:每 15min `energy_cost_period` 只算计量电费(不可变、快照价);日/月/年汇总再加固定费(按月→天)− heffingskorting(按年→天)。
|
||||||
6. **回送阶梯罚金(terugleverkosten)不做**:按自然年累计、用户住不到年底算不准——不算、不记、不加功能(留痕见 §10)。
|
6. **回送阶梯罚金(terugleverkosten)不做**:按自然年累计、用户住不到年底算不准——不算、不记、不加功能(留痕见 §10)。
|
||||||
@@ -475,7 +476,7 @@ Phase D(API + 前端)
|
|||||||
## 13. 待确认 / TODO(拿到真实 token + 账单后钉死,均已落成配置/默认值,不阻塞实现)
|
## 13. 待确认 / TODO(拿到真实 token + 账单后钉死,均已落成配置/默认值,不阻塞实现)
|
||||||
|
|
||||||
1. **买价**:✅ tibber = API `total`(demo 已证 total=energy+tax);manual = energy_buy_档 + energy_tax。无待办。
|
1. **买价**:✅ tibber = API `total`(demo 已证 total=energy+tax);manual = energy_buy_档 + energy_tax。无待办。
|
||||||
2. **卖价残差(tibber)**:`sell = total − energy_tax − sell_adjust`,`sell_adjust` 默认 0(买卖费相等抵消)。真实账单确认后若有残差再调。
|
2. **卖价残差(tibber)**:~~`sell = total − energy_tax − sell_adjust`,`sell_adjust` 默认 0(买卖费相等抵消)~~ → **已修正(2026-07,见 references §3.1)**:`total` 含 inkoopvergoeding,净计量回送 = `total − verkoopvergoeding`,两费**不抵消**。公式改为 `sell = total − energy_tax − sell_fee − sell_adjust`,新增 `sell_fee`(默认 0.0248,始终扣除);`sell_adjust` 净计量期设 `−energy_tax`。真实账单确认后若有残差再调 `sell_fee`。
|
||||||
3. **双费率寄存器映射**:`_1`=dal/低、`_2`=normal/高(NL 惯例)——接价前用真实数据确认别接反(差价小但要对)。
|
3. **双费率寄存器映射**:`_1`=dal/低、`_2`=normal/高(NL 惯例)——接价前用真实数据确认别接反(差价小但要对)。
|
||||||
4. **能源税年值**:manual/tibber 的 `energy_tax` 默认 ~0.1108(2026 第一档含 VAT),按当年实际值核。
|
4. **能源税年值**:manual/tibber 的 `energy_tax` 默认 ~0.1108(2026 第一档含 VAT),按当年实际值核。
|
||||||
5. **Tibber 15min + 币种**:✅ 查询/分辨率已 demo 证实;仍需合同生效后用**真实 token** 确认 NL 返回真 15 分钟价 + 币种 EUR。
|
5. **Tibber 15min + 币种**:✅ 查询/分辨率已 demo 证实;仍需合同生效后用**真实 token** 确认 NL 返回真 15 分钟价 + 币种 EUR。
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,198 @@
|
|||||||
|
# DDSU666 Modbus 协议(从官方 PDF 提取)
|
||||||
|
|
||||||
|
> 来源:`docs/references/DDSU666 Single phase Smart Meter.pdf`
|
||||||
|
> (CHINT / 正泰仪表 **DDSU666 Single phase Smart Meter — Operation Manual**,文档号 `ZTY0.464.1224`,版本 **V2**,2020 年 8 月;厂商 Zhejiang Chint Instrument & Meter Co., Ltd.)
|
||||||
|
> 本文件是 PDF 的可读化提取,供本项目的 Modbus 采集驱动设计参考。**以官方 PDF 为准**,本文件如有出入以 PDF 为准。
|
||||||
|
> 同类文档见 SDM120 的 `SDM120-Modbus-Protocol.md`;两表差异较大,见下方 §6「与 SDM120 的关键差异」。
|
||||||
|
|
||||||
|
## 设备速览(来自手册 Table 1 / Table 5)
|
||||||
|
|
||||||
|
| 项 | DDSU666(直接接入) | DDSU666-CT(经互感器) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 精度等级 | Active Class B | Active Class C |
|
||||||
|
| 参考电压 | 230 V | 230 V |
|
||||||
|
| 电流规格 | 0.25–5(80) A | 0.015–1.5(6) A |
|
||||||
|
| 表常数 | 800 imp/kWh | 6400 imp/kWh |
|
||||||
|
| 接入方式 | 直接接入 | 经电流互感器 |
|
||||||
|
|
||||||
|
- 单相电子式电能表,DIN35mm 导轨安装;测量电压、电流、有功/无功功率、频率、功率因数、正/反向有功电能。
|
||||||
|
- 电能测量范围 `0~999999.99 kWh`(LCD 只显示 6 位,自动移动小数点)。
|
||||||
|
- 通信:RS485,**Modbus-RTU**(也支持 DL/T 645-2007,可切换,见 §5 `0005H ChangeProtocol`)。
|
||||||
|
- 手册 Table 1 标注 Frequency Reference = 60Hz,但 LCD 示例又写 `F=50.00Hz`(手册自身不一致);**实际频率以寄存器 `200EH` 读数为准**,不要把 50/60 写死。
|
||||||
|
|
||||||
|
## 0. 本项目的接入方式(重要)
|
||||||
|
|
||||||
|
DDSU666 物理层是 **Modbus RTU(RS-485 串口)**,半双工。和 SDM120 一样,本项目通过一个 **Modbus-TCP 网关**接入:
|
||||||
|
|
||||||
|
- 后端用 **Modbus TCP**(`IP:port`)连到网关,网关在串口侧转成 RTU 与电表通信。
|
||||||
|
- TCP 帧用 MBAP header、**无 CRC**(CRC 由网关在 RTU 侧处理)。本文档里 RTU 帧的 `CRC (Lo/Hi)` 字段在 TCP 模式下不需要我们关心。
|
||||||
|
- **Slave Address / Unit ID = 电表的通信地址 Addr**(范围 1–247;面板按键只能设 1–99;见 §5 `0006H`),在 TCP 请求里作为 unit id 传入。
|
||||||
|
- 若以后直连串口(RTU),才需要管波特率 / 数据格式 / CRC:**默认串口格式是 8 数据位、无校验、2 停止位(8N2)**,与 SDM120 的 8N1 不同——直连时务必对齐。
|
||||||
|
- 电表必须处于 **Modbus 协议模式**(而非 DL/T 645)才能用本协议;可经面板长按切换,或写 `0005H = 2`(见 §5)。
|
||||||
|
|
||||||
|
## 1. 协议帧格式(Appendix A)
|
||||||
|
|
||||||
|
异步传输,按字节为单位。一帧 10 位字符 = **1 起始位(0) + 8 数据位(无校验) + 2 停止位(1)**(其它格式可定制)。
|
||||||
|
|
||||||
|
### 信息帧结构(Table A.1)
|
||||||
|
|
||||||
|
| 字段 | 长度 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Start(起始) | >3.5 字符静默 | 帧间至少 3.5 字符空闲时间作为分隔 |
|
||||||
|
| Address code(地址码) | 1 字节 | 目标从机地址 1–247;每个从机在总线上地址唯一 |
|
||||||
|
| Function code(功能码) | 1 字节 | 仅支持 **03H / 10H**(见 §2) |
|
||||||
|
| Data(数据域) | n 字节 | 随功能码不同而不同(起始地址、寄存器数、寄存器数据等) |
|
||||||
|
| CRC check code(CRC 校验) | 2 字节 | 16-bit CRC(**低字节在前、高字节在后**;多项式 `A001`) |
|
||||||
|
| End(结束) | >3.5 字符静默 | 帧间静默 |
|
||||||
|
|
||||||
|
> TCP 网关模式下 Start/End 静默与 CRC 由网关处理,本项目不关心。
|
||||||
|
|
||||||
|
### 功能码 03H 示例(读寄存器,Table A.3/A.4)
|
||||||
|
|
||||||
|
读从机 `01H`、起始地址 `0CH`、2 个寄存器:
|
||||||
|
|
||||||
|
- 主机发送:`01 03 00 0C 00 02 04 08`(最后 `04 08` 是 CRC,低字节 `04` 在前)。
|
||||||
|
- 从机返回(设 `0CH/0DH` 内容为 `0000H`、`1388H`):`01 03 04 00 00 13 88 F7 65`
|
||||||
|
- `04` = 字节数;`00 00` = `0CH` 数据;`13 88` = `0DH` 数据;`F7 65` = CRC(低字节 `F7` 在前)。
|
||||||
|
|
||||||
|
> **注意**:单个 16-bit 寄存器内是「高字节在前、低字节在后」(Table A.4 里 `0DH` 数据返回 `13 88` = `0x1388`)。这一点对解码浮点的字节序很关键,见 §3。
|
||||||
|
|
||||||
|
### 功能码 10H 示例(写多个寄存器,Table A.5/A.6)
|
||||||
|
|
||||||
|
向从机 `01H`、起始地址 `00H` 连续写 3 个寄存器 `0002H,1388H,000AH`:
|
||||||
|
|
||||||
|
- 主机发送:`01 10 00 00 00 03 06 00 02 13 88 00 0A 9B E9`
|
||||||
|
- `06` = 写入字节数(3 寄存器 × 2 字节);随后是 3 个寄存器的数据;末尾 `9B E9` CRC。
|
||||||
|
- 从机返回:`01 10 00 00 00 03 80 08`(回显起始地址 + 寄存器数 + CRC `80 08`)。
|
||||||
|
|
||||||
|
### 异常响应(Table A.7/A.8)
|
||||||
|
|
||||||
|
- 异常时返回的 Function Code = **原功能码 + 128**(即最高位置 1,`03H→83H`、`10H→90H`)。
|
||||||
|
- 数据为单字节 Error Code:
|
||||||
|
|
||||||
|
| Error Code | 含义 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `01H` | Illegal function code | 收到的功能码本表不支持 |
|
||||||
|
| `02H` | Illegal register address | 寄存器地址超出有效范围 |
|
||||||
|
| `03H` | Illegal data value | 数据值超出对应地址的取值范围 |
|
||||||
|
|
||||||
|
## 2. 功能码
|
||||||
|
|
||||||
|
DDSU666 **只支持两个功能码**(Table A.2):
|
||||||
|
|
||||||
|
| 功能码 | 作用 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **03H** | Read register(读寄存器) | 读一个或多个寄存器——**测量值、电量、配置全部走它** |
|
||||||
|
| **10H** | Write multiple registers(写多个寄存器) | 向 n 个连续寄存器写 n 个 16-bit 数据(改配置 / 清电量) |
|
||||||
|
|
||||||
|
> ⚠️ **DDSU666 没有「输入寄存器 / FC04」概念**——所有数据(包括电压电流功率)都用 **FC 03H** 读保持寄存器。这是它和 SDM120(测量值走 FC04)最大的踩坑差异,见 §6。
|
||||||
|
|
||||||
|
## 3. 数据编码
|
||||||
|
|
||||||
|
DDSU666 有两类数据:
|
||||||
|
|
||||||
|
1. **配置 / 参数寄存器(§5,`0000H`–`0010H`)**:每个 1 个寄存器、`16-bit with symbols`(**16 位有符号整数**)。
|
||||||
|
2. **测量 / 电量寄存器(§4,`2000H`+ / `4000H`+)**:每个参数 = **32-bit IEEE-754 单精度浮点**(手册写 “single precision floating decimal”),占 **2 个相邻寄存器**(Length = 2 Word)。
|
||||||
|
|
||||||
|
**浮点字节序 / 字序**
|
||||||
|
|
||||||
|
- **字节序(byte order)= 大端:寄存器内高字节在前** —— 由 Table A.4 的 `0x1388` 返回为 `13 88` 确认。
|
||||||
|
- **字序(word order,两个寄存器谁是高 16 位)**:手册**没有给出浮点解码的实例**,未明确标注。按标准 Modbus 浮点惯例应为**大端字序(高寄存器在前,`ABCD`)**,与本项目 SDM120 驱动一致(`registers_to_float` 用 `>f`,高寄存器在前)。
|
||||||
|
- ⚠️ **需上机实测确认**:读 `2000H`(电压)应解出 ~230V 这样的合理值;若解出乱数,多半是字序相反,改成「低寄存器在前」再试。CHINT 同系列(DTSU/DDSU666)现场固件偶有字序差异,**首次接入务必用一个已知量(电压)校验**,不要凭手册想当然。
|
||||||
|
|
||||||
|
> Python 解码(大端、高寄存器在前):`struct.unpack('>f', struct.pack('>HH', hi_reg, lo_reg))[0]`。
|
||||||
|
> pymodbus:`BinaryPayloadDecoder.fromRegisters(regs, byteorder=Endian.BIG, wordorder=Endian.BIG)`。
|
||||||
|
|
||||||
|
## 4. 测量 / 电量寄存器表(FC 03H 读)
|
||||||
|
|
||||||
|
全部为只读、`Float`(32-bit),每项占 **2 个寄存器**。地址为 Modbus 协议原始地址(即帧里的 Start Register Address Hi/Lo),手册用十六进制。
|
||||||
|
|
||||||
|
### 4.1 瞬时量(“Electric quantity of the secondary side”,`2000H` 段)
|
||||||
|
|
||||||
|
| 地址(hex) | 参数 | 代号 | 单位 | 备注 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `2000H` | 电压 Voltage | U | V | |
|
||||||
|
| `2002H` | 电流 Current | I | A | |
|
||||||
|
| `2004H` | 有功功率 Active power | P | **kW** | 手册标注 “the unit is KW”——**不是 W** |
|
||||||
|
| `2006H` | 无功功率 Reactive power | Q | **kvar** | |
|
||||||
|
| `2008H` | (保留 RESERVED) | — | — | 占 2 寄存器,跳过 |
|
||||||
|
| `200AH` | 功率因数 Power factor | PF | — | 无量纲 |
|
||||||
|
| `200CH` | (保留 RESERVED) | — | — | 占 2 寄存器,跳过 |
|
||||||
|
| `200EH` | 频率 Frequency | Freq | Hz | |
|
||||||
|
|
||||||
|
### 4.2 电量(“Electrical data of the secondary side”,`4000H` 段)
|
||||||
|
|
||||||
|
| 地址(hex) | 参数 | 代号 | 单位 | 备注 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `4000H` | 正向(导入)有功电能 Active in electricity | Ep | kWh | 正向 / forward active energy |
|
||||||
|
| `400AH` | 反向(导出)有功电能 Reverse in electricity | -Ep | kWh | 反向 / reverse active energy |
|
||||||
|
|
||||||
|
> 手册里 `4000H` 与 `400AH` 之间(`4002H`–`4009H`)未列出,视为保留/未文档化。
|
||||||
|
|
||||||
|
**读取分块建议**
|
||||||
|
|
||||||
|
- 瞬时量:`2000H`–`200FH` 共 **16 个寄存器连续**,一次块读即可覆盖 U…Freq(含两段 RESERVED,解码时跳过)。
|
||||||
|
- 电量:`4000H`(2 寄存器)与 `400AH`(2 寄存器)相距较远,分两小块读,或读 `4000H`–`400BH`(12 寄存器)一次取出后挑用——以网关 / 电表是否允许跨保留地址块读为准,谨慎起见分开读更稳。
|
||||||
|
|
||||||
|
### 常用核心子集(日常监控够用)
|
||||||
|
|
||||||
|
电压 `2000H`、电流 `2002H`、有功功率 `2004H`(kW)、功率因数 `200AH`、频率 `200EH`、正向有功电能 `4000H`、反向有功电能 `400AH`。
|
||||||
|
|
||||||
|
## 5. 配置 / 参数寄存器表(FC 03H 读 / FC 10H 写)(Table 9)
|
||||||
|
|
||||||
|
每个 1 个寄存器、**16-bit 有符号整数**。`R/W` 列来自手册。
|
||||||
|
|
||||||
|
| 地址(hex) | 代号 | 含义 | R/W | 取值 / 说明 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `0000H` | UCode | 编程密码 Programming password code | R/W | 写配置前的密码字 |
|
||||||
|
| `0001H` | REV. | 保留;**实际读出的是版本号** | R | |
|
||||||
|
| `0002H` | ClrE | 电能清零 CLr.E | R/W | **写 `1` 清除总电量**(不可逆,慎用) |
|
||||||
|
| `0003H`–`0004H` | RESERVED | 保留 | — | |
|
||||||
|
| `0005H` | ChangeProtocol | 协议切换 | R/W | **`2` = Modbus-RTU**,`1` = DL/T 645-2007 |
|
||||||
|
| `0006H` | Addr | 通信地址 | R/W | 1–247(面板按键仅 1–99) |
|
||||||
|
| `0007H`–`000AH` | RESERVED | 保留 | — | |
|
||||||
|
| `000BH` | Meter type | 表型 Meter type | R | 只读设备类型标识 |
|
||||||
|
| `000CH` | BAud | 通信波特率 | R/W | **`1`=2400bps,`2`=4800bps,`3`=9600bps**(手册寄存器仅列这三档;通信章另提到也支持 1200bps) |
|
||||||
|
| `000DH`–`0010H` | RESERVED | 保留 | — | |
|
||||||
|
|
||||||
|
> ⚠️ 写 `0002H`(清电量)、`0006H`(改地址)、`000CH`(改波特率)、`0005H`(切协议)都会改变电表状态或通信参数,配错可能**清空累计电量**或**导致通信中断**。本项目默认**只读采集**,不在自动化链路里写电表配置寄存器。
|
||||||
|
> 写配置通常需先经 `0000H UCode` 密码字校验;具体密码值手册正文未给出,需向厂商确认或经面板操作。
|
||||||
|
|
||||||
|
## 6. 与 SDM120 的关键差异(迁移 / 复用驱动时必看)
|
||||||
|
|
||||||
|
本项目已有 SDM120 profile(`app/integrations/modbus/profiles/sdm120.yaml`)。DDSU666 **不能照搬**,主要差异:
|
||||||
|
|
||||||
|
| 维度 | SDM120 (Eastron) | DDSU666 (CHINT) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 读测量值功能码 | **FC 04**(输入寄存器 3X) | **FC 03**(保持寄存器,无 FC04) |
|
||||||
|
| 测量值起始地址 | `0x0000` 起(30001) | 瞬时量 `0x2000` 起;电量 `0x4000`/`0x400A` |
|
||||||
|
| 有功功率单位 | **W**(瓦) | **kW(千瓦)** —— 入库前注意换算 / 单位标注 |
|
||||||
|
| 无功功率单位 | VAr | kvar |
|
||||||
|
| 配置寄存器格式 | Float(FC03/16) | **16-bit 有符号整数**(FC03/10) |
|
||||||
|
| 写功能码 | 16 / 0x10 | 10H(同 0x10) |
|
||||||
|
| 串口默认格式 | 8N1(1 停止位) | **8N2(2 停止位)** |
|
||||||
|
| 多协议 | 仅 Modbus | Modbus **与 DL/T 645-2007 可切换**(需确保在 Modbus 模式) |
|
||||||
|
| 浮点字序 | 大端、高寄存器在前(手册有实例佐证) | 字节序大端已确认;**字序手册无实例,需上机实测** |
|
||||||
|
|
||||||
|
## 7. 给本项目采集驱动的要点小结
|
||||||
|
|
||||||
|
1. 走 **Modbus TCP 网关**:`ModbusTcpClient(host, port)`,`slave=<Addr>`;电表须在 **Modbus 协议模式**。
|
||||||
|
2. 所有读取(测量 + 电量 + 配置)都用 **FC 03H**——**没有 FC04**。
|
||||||
|
3. 测量值在 `0x2000` 段、电量在 `0x4000`/`0x400A`,均为 **float32**;解码大端字节序,**字序默认高寄存器在前但务必用电压实测校验**。
|
||||||
|
4. **有功功率单位是 kW、无功是 kvar**——与 SDM120 的 W/VAr 不同,新建 profile / 入库映射时单位别抄错。
|
||||||
|
5. 配置寄存器(`0x0000`–`0x0010`)是 **16-bit 有符号整数**,不是 float。
|
||||||
|
6. 默认**只读**;`0002H` 写 1 会**清空累计电量**、`0006H/000CH/0005H` 会改通信参数,自动化链路里一律不写。
|
||||||
|
7. 新建 profile 时这是 `ddsu666` 这一个 register profile 的定义;建议 `function_code: 3`、`word_order: big`(先按大端字序,接入后用电压读数验证)、瞬时量与电量分块读。
|
||||||
|
|
||||||
|
## 8. 实测记录(真机验证,2026-06-30)
|
||||||
|
|
||||||
|
首次接入一台 **DDSU666 直接接入版(5(80)A)** 实测,确认以下几点:
|
||||||
|
|
||||||
|
- **字序大端,确认无误**:电压 / 电流 / 频率 / 电能用「大端、高寄存器在前」解码全部得到合理值(如 233.9 V / 0.055 A / 49.99 Hz),与 §3 的假设一致。`ddsu666.yaml` 的 `word_order: big` / `byte_order: big` **无需修改**,§3 里「字序需上机实测」一项可视为已关闭。
|
||||||
|
- **FC03 读通**:所有量走 FC03,profile `ddsu666` 在采集链路(CLI `read` / 设备 `/test` / 后台轮询)中工作正常。
|
||||||
|
- **低电流下「瞬时功率读 0、但电能照常累加」**:测试负载仅为一台 PoE 交换机(≈230 V / 0.05 A,真实有功仅几瓦),**远低于本表测量量程下限 Imin≈0.25 A**。此工况下:
|
||||||
|
- 瞬时 `active_power`(`0x2004`)与 `power_factor`(`0x200A`)寄存器返回**全零**(原始 hex `0x0000 0x0000`);电表 LCD 上功率在 0~3.3 W、PF 在 0~0.6 之间抖动。
|
||||||
|
- 抖动成因 = **低电流测量噪声 + 开关电源(SMPS,无 PFC)畸变电流**(电流为电压峰值附近的窄脉冲、谐波重 → 畸变功率因数天然偏低)。**不是**「采样率与开关频率拍频」:计量芯片 SH79F7019 采样在 kHz 量级,远低于开关电源 50–200 kHz 的开关频率,两者不在一个频段。
|
||||||
|
- 但累计电能 `import_energy`(`0x4000`)**正常累加**(实测 0 → 0.01 kWh)——电表内部积分器在防潜动起始电流(`0.004·Ib`≈0.02 A)之上照常计量。
|
||||||
|
- **结论**:低于量程下限时**瞬时功率 / PF 不可信,但电能计量不丢**;电流进入量程(正常负载)后瞬时量即稳定可信。这是 5(80)A 大量程表对极小负载的固有特性,**非缺陷、非解码问题**。
|
||||||
|
- **排错提示**:若日后看到 DDSU666「功率一直 0」,先确认负载电流是否在 Imin 以上——多半是负载太轻而非链路故障;可用 `scripts/modbus_cli probe --fc 3 --address 0x2000 --count 16` 看 `0x2004/0x2005` 原始寄存器是否真为全零佐证。
|
||||||
@@ -113,16 +113,26 @@ curl -s -X POST https://api.tibber.com/v1-beta/gql \
|
|||||||
> "De verkoopvergoeding van 2,48 cent is gelijk aan de inkoopvergoeding die je bij je afgenomen stroom betaalt."
|
> "De verkoopvergoeding van 2,48 cent is gelijk aan de inkoopvergoeding die je bij je afgenomen stroom betaalt."
|
||||||
> (卖侧 verkoopvergoeding 2.48 分 = 买侧 inkoopvergoeding。)
|
> (卖侧 verkoopvergoeding 2.48 分 = 买侧 inkoopvergoeding。)
|
||||||
|
|
||||||
→ **买卖服务费相等(均 €0.0248/kWh)**,在买卖里一进一出**相互抵消**。
|
→ **买卖服务费金额相等(均 €0.0248/kWh),但两者对住户都是成本、不互相抵消**:
|
||||||
|
- 买侧 inkoopvergoeding 已经**包含在 Tibber API 的 `total` 里**(见下 §3.1 的实证拆解),买电按 `total` 计价即已含它。
|
||||||
|
- 卖侧 verkoopvergoeding 则是从回送价里**额外扣掉**的一笔——所以回送价 = `total − 0.0248`,比买价低 0.0248/kWh。
|
||||||
|
- ⚠️ **早期版本误判为"一进一出抵消 → 回送=total"**,这是错的:`total` 里那笔 inkoopvergoeding 不会退回来充抵 verkoopvergoeding。代码里用 `energy.sell_fee`(默认 0.0248)建模这笔卖侧费用。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 净计量(saldering)、回送(teruglevering)、负电价、2027
|
## 3. 净计量(saldering)、回送(teruglevering)、负电价、2027
|
||||||
|
|
||||||
### 3.1 回送价(净计量期内,文档原文)
|
### 3.1 回送价(净计量期内,文档原文 + 实证)
|
||||||
> "Op het moment dat je teruglevert geven we je per kWh de beursprijs die op dat moment geldt …, inclusief energiebelasting en inkoopvergoeding plus de btw minus de verkoopvergoeding."
|
> "Op het moment dat je teruglevert geven we je per kWh de beursprijs die op dat moment geldt …, inclusief energiebelasting en inkoopvergoeding plus de btw minus de verkoopvergoeding."
|
||||||
|
|
||||||
即净计量期内回送价 = `beursprijs + energiebelasting + inkoopvergoeding + btw − verkoopvergoeding`。因 inkoopvergoeding = verkoopvergoeding 抵消 → **= 全额零售价**(spot+能源税+VAT),正是 saldering "回送 1 度 = 用 1 度"的本质。
|
> **Worked example(Tibber NL 原文)**:"Stel dat tussen 14:00 en 14:15 de totale stroomprijs €0,28 per kWh incl. is, dan krijg je €0,28 − €0,0248 verkoopvergoeding = **€0,2552** per teruggeleverde kWh terug."
|
||||||
|
|
||||||
|
即净计量期内回送价 = `beursprijs + energiebelasting + inkoopvergoeding + btw − verkoopvergoeding`,而官方例子直接写成 **`回送价 = totale stroomprijs − verkoopvergoeding = total − 0.0248`**。能源税**退回**(留在 total 里没动),只有 verkoopvergoeding 这 0.0248 被扣。
|
||||||
|
|
||||||
|
**✅ 实证(本项目生产库,2026-07-20 三个刻钟)**:按 21% VAT 拆 `total`:`total = 现货×1.21 + energiebelasting(0.11085) + inkoopvergoeding(0.0248)`,三段解出的 inkoop 都精确等于 **0.0248**。→ **我们存的 `tibber_price.total` 就是官方 "totale stroomprijs"(含 inkoopvergoeding 的买价)**,因此:
|
||||||
|
- 买价 `buy = total`(已含 inkoopvergoeding,正确)。
|
||||||
|
- 净计量回送价 `sell = total − verkoopvergoeding = total − 0.0248`。
|
||||||
|
- ⚠️ 所以 saldering 下"回送 1 度"仍比"用 1 度"少 0.0248——**不是完全 1:1**。代码用 `sell_fee` 建模这笔扣减,`sell_adjust` 只负责在净计量期把能源税补回(`sell_adjust = −energy_tax`)。
|
||||||
|
|
||||||
### 3.2 年末盈余 / 取消净计量后(文档原文,Scenario 2)
|
### 3.2 年末盈余 / 取消净计量后(文档原文,Scenario 2)
|
||||||
> "Voor de overproductie van 500 kWh heb je recht op de beursprijs en de inkoopvergoeding, maar heb je geen recht op de energiebelasting. … ontvang je nog een factuur van ons voor de te veel uitgekeerde belastingen …"
|
> "Voor de overproductie van 500 kWh heb je recht op de beursprijs en de inkoopvergoeding, maar heb je geen recht op de energiebelasting. … ontvang je nog een factuur van ons voor de te veel uitgekeerde belastingen …"
|
||||||
@@ -146,9 +156,11 @@ curl -s -X POST https://api.tibber.com/v1-beta/gql \
|
|||||||
|
|
||||||
> spot 取 API `energy`;`total = energy + tax`(全包)。**买价直接用 `total`**,卖价从 `total` 扣掉卖电不交的能源税。
|
> spot 取 API `energy`;`total = energy + tax`(全包)。**买价直接用 `total`**,卖价从 `total` 扣掉卖电不交的能源税。
|
||||||
|
|
||||||
- **Tibber 动态合同**(post-2027 口径):
|
- **Tibber 动态合同**:
|
||||||
- 买价 `buy = price.total`
|
- 买价 `buy = price.total`(含 energy_tax + VAT + inkoopvergoeding)
|
||||||
- 卖价 `sell = price.total − energy_tax_per_kwh − sell_adjust`(`sell_adjust` 默认 0;含 VAT 归己;买卖费抵消已隐含在 total 里)
|
- 卖价 `sell = price.total − energy_tax − sell_fee − sell_adjust`
|
||||||
|
- `sell_fee`:verkoopvergoeding(卖侧上网费,默认 **0.0248**,含 VAT),**始终扣除**——即使净计量期也扣(见 §3.1)。
|
||||||
|
- `sell_adjust`:手动修正项(默认 0)。**净计量期**设为 `−energy_tax`(把能源税补回),得 `sell = total − sell_fee`;**2027 取消净计量后**设为 0,得 `sell = total − energy_tax − sell_fee`(无能源税、纯市场价再扣上网费)。
|
||||||
- **固定合同(manual,双费率)**:
|
- **固定合同(manual,双费率)**:
|
||||||
- 买价 `buy_档 = energy_buy_档 + energy_tax`(档 ∈ {normal, dal})
|
- 买价 `buy_档 = energy_buy_档 + energy_tax`(档 ∈ {normal, dal})
|
||||||
- 卖价 `sell_档 = sell_档`(回送价,**无能源税**)
|
- 卖价 `sell_档 = sell_档`(回送价,**无能源税**)
|
||||||
@@ -242,7 +254,7 @@ extra_device_timestamp, extra_device_delivered # 燃气表(m³,每
|
|||||||
## 8. 待真实数据核对(合同生效后用真实 token / 账单)
|
## 8. 待真实数据核对(合同生效后用真实 token / 账单)
|
||||||
|
|
||||||
1. **真实 token 复核**:跑 §1.4 的 15 分钟 curl,确认 NL 返回**真** 15 分钟价(非重复小时价)+ 币种 EUR。
|
1. **真实 token 复核**:跑 §1.4 的 15 分钟 curl,确认 NL 返回**真** 15 分钟价(非重复小时价)+ 币种 EUR。
|
||||||
2. **卖价残差**:确认 `total` 里 purchase fee 是否被卖侧 sales fee 完全抵掉、回送 VAT 口径 → 调 `sell_adjust`(默认 0)。
|
2. ~~**卖价残差**:确认 `total` 里 purchase fee 是否被卖侧 sales fee 完全抵掉~~ → **已核实(2026-07)**:`total` 含 inkoopvergoeding(0.0248),净计量回送价 = `total − verkoopvergoeding(0.0248)`,两费**不抵消**;代码以 `sell_fee`(默认 0.0248)建模。仍待真实账单核对 `sell_fee` / VAT 口径的最终残差。
|
||||||
3. **双费率寄存器映射**:确认 `_1`=dal/`_2`=normal 没接反(差价小但要对)。
|
3. **双费率寄存器映射**:确认 `_1`=dal/`_2`=normal 没接反(差价小但要对)。
|
||||||
4. **能源税年值**:按当年实际值与年用电档位核 `energy_tax`。
|
4. **能源税年值**:按当年实际值与年用电档位核 `energy_tax`。
|
||||||
5. **固定合同数值**:回送两档价、电网费、heffingskorting 待用户从账单填。
|
5. **固定合同数值**:回送两档价、电网费、heffingskorting 待用户从账单填。
|
||||||
|
|||||||
+26
-1
@@ -288,11 +288,36 @@ httpx / paho-mqtt / pyyaml / apscheduler 均为 M5 已有依赖,M6 复用,
|
|||||||
|
|
||||||
**动机**:浏览器端走 session cookie 即可,但**脚本 / 设备 / 外部程序调用 API** 需要一种长期有效、可随身携带的凭据。在设置页加一组功能,由 admin **手动签发 long-lived token**,之后用它来调 API。
|
**动机**:浏览器端走 session cookie 即可,但**脚本 / 设备 / 外部程序调用 API** 需要一种长期有效、可随身携带的凭据。在设置页加一组功能,由 admin **手动签发 long-lived token**,之后用它来调 API。
|
||||||
|
|
||||||
|
**本次明确的首要目标 = 给现在裸奔的 ingestion 端点上鉴权**(2026-06-27 与用户确认):
|
||||||
|
|
||||||
|
- `POST /location/record`(`app/api/routes/location.py:18`)——位置记录上报。**目前无任何鉴权**。当前数据经 Home Assistant 转发进来,上 token 后 **HA 侧需携带该 token**;也可由其他客户端直接上报。
|
||||||
|
- `POST /poo/record`(`app/api/routes/poo.py:21`)+ `GET /poo/latest`(`poo.py:57`)——小狗排便记录上报 / 最新查询。**目前无任何鉴权**。
|
||||||
|
- 这些是设备 / 脚本(非浏览器)端点,session cookie 不适用,正是 long-lived token 的用武之地。(浏览器 CRUD `/api/data/*` 已由 session 保护,不在此列。)
|
||||||
|
|
||||||
**范围(粗略,待细化)**:
|
**范围(粗略,待细化)**:
|
||||||
|
|
||||||
- 设置页新增「API Token」区:生成 / 命名 / 吊销 long-lived token;明文只在**生成时展示一次**,此后只存哈希。
|
- 设置页新增「API Token」区:生成 / 命名 / 吊销 long-lived token;明文只在**生成时展示一次**,此后只存哈希。
|
||||||
- 后端支持用该 token 鉴权访问 API(与现有 session cookie 并存,互不影响)。
|
- 后端支持用该 token 鉴权访问 API(与现有 session cookie 并存,互不影响);给上述 ingestion 端点加 token 鉴权依赖。
|
||||||
- 与 [M3](#m3--开放与移动端远期试水) 的 token 主题相关,但**这条是 Web 设置页手动签发的 PAT 风格**,不依赖移动端 OAuth 流程;两者实现时可复用同一套 token 存储 / 校验。
|
- 与 [M3](#m3--开放与移动端远期试水) 的 token 主题相关,但**这条是 Web 设置页手动签发的 PAT 风格**,不依赖移动端 OAuth 流程;两者实现时可复用同一套 token 存储 / 校验。
|
||||||
|
- 与下面第 3 条「Session 滑动续期」同属 Authentication 主题(一个是设备/脚本的长期凭据,一个是浏览器短会话体验),实现时鉴权层可一并梳理。
|
||||||
|
|
||||||
|
### 3. Session 滑动自动续期(Authentication)
|
||||||
|
|
||||||
|
**动机**(2026-06-27 与用户确认):当前 session 是**绝对过期**——登录即定死、活动不续期,满 TTL 必须重新登录,体验割裂。希望改成**滑动续期(sliding / rolling)**:只要用户还在活动就自动延长,提供"在用就不掉线"的体验。
|
||||||
|
|
||||||
|
**现状(实现起点,便于快速拾起)**:
|
||||||
|
|
||||||
|
- TTL 默认 **12 小时**:`auth_session_ttl_hours`(`app/config.py:38`;配置页 `app/services/config_page.py:45` 可运行时改)。
|
||||||
|
- 登录时**一次性写死**:`create_session` 设 `expires_at = now + ttl`(`app/services/auth.py:94`)+ cookie `max_age = ttl`(`app/api/routes/api/session.py:153`)。
|
||||||
|
- 每请求**只读校验、从不延长**:`get_authenticated_session`(`app/services/auth.py:103`)只判断 `expires_at <= now`,过期时仅顺手标 `revoked`。`set_cookie` 只在登录路由调用一次,**无 per-request 中间件**。→ 所以是绝对过期,不是滑动。
|
||||||
|
|
||||||
|
**设计要点(待写设计文档时展开)**:
|
||||||
|
|
||||||
|
- 校验通过时 bump `expires_at = now + ttl` 并**重发 cookie**(滑动窗口)。
|
||||||
|
- **写节流**:不要每个请求都写 DB——仅当剩余寿命已过半(或距上次续期 > N 分钟)才续期,避免高频写放大。
|
||||||
|
- **绝对寿命硬顶**:除滑动 TTL 外再设 `created_at + max_lifetime` 上限,防止"永不过期"的会话(安全考量)。
|
||||||
|
- 新增配置项:滑动 TTL、绝对寿命上限、续期节流阈值。
|
||||||
|
- 注意:改动只对**新逻辑生效**,已存在 session 的 `expires_at` 行为按新校验路径走即可;上线前过校验闸门。
|
||||||
|
|
||||||
## Future Ideas(暂不排期,想到先记下)
|
## Future Ideas(暂不排期,想到先记下)
|
||||||
|
|
||||||
|
|||||||
Vendored
+4
-3
@@ -591,9 +591,10 @@ export interface paths {
|
|||||||
*
|
*
|
||||||
* **Retroactive recompute**: if ``started_at`` is in the past, billing
|
* **Retroactive recompute**: if ``started_at`` is in the past, billing
|
||||||
* records from that point forward are re-judged via ``recompute_range`` to
|
* records from that point forward are re-judged via ``recompute_range`` to
|
||||||
* reflect the new meter attribution. The response includes the count of
|
* reflect the new meter attribution. The recompute is transparent — the
|
||||||
* recomputed periods in ``recomputed_periods`` (not part of ``MeterResponse``
|
* response body is the created meter (``MeterResponse``) only and does **not**
|
||||||
* — the recompute is transparent; callers should re-fetch costs if needed).
|
* include a recompute count; callers should re-fetch costs if they need the
|
||||||
|
* updated totals.
|
||||||
*/
|
*/
|
||||||
post: operations["declare_energy_meter_api_energy_meters_post"];
|
post: operations["declare_energy_meter_api_energy_meters_post"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
|
|||||||
@@ -258,6 +258,95 @@ describe('MeterManager — declare new meter', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('MeterManager — edit meter date initialisation', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('initialises date input from started_at (Z-suffix, UTC midnight → local date)', async () => {
|
||||||
|
// ACTIVE_METER.started_at = '2024-01-15T00:00:00Z' (UTC midnight).
|
||||||
|
// The test suite is pinned to TZ=UTC (via vite.config.ts test.env), so
|
||||||
|
// the local date is deterministically '2024-01-15' on any CI runner.
|
||||||
|
const user = userEvent.setup()
|
||||||
|
mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } })
|
||||||
|
|
||||||
|
renderWithProviders(<MeterManager />)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)).toBeInTheDocument())
|
||||||
|
await user.click(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument())
|
||||||
|
|
||||||
|
const dateInput = screen.getByTestId('edit-meter-started-at') as HTMLInputElement
|
||||||
|
expect(dateInput.value).toBe('2024-01-15')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('initialises date input from started_at (naive, no tz marker)', async () => {
|
||||||
|
// A naive timestamp without timezone marker — parseBackendTimestamp appends 'Z'
|
||||||
|
// so it is treated as UTC. With TZ=UTC (pinned in vite.config.ts), the local date
|
||||||
|
// equals the UTC date exactly.
|
||||||
|
const naiveMeter = { ...ACTIVE_METER, started_at: '2024-03-20T00:00:00' }
|
||||||
|
const user = userEvent.setup()
|
||||||
|
mockGet.mockResolvedValue({ data: { items: [naiveMeter], total: 1 } })
|
||||||
|
|
||||||
|
renderWithProviders(<MeterManager />)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId(`meter-edit-${naiveMeter.id}`)).toBeInTheDocument())
|
||||||
|
await user.click(screen.getByTestId(`meter-edit-${naiveMeter.id}`))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument())
|
||||||
|
|
||||||
|
const dateInput = screen.getByTestId('edit-meter-started-at') as HTMLInputElement
|
||||||
|
expect(dateInput.value).toBe('2024-03-20')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('initialises date input from started_at with explicit UTC offset (+02:00) — regression for old buggy regex', async () => {
|
||||||
|
// The old hand-written regex /[zZ+-]\d*$/ would fail to match '+02:00' (the ':00'
|
||||||
|
// suffix broke the pattern) and would incorrectly append 'Z', producing an Invalid Date.
|
||||||
|
// The new code uses parseBackendTimestamp which uses the correct TZ_MARKER_RE regex
|
||||||
|
// and handles explicit offsets properly.
|
||||||
|
const offsetMeter = { ...ACTIVE_METER, started_at: '2024-01-15T02:00:00+02:00' }
|
||||||
|
// UTC equivalent: 2024-01-15T00:00:00Z → with TZ=UTC (pinned) local date = '2024-01-15'
|
||||||
|
const user = userEvent.setup()
|
||||||
|
mockGet.mockResolvedValue({ data: { items: [offsetMeter], total: 1 } })
|
||||||
|
|
||||||
|
renderWithProviders(<MeterManager />)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId(`meter-edit-${offsetMeter.id}`)).toBeInTheDocument())
|
||||||
|
await user.click(screen.getByTestId(`meter-edit-${offsetMeter.id}`))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument())
|
||||||
|
|
||||||
|
const dateInput = screen.getByTestId('edit-meter-started-at') as HTMLInputElement
|
||||||
|
// Must not be empty (which would indicate Invalid Date from the old buggy path)
|
||||||
|
expect(dateInput.value).not.toBe('')
|
||||||
|
expect(dateInput.value).toBe('2024-01-15')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not include started_at in PATCH body when date is unchanged (round-trip idempotence)', async () => {
|
||||||
|
// Open the edit form and immediately submit without changing any fields except label.
|
||||||
|
// The date should be considered unchanged → no started_at in the PATCH body.
|
||||||
|
const user = userEvent.setup()
|
||||||
|
mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } })
|
||||||
|
mockPatch.mockResolvedValue({ data: { ...ACTIVE_METER, label: 'New label' } })
|
||||||
|
|
||||||
|
renderWithProviders(<MeterManager />)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)).toBeInTheDocument())
|
||||||
|
await user.click(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument())
|
||||||
|
|
||||||
|
// Change only label; leave date untouched
|
||||||
|
const labelInput = screen.getByTestId('edit-meter-label')
|
||||||
|
await user.clear(labelInput)
|
||||||
|
await user.type(labelInput, 'New label')
|
||||||
|
|
||||||
|
await user.click(screen.getByTestId('edit-meter-submit'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPatch).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
const patchBody = mockPatch.mock.calls[0][1].body
|
||||||
|
expect(patchBody).not.toHaveProperty('started_at')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('MeterManager — edit meter', () => {
|
describe('MeterManager — edit meter', () => {
|
||||||
beforeEach(() => vi.clearAllMocks())
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import {
|
|||||||
type MeterReason,
|
type MeterReason,
|
||||||
} from './hooks'
|
} from './hooks'
|
||||||
import { ApiError } from '../api/client'
|
import { ApiError } from '../api/client'
|
||||||
import { formatLocalDate } from '../utils/datetime'
|
import { formatLocalDate, parseBackendTimestamp } from '../utils/datetime'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
@@ -60,6 +60,19 @@ function toLocalMidnightNaive(dateStr: string): string {
|
|||||||
return `${dateStr}T00:00:00`
|
return `${dateStr}T00:00:00`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a Date object as a "YYYY-MM-DD" string using the browser's local timezone.
|
||||||
|
* Used to populate <input type="date"> fields.
|
||||||
|
* Returns '' if the Date is invalid.
|
||||||
|
*/
|
||||||
|
function toLocalDateInputString(d: Date): string {
|
||||||
|
if (isNaN(d.getTime())) return ''
|
||||||
|
const y = d.getFullYear()
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(d.getDate()).padStart(2, '0')
|
||||||
|
return `${y}-${m}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Declare meter form (modal)
|
// Declare meter form (modal)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -204,19 +217,12 @@ interface EditMeterFormProps {
|
|||||||
function EditMeterForm({ meter, onClose, onSaved }: EditMeterFormProps) {
|
function EditMeterForm({ meter, onClose, onSaved }: EditMeterFormProps) {
|
||||||
const [label, setLabel] = useState(meter.label)
|
const [label, setLabel] = useState(meter.label)
|
||||||
const [note, setNote] = useState(meter.note ?? '')
|
const [note, setNote] = useState(meter.note ?? '')
|
||||||
// Convert UTC started_at to a local date string for the <input type="date">.
|
// Convert started_at to a local date string for the <input type="date">.
|
||||||
// We parse as UTC (appending Z if needed) and format to "YYYY-MM-DD" in local tz.
|
// parseBackendTimestamp correctly handles naive strings (no tz marker → treated as UTC,
|
||||||
const initialDateStr = (() => {
|
// matching the backend's storage convention), Z-suffixed strings, and strings with
|
||||||
const iso = meter.started_at.includes('T') && !meter.started_at.match(/[zZ+-]\d*$/)
|
// explicit offsets like +02:00. We then extract the local-timezone date components
|
||||||
? meter.started_at + 'Z'
|
// so the displayed date matches the local wall-clock date of the meter start.
|
||||||
: meter.started_at
|
const initialDateStr = toLocalDateInputString(parseBackendTimestamp(meter.started_at))
|
||||||
const d = new Date(iso)
|
|
||||||
if (isNaN(d.getTime())) return ''
|
|
||||||
const y = d.getFullYear()
|
|
||||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
|
||||||
const day = String(d.getDate()).padStart(2, '0')
|
|
||||||
return `${y}-${m}-${day}`
|
|
||||||
})()
|
|
||||||
const [dateStr, setDateStr] = useState(initialDateStr)
|
const [dateStr, setDateStr] = useState(initialDateStr)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
|||||||
@@ -20,5 +20,13 @@ export default defineConfig({
|
|||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
globals: true,
|
globals: true,
|
||||||
setupFiles: ['./src/test-setup.ts'],
|
setupFiles: ['./src/test-setup.ts'],
|
||||||
|
env: {
|
||||||
|
// Lock the test timezone to UTC so that date-formatting assertions
|
||||||
|
// (which rely on toLocalDateInputString / getFullYear etc.) produce
|
||||||
|
// deterministic results regardless of the CI runner's local timezone.
|
||||||
|
// All fixture timestamps are UTC midnight, so the expected YYYY-MM-DD
|
||||||
|
// values in MeterManager.test.tsx are correct under UTC.
|
||||||
|
TZ: 'UTC',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -701,7 +701,7 @@
|
|||||||
"api-energy"
|
"api-energy"
|
||||||
],
|
],
|
||||||
"summary": "Get Prices",
|
"summary": "Get Prices",
|
||||||
"description": "Return the price curve for the active contract.\n\n**Tibber contracts** (kind=\"tibber\"):\n Fetches ``tibber_price`` rows within ``[start, end]``, ordered ascending\n by ``starts_at``. At most ``limit`` rows are returned (most recent first\n within the window, then reversed to ascending order — identical to the\n modbus readings pattern).\n\n Response ``points`` carries per-slot:\n - ``buy = total`` (Tibber all-inclusive price)\n - ``sell = total − energy_tax − sell_adjust`` (from active version values)\n - ``level`` (Tibber price level, may be null)\n\n ``tariff`` is null.\n\n**Manual contracts** (kind=\"manual\"):\n ``points`` is empty. ``tariff`` carries the four effective prices\n derived using the billing engine formula:\n - ``buy_dal = energy.buy.dal + energy_tax + ode``\n - ``buy_normal = energy.buy.normal + energy_tax + ode``\n - ``sell_dal = energy.sell.dal``\n - ``sell_normal = energy.sell.normal``\n\n**No active contract**: returns kind=null, currency=\"EUR\", points=[], tariff=null (200).",
|
"description": "Return the price curve for the active contract.\n\n**Tibber contracts** (kind=\"tibber\"):\n Fetches ``tibber_price`` rows within ``[start, end]``, ordered ascending\n by ``starts_at``. At most ``limit`` rows are returned (most recent first\n within the window, then reversed to ascending order — identical to the\n modbus readings pattern).\n\n Response ``points`` carries per-slot:\n - ``buy = total`` (Tibber all-inclusive price)\n - ``sell = total − energy_tax − sell_fee − sell_adjust`` (from active version values)\n - ``level`` (Tibber price level, may be null)\n\n ``tariff`` is null.\n\n**Manual contracts** (kind=\"manual\"):\n ``points`` is empty. ``tariff`` carries the four effective prices\n derived using the billing engine formula:\n - ``buy_dal = energy.buy.dal + energy_tax + ode``\n - ``buy_normal = energy.buy.normal + energy_tax + ode``\n - ``sell_dal = energy.sell.dal``\n - ``sell_normal = energy.sell.normal``\n\n**No active contract**: returns kind=null, currency=\"EUR\", points=[], tariff=null (200).",
|
||||||
"operationId": "get_prices_api_energy_prices_get",
|
"operationId": "get_prices_api_energy_prices_get",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@@ -1405,7 +1405,7 @@
|
|||||||
"api-energy-meters"
|
"api-energy-meters"
|
||||||
],
|
],
|
||||||
"summary": "Declare Energy Meter",
|
"summary": "Declare Energy Meter",
|
||||||
"description": "Declare a new meter epoch (swap, home move, or initial declaration).\n\nCloses the current active meter for the given commodity at ``started_at``\nand opens a new active meter. If no active meter exists, the new meter is\nsimply created without closing anything.\n\n**Validation**: ``started_at`` must be **≥** the current active meter's\nown ``started_at`` (no chronological backdate below the active epoch's\nstart). Equal timestamps are allowed (replaces the current meter at the\nsame logical moment). Violation → 422.\n\n**Retroactive recompute**: if ``started_at`` is in the past, billing\nrecords from that point forward are re-judged via ``recompute_range`` to\nreflect the new meter attribution. The response includes the count of\nrecomputed periods in ``recomputed_periods`` (not part of ``MeterResponse``\n— the recompute is transparent; callers should re-fetch costs if needed).",
|
"description": "Declare a new meter epoch (swap, home move, or initial declaration).\n\nCloses the current active meter for the given commodity at ``started_at``\nand opens a new active meter. If no active meter exists, the new meter is\nsimply created without closing anything.\n\n**Validation**: ``started_at`` must be **≥** the current active meter's\nown ``started_at`` (no chronological backdate below the active epoch's\nstart). Equal timestamps are allowed (replaces the current meter at the\nsame logical moment). Violation → 422.\n\n**Retroactive recompute**: if ``started_at`` is in the past, billing\nrecords from that point forward are re-judged via ``recompute_range`` to\nreflect the new meter attribution. The recompute is transparent — the\nresponse body is the created meter (``MeterResponse``) only and does **not**\ninclude a recompute count; callers should re-fetch costs if they need the\nupdated totals.",
|
||||||
"operationId": "declare_energy_meter_api_energy_meters_post",
|
"operationId": "declare_energy_meter_api_energy_meters_post",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -527,9 +527,9 @@ paths:
|
|||||||
\ (most recent first\n within the window, then reversed to ascending order\
|
\ (most recent first\n within the window, then reversed to ascending order\
|
||||||
\ — identical to the\n modbus readings pattern).\n\n Response ``points``\
|
\ — identical to the\n modbus readings pattern).\n\n Response ``points``\
|
||||||
\ carries per-slot:\n - ``buy = total`` (Tibber all-inclusive\
|
\ carries per-slot:\n - ``buy = total`` (Tibber all-inclusive\
|
||||||
\ price)\n - ``sell = total − energy_tax − sell_adjust`` (from active\
|
\ price)\n - ``sell = total − energy_tax − sell_fee − sell_adjust`` (from\
|
||||||
\ version values)\n - ``level`` (Tibber price level,\
|
\ active version values)\n - ``level`` (Tibber price\
|
||||||
\ may be null)\n\n ``tariff`` is null.\n\n**Manual contracts** (kind=\"\
|
\ level, may be null)\n\n ``tariff`` is null.\n\n**Manual contracts** (kind=\"\
|
||||||
manual\"):\n ``points`` is empty. ``tariff`` carries the four effective\
|
manual\"):\n ``points`` is empty. ``tariff`` carries the four effective\
|
||||||
\ prices\n derived using the billing engine formula:\n - ``buy_dal \
|
\ prices\n derived using the billing engine formula:\n - ``buy_dal \
|
||||||
\ = energy.buy.dal + energy_tax + ode``\n - ``buy_normal = energy.buy.normal\
|
\ = energy.buy.dal + energy_tax + ode``\n - ``buy_normal = energy.buy.normal\
|
||||||
@@ -1125,11 +1125,13 @@ paths:
|
|||||||
|
|
||||||
records from that point forward are re-judged via ``recompute_range`` to
|
records from that point forward are re-judged via ``recompute_range`` to
|
||||||
|
|
||||||
reflect the new meter attribution. The response includes the count of
|
reflect the new meter attribution. The recompute is transparent — the
|
||||||
|
|
||||||
recomputed periods in ``recomputed_periods`` (not part of ``MeterResponse``
|
response body is the created meter (``MeterResponse``) only and does **not**
|
||||||
|
|
||||||
— the recompute is transparent; callers should re-fetch costs if needed).'
|
include a recompute count; callers should re-fetch costs if they need the
|
||||||
|
|
||||||
|
updated totals.'
|
||||||
operationId: declare_energy_meter_api_energy_meters_post
|
operationId: declare_energy_meter_api_energy_meters_post
|
||||||
parameters:
|
parameters:
|
||||||
- name: X-CSRF-Token
|
- name: X-CSRF-Token
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ if str(PROJECT_ROOT) not in sys.path:
|
|||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
|
||||||
APP_BASELINE_REVISION = "20260625_13_meter_table"
|
APP_BASELINE_REVISION = "20260625_14_meter_uuid"
|
||||||
|
|
||||||
|
|
||||||
class AppDatabaseAdoptionError(RuntimeError):
|
class AppDatabaseAdoptionError(RuntimeError):
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ def cmd_read(args: argparse.Namespace) -> None:
|
|||||||
|
|
||||||
print(f"Profile : {profile.name} — {profile.description}")
|
print(f"Profile : {profile.name} — {profile.description}")
|
||||||
print(f"Gateway : {host}:{port} unit_id={unit_id}")
|
print(f"Gateway : {host}:{port} unit_id={unit_id}")
|
||||||
|
print(f"Function : FC{profile.function_code:02d}")
|
||||||
print(f"Blocks : {[(b.start, b.count) for b in profile.blocks]}")
|
print(f"Blocks : {[(b.start, b.count) for b in profile.blocks]}")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
@@ -141,7 +142,7 @@ def cmd_read(args: argparse.Namespace) -> None:
|
|||||||
from app.integrations.modbus.driver import read_blocks
|
from app.integrations.modbus.driver import read_blocks
|
||||||
|
|
||||||
try:
|
try:
|
||||||
registers = read_blocks(host, port, unit_id, blocks)
|
registers = read_blocks(host, port, unit_id, blocks, function_code=profile.function_code)
|
||||||
except ModbusConnectionError as exc:
|
except ModbusConnectionError as exc:
|
||||||
print(f"Connection error: {exc}", file=sys.stderr)
|
print(f"Connection error: {exc}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
@@ -363,13 +363,65 @@ def test_prices_tibber_contract_returns_points(energy_client):
|
|||||||
starts_at_list = [p["starts_at"] for p in body["points"]]
|
starts_at_list = [p["starts_at"] for p in body["points"]]
|
||||||
assert starts_at_list == sorted(starts_at_list)
|
assert starts_at_list == sorted(starts_at_list)
|
||||||
|
|
||||||
# Check buy/sell calculations: buy=total=0.245, sell=total-energy_tax-sell_adjust=0.245-0.1108-0.0
|
# Check buy/sell calculations: buy=total=0.245, sell=total-energy_tax-sell_fee-sell_adjust
|
||||||
|
# (this version has no sell_fee/sell_adjust → both default to 0 at read time).
|
||||||
for p in body["points"]:
|
for p in body["points"]:
|
||||||
assert abs(p["buy"] - 0.245) < 1e-6
|
assert abs(p["buy"] - 0.245) < 1e-6
|
||||||
assert abs(p["sell"] - (0.245 - 0.1108)) < 1e-4
|
assert abs(p["sell"] - (0.245 - 0.1108)) < 1e-4
|
||||||
assert p["level"] == "NORMAL"
|
assert p["level"] == "NORMAL"
|
||||||
|
|
||||||
|
|
||||||
|
def test_prices_tibber_sell_reflects_sell_fee(energy_client):
|
||||||
|
"""/prices sell price deducts sell_fee (verkoopvergoeding), net-metering config."""
|
||||||
|
client, engine, _app = energy_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
# Net-metering version: sell_adjust = −energy_tax (refund tax), sell_fee = 0.0248.
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
with Session(engine) as session:
|
||||||
|
contract = EnergyContract(
|
||||||
|
name="Tibber NetMeter",
|
||||||
|
kind="tibber",
|
||||||
|
active=True,
|
||||||
|
currency="EUR",
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
session.add(contract)
|
||||||
|
session.flush()
|
||||||
|
session.add(
|
||||||
|
EnergyContractVersion(
|
||||||
|
contract_id=contract.id,
|
||||||
|
effective_from=now - timedelta(days=30),
|
||||||
|
effective_to=None,
|
||||||
|
values={
|
||||||
|
"energy": {
|
||||||
|
"energy_tax": 0.1108,
|
||||||
|
"sell_fee": 0.0248,
|
||||||
|
"sell_adjust": -0.1108,
|
||||||
|
},
|
||||||
|
"standing": {"management_fee": 5.99, "network_fee": 25.0},
|
||||||
|
"credits": {"heffingskorting": 600.0},
|
||||||
|
},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
_make_tibber_prices(engine, count=3)
|
||||||
|
|
||||||
|
start = (datetime.now(UTC) - timedelta(hours=2)).isoformat()
|
||||||
|
end = (datetime.now(UTC) + timedelta(hours=2)).isoformat()
|
||||||
|
resp = client.get("/api/energy/prices", params={"start": start, "end": end})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["kind"] == "tibber"
|
||||||
|
assert len(body["points"]) == 3
|
||||||
|
# sell = 0.245 − 0.1108 − 0.0248 − (−0.1108) = 0.245 − 0.0248 = 0.2202
|
||||||
|
for p in body["points"]:
|
||||||
|
assert abs(p["buy"] - 0.245) < 1e-6
|
||||||
|
assert abs(p["sell"] - 0.2202) < 1e-4
|
||||||
|
|
||||||
|
|
||||||
def test_prices_tibber_limit_caps_results(energy_client):
|
def test_prices_tibber_limit_caps_results(energy_client):
|
||||||
client, engine, _app = energy_client
|
client, engine, _app = energy_client
|
||||||
_login(client)
|
_login(client)
|
||||||
|
|||||||
@@ -72,6 +72,24 @@ def _declare_payload(**overrides) -> dict:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def mock_publish_discovery():
|
||||||
|
"""Auto-mock publish_discovery for all tests in this module.
|
||||||
|
|
||||||
|
The meters API now calls _trigger_discovery_republish (best-effort) after
|
||||||
|
every successful write. publish_discovery is lazy-imported inside that
|
||||||
|
helper, so we patch it at its canonical source path
|
||||||
|
(app.services.ha_discovery.publish_discovery). Tests that need to assert
|
||||||
|
the call receive this fixture explicitly; all others benefit from the
|
||||||
|
isolation it provides (no live MQTT broker required).
|
||||||
|
"""
|
||||||
|
with patch(
|
||||||
|
"app.services.ha_discovery.publish_discovery",
|
||||||
|
return_value=None,
|
||||||
|
) as mock:
|
||||||
|
yield mock
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def meters_client(auth_database):
|
def meters_client(auth_database):
|
||||||
"""TestClient + SQLAlchemy engine for Meter API tests."""
|
"""TestClient + SQLAlchemy engine for Meter API tests."""
|
||||||
@@ -543,7 +561,7 @@ def test_patch_meter_no_recompute_when_started_at_not_changed(meters_client):
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Timeline continuity (integration: no recompute mock)
|
# Timeline continuity (recompute mocked to avoid slow computation over empty quarters)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -615,7 +633,7 @@ def test_declare_meter_invalid_reason_returns_422(meters_client):
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Retroactive recompute: integration (no mock) — window coverage check
|
# Retroactive recompute & boundary update (recompute mocked) — window coverage check
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -660,3 +678,78 @@ def test_patch_started_at_earlier_updates_boundary(meters_client):
|
|||||||
if ended is not None and ended.tzinfo is None:
|
if ended is not None and ended.tzinfo is None:
|
||||||
ended = ended.replace(tzinfo=UTC)
|
ended = ended.replace(tzinfo=UTC)
|
||||||
assert ended == t1_earlier
|
assert ended == t1_earlier
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# FUE-T06: HA discovery re-publish triggered after meter writes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_declare_meter_triggers_publish_discovery(meters_client, mock_publish_discovery):
|
||||||
|
"""POST /api/energy/meters triggers publish_discovery after successful commit."""
|
||||||
|
client, _ = meters_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
|
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/energy/meters",
|
||||||
|
json=_declare_payload(label="Discovery Meter", started_at=t0.isoformat()),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
# publish_discovery must have been called exactly once after the declare.
|
||||||
|
mock_publish_discovery.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_meter_triggers_publish_discovery(meters_client, mock_publish_discovery):
|
||||||
|
"""PATCH /api/energy/meters/{id} triggers publish_discovery after successful commit."""
|
||||||
|
client, _ = meters_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
|
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/energy/meters",
|
||||||
|
json=_declare_payload(label="Original Label", started_at=t0.isoformat()),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
meter_id = resp.json()["id"]
|
||||||
|
# Reset call count: the POST above also called publish_discovery.
|
||||||
|
mock_publish_discovery.reset_mock()
|
||||||
|
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/energy/meters/{meter_id}",
|
||||||
|
json={"label": "Renamed Label"},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
# publish_discovery must have been called exactly once after the PATCH.
|
||||||
|
mock_publish_discovery.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_declare_meter_succeeds_when_publish_discovery_raises(meters_client):
|
||||||
|
"""publish_discovery raising an exception must NOT cause POST declare to return 500.
|
||||||
|
|
||||||
|
The _trigger_discovery_republish helper is best-effort: it swallows all
|
||||||
|
exceptions so that a broken MQTT / discovery layer never breaks the API.
|
||||||
|
"""
|
||||||
|
client, _ = meters_client
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
|
with (
|
||||||
|
patch("app.api.routes.api.meters.recompute_range", return_value=0),
|
||||||
|
patch(
|
||||||
|
"app.services.ha_discovery.publish_discovery",
|
||||||
|
side_effect=RuntimeError("MQTT broker unreachable"),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/energy/meters",
|
||||||
|
json=_declare_payload(label="Best Effort Meter", started_at=t0.isoformat()),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
# The meter must be created successfully despite the discovery failure.
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert resp.json()["label"] == "Best Effort Meter"
|
||||||
|
|||||||
+491
-39
@@ -996,35 +996,52 @@ class TestSummarize:
|
|||||||
assert result["period_count"] == 2
|
assert result["period_count"] == 2
|
||||||
assert result["degraded_count"] == 0
|
assert result["degraded_count"] == 0
|
||||||
|
|
||||||
def test_fixed_costs_zero_for_sub_day_window(self, energy_db: Session) -> None:
|
def test_fixed_costs_one_day_for_sub_day_window(self, energy_db: Session) -> None:
|
||||||
"""Principle C: a 30-minute window within the same local day counts 0 whole days.
|
"""FUE-T01 (symmetric begin/end): a 30-minute window within one local day counts 1 day.
|
||||||
|
|
||||||
Fixed costs are only added for elapsed whole local calendar days.
|
Under the symmetric-begin/end fix, fixed charges are assessed on a
|
||||||
A window [10:00, 10:30) UTC stays within the same local calendar date
|
"service-is-active" basis: if the window falls within a local calendar day,
|
||||||
regardless of timezone offset (any UTC-11..UTC+14 range), so no whole
|
that entire day's charge applies. The begin side no longer requires a local
|
||||||
day is covered → fixed_costs = 0.
|
midnight to fall *inside* the window — the local date of start_utc itself is
|
||||||
|
always counted as the first day.
|
||||||
|
|
||||||
|
Window [10:00, 10:30) UTC in Europe/Amsterdam (CEST = UTC+2):
|
||||||
|
local date of start: June 23 (12:00 CEST)
|
||||||
|
local date of end: June 23 (12:30 CEST)
|
||||||
|
→ first_counted = last_counted = June 23 → 1 day
|
||||||
|
→ fixed_costs = (9.87 + 9.87) / 30 × 1
|
||||||
"""
|
"""
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
self._setup_two_periods(energy_db)
|
self._setup_two_periods(energy_db)
|
||||||
# Pin the local timezone so the test is deterministic on any CI host.
|
# Pin the local timezone so the test is deterministic on any CI host.
|
||||||
with __import__("unittest.mock", fromlist=["patch"]).patch.object(
|
with patch.object(tz_module, "local_tz", return_value=ZoneInfo("Europe/Amsterdam")):
|
||||||
tz_module, "local_tz", return_value=ZoneInfo("Europe/Amsterdam")
|
|
||||||
):
|
|
||||||
result = summarize(energy_db, _ts(10, 0), _ts(10, 30))
|
result = summarize(energy_db, _ts(10, 0), _ts(10, 30))
|
||||||
assert result["fixed_costs"] == 0.0, (
|
|
||||||
"Sub-day window should contribute 0 whole-day fixed costs under Principle C"
|
expected_fixed = (9.87 + 9.87) / 30 # 1 day
|
||||||
|
assert abs(result["fixed_costs"] - expected_fixed) < 1e-9, (
|
||||||
|
f"FUE-T01: sub-day window in one local day should count 1 day of fixed costs; "
|
||||||
|
f"expected {expected_fixed:.6f}, got {result['fixed_costs']}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_credits_zero_for_sub_day_window(self, energy_db: Session) -> None:
|
def test_credits_one_day_for_sub_day_window(self, energy_db: Session) -> None:
|
||||||
"""Principle C: a 30-minute window within the same local day counts 0 whole days for credits."""
|
"""FUE-T01 (symmetric begin/end): a 30-minute window within one local day counts 1 day of credits.
|
||||||
|
|
||||||
|
Same rationale as test_fixed_costs_one_day_for_sub_day_window.
|
||||||
|
credits = 600.0 / 365 × 1 day.
|
||||||
|
"""
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
self._setup_two_periods(energy_db)
|
self._setup_two_periods(energy_db)
|
||||||
with __import__("unittest.mock", fromlist=["patch"]).patch.object(
|
with patch.object(tz_module, "local_tz", return_value=ZoneInfo("Europe/Amsterdam")):
|
||||||
tz_module, "local_tz", return_value=ZoneInfo("Europe/Amsterdam")
|
|
||||||
):
|
|
||||||
result = summarize(energy_db, _ts(10, 0), _ts(10, 30))
|
result = summarize(energy_db, _ts(10, 0), _ts(10, 30))
|
||||||
assert result["credits"] == 0.0, (
|
|
||||||
"Sub-day window should contribute 0 whole-day credits under Principle C"
|
expected_credits = 600.0 / 365 # 1 day
|
||||||
|
assert abs(result["credits"] - expected_credits) < 1e-9, (
|
||||||
|
f"FUE-T01: sub-day window in one local day should count 1 day of credits; "
|
||||||
|
f"expected {expected_credits:.6f}, got {result['credits']}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_total_payable_formula(self, energy_db: Session) -> None:
|
def test_total_payable_formula(self, energy_db: Session) -> None:
|
||||||
@@ -1068,12 +1085,20 @@ class TestSummarize:
|
|||||||
assert result["period_count"] == 0
|
assert result["period_count"] == 0
|
||||||
|
|
||||||
def test_one_day_summarize_hand_calc(self, energy_db: Session) -> None:
|
def test_one_day_summarize_hand_calc(self, energy_db: Session) -> None:
|
||||||
"""Full 1-day hand-calculation: fixed_costs/30 and credits/365 with 1-day interval.
|
"""Full 1-day hand-calculation: fixed_costs/30 and credits/365 with 1-day UTC interval.
|
||||||
|
|
||||||
Principle C: the window [2026-06-23 00:00 UTC, 2026-06-24 00:00 UTC) spans
|
FUE-T01 (symmetric begin/end): the window [2026-06-23 00:00 UTC, 2026-06-24 00:00 UTC)
|
||||||
exactly one local calendar day in Europe/Amsterdam (CEST=UTC+2: 02:00→02:00).
|
in Europe/Amsterdam (CEST=UTC+2):
|
||||||
effective_from = June 23 00:00 UTC = June 23 02:00 CEST = local date June 23.
|
- local date of start (June 23 00:00 UTC = June 23 02:00 CEST) = June 23
|
||||||
Today (2026-06-25) ≥ June 23, so the day is elapsed → 1 day counted.
|
→ first_counted = June 23 (anchor day always counted)
|
||||||
|
- local midnight of June 24 = June 23 22:00 UTC, which is < end_utc (June 24 00:00 UTC)
|
||||||
|
→ last_counted = June 24
|
||||||
|
- today (2026-06-25) ≥ June 24 → cap doesn't apply
|
||||||
|
→ 2 local calendar days counted (June 23 + June 24)
|
||||||
|
|
||||||
|
Note: a 1-day UTC window centered at non-midnight UTC spans **two** local days
|
||||||
|
in Amsterdam (CEST=UTC+2) because local midnight (June 23 22:00 UTC) falls
|
||||||
|
inside the window. This is correct under the symmetric begin/end principle.
|
||||||
"""
|
"""
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
@@ -1091,12 +1116,18 @@ class TestSummarize:
|
|||||||
with patch.object(tz_module, "local_tz", return_value=ZoneInfo("Europe/Amsterdam")):
|
with patch.object(tz_module, "local_tz", return_value=ZoneInfo("Europe/Amsterdam")):
|
||||||
result = summarize(energy_db, start, end)
|
result = summarize(energy_db, start, end)
|
||||||
|
|
||||||
# fixed_costs = (9.87 + 9.87) / 30 × 1.0 = 0.658
|
# 2 local days (June 23 start day + June 24 because local midnight of June 24
|
||||||
assert abs(result["fixed_costs"] - (9.87 + 9.87) / 30) < 1e-9
|
# = June 23 22:00 UTC falls within [start, end)).
|
||||||
# credits = 600 / 365
|
n_days = 2
|
||||||
assert abs(result["credits"] - 600.0 / 365) < 1e-9
|
assert abs(result["fixed_costs"] - (9.87 + 9.87) / 30 * n_days) < 1e-9, (
|
||||||
# total = 0 + 0.658 − (600/365)
|
f"FUE-T01: 1-day UTC window = 2 local days in Amsterdam; "
|
||||||
expected_total = (9.87 + 9.87) / 30 - 600.0 / 365
|
f"expected fixed={((9.87 + 9.87) / 30 * n_days):.6f}, got {result['fixed_costs']}"
|
||||||
|
)
|
||||||
|
assert abs(result["credits"] - 600.0 / 365 * n_days) < 1e-9, (
|
||||||
|
f"FUE-T01: expected credits={600.0 / 365 * n_days:.6f}, got {result['credits']}"
|
||||||
|
)
|
||||||
|
# total = 0 + fixed − credits
|
||||||
|
expected_total = (9.87 + 9.87) / 30 * n_days - 600.0 / 365 * n_days
|
||||||
assert abs(result["total_payable"] - expected_total) < 1e-9
|
assert abs(result["total_payable"] - expected_total) < 1e-9
|
||||||
|
|
||||||
|
|
||||||
@@ -1168,10 +1199,16 @@ def _ams_midnight(year: int, month: int, day: int) -> datetime:
|
|||||||
|
|
||||||
|
|
||||||
class TestSummarizePrincipleC:
|
class TestSummarizePrincipleC:
|
||||||
"""Principle C whole-day counting with pinned Europe/Amsterdam timezone."""
|
"""Principle C whole-day counting with pinned Europe/Amsterdam timezone.
|
||||||
|
|
||||||
|
Tests that assert a specific "today" (e.g. June 25) must also pin
|
||||||
|
``local_now`` via *pinned_now* to remain deterministic as wall-clock
|
||||||
|
time advances. Tests that only care about windows entirely in the past
|
||||||
|
or entirely in the future do not need to pin ``local_now``.
|
||||||
|
"""
|
||||||
|
|
||||||
# Effective_from: June 1 CEST local midnight = May 31 22:00 UTC.
|
# Effective_from: June 1 CEST local midnight = May 31 22:00 UTC.
|
||||||
# Today local = June 25 CEST (since today is 2026-06-25).
|
# Reference "today" pinned in individual tests = June 25 CEST.
|
||||||
|
|
||||||
def _make_single_version_contract(self, session: Session, *, effective_from_utc: datetime) -> None:
|
def _make_single_version_contract(self, session: Session, *, effective_from_utc: datetime) -> None:
|
||||||
"""Create an active manual contract with a single version."""
|
"""Create an active manual contract with a single version."""
|
||||||
@@ -1179,10 +1216,27 @@ class TestSummarizePrincipleC:
|
|||||||
_make_version(session, contract, _MANUAL_VALUES, effective_from=effective_from_utc)
|
_make_version(session, contract, _MANUAL_VALUES, effective_from=effective_from_utc)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
def _run_summarize_ams(self, session: Session, start_utc: datetime, end_utc: datetime) -> dict:
|
def _run_summarize_ams(
|
||||||
"""Run summarize with Europe/Amsterdam local_tz monkeypatched."""
|
self,
|
||||||
|
session: Session,
|
||||||
|
start_utc: datetime,
|
||||||
|
end_utc: datetime,
|
||||||
|
*,
|
||||||
|
pinned_now: datetime | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Run summarize with Europe/Amsterdam local_tz monkeypatched.
|
||||||
|
|
||||||
|
If *pinned_now* is given, also patches ``app.services.energy_cost.local_now``
|
||||||
|
to that fixed value, making settlement-offset logic deterministic
|
||||||
|
regardless of wall-clock time.
|
||||||
|
"""
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
import app.services.energy_cost as _ec
|
||||||
|
|
||||||
with patch.object(tz_module, "local_tz", return_value=_ams()):
|
with patch.object(tz_module, "local_tz", return_value=_ams()):
|
||||||
|
if pinned_now is not None:
|
||||||
|
with patch.object(_ec, "local_now", return_value=pinned_now):
|
||||||
|
return summarize(session, start_utc, end_utc)
|
||||||
return summarize(session, start_utc, end_utc)
|
return summarize(session, start_utc, end_utc)
|
||||||
|
|
||||||
def test_today_window_counts_1_day(self, energy_db: Session) -> None:
|
def test_today_window_counts_1_day(self, energy_db: Session) -> None:
|
||||||
@@ -1209,6 +1263,9 @@ class TestSummarizePrincipleC:
|
|||||||
def test_future_window_counts_0_days(self, energy_db: Session) -> None:
|
def test_future_window_counts_0_days(self, energy_db: Session) -> None:
|
||||||
"""A fully future window (all local dates > today) counts 0 days.
|
"""A fully future window (all local dates > today) counts 0 days.
|
||||||
|
|
||||||
|
Pins ``local_now`` to June 25 2026 noon AMS so the 7/1→8/1 window is
|
||||||
|
genuinely in the future regardless of the actual wall-clock date
|
||||||
|
(mirrors the sibling window tests, which all pin ``local_now``).
|
||||||
Matches table row: 7/1→8/1 (all future) → 0 days.
|
Matches table row: 7/1→8/1 (all future) → 0 days.
|
||||||
"""
|
"""
|
||||||
eff_utc = _ams_midnight(2026, 6, 1)
|
eff_utc = _ams_midnight(2026, 6, 1)
|
||||||
@@ -1216,7 +1273,9 @@ class TestSummarizePrincipleC:
|
|||||||
|
|
||||||
start = _ams_midnight(2026, 7, 1)
|
start = _ams_midnight(2026, 7, 1)
|
||||||
end = _ams_midnight(2026, 8, 1)
|
end = _ams_midnight(2026, 8, 1)
|
||||||
result = self._run_summarize_ams(energy_db, start, end)
|
# Pin local_now to June 25 2026 noon AMS so 7/1→8/1 stays fully future.
|
||||||
|
pinned_now = datetime(2026, 6, 25, 12, 0, 0, tzinfo=_ams())
|
||||||
|
result = self._run_summarize_ams(energy_db, start, end, pinned_now=pinned_now)
|
||||||
|
|
||||||
assert result["fixed_costs"] == 0.0, (
|
assert result["fixed_costs"] == 0.0, (
|
||||||
f"All-future window must count 0 days; got fixed_costs={result['fixed_costs']}"
|
f"All-future window must count 0 days; got fixed_costs={result['fixed_costs']}"
|
||||||
@@ -1228,7 +1287,10 @@ class TestSummarizePrincipleC:
|
|||||||
def test_this_month_window_counts_25_days(self, energy_db: Session) -> None:
|
def test_this_month_window_counts_25_days(self, energy_db: Session) -> None:
|
||||||
"""This Month (June 1 → July 1 local) counts 25 elapsed days (June 1..25).
|
"""This Month (June 1 → July 1 local) counts 25 elapsed days (June 1..25).
|
||||||
|
|
||||||
Today = June 25 local, so June 26–30 are not yet elapsed.
|
Pins ``local_now`` to June 25 noon AMS so the test is deterministic
|
||||||
|
regardless of the actual wall-clock date. June 25 is well past the
|
||||||
|
01:05 settlement offset, so ``settled_cap = June 25``.
|
||||||
|
Today = June 25 local, so June 26–30 are not yet elapsed → 25 days.
|
||||||
Matches table row: 6/1→7/1 (This Month) → 25 days.
|
Matches table row: 6/1→7/1 (This Month) → 25 days.
|
||||||
"""
|
"""
|
||||||
eff_utc = _ams_midnight(2026, 6, 1)
|
eff_utc = _ams_midnight(2026, 6, 1)
|
||||||
@@ -1236,7 +1298,9 @@ class TestSummarizePrincipleC:
|
|||||||
|
|
||||||
start = _ams_midnight(2026, 6, 1)
|
start = _ams_midnight(2026, 6, 1)
|
||||||
end = _ams_midnight(2026, 7, 1)
|
end = _ams_midnight(2026, 7, 1)
|
||||||
result = self._run_summarize_ams(energy_db, start, end)
|
# Pin local_now to June 25 2026 noon AMS (well past 01:05 settlement).
|
||||||
|
pinned_now = datetime(2026, 6, 25, 12, 0, 0, tzinfo=_ams())
|
||||||
|
result = self._run_summarize_ams(energy_db, start, end, pinned_now=pinned_now)
|
||||||
|
|
||||||
daily_fixed = (9.87 + 9.87) / 30
|
daily_fixed = (9.87 + 9.87) / 30
|
||||||
daily_credit = 600.0 / 365
|
daily_credit = 600.0 / 365
|
||||||
@@ -1273,8 +1337,9 @@ class TestSummarizePrincipleC:
|
|||||||
def test_partial_future_window_caps_at_today(self, energy_db: Session) -> None:
|
def test_partial_future_window_caps_at_today(self, energy_db: Session) -> None:
|
||||||
"""A window partially in the future caps at today (only elapsed days counted).
|
"""A window partially in the future caps at today (only elapsed days counted).
|
||||||
|
|
||||||
|
Pins ``local_now`` to June 25 noon AMS so the test is deterministic.
|
||||||
Window: June 25 → June 28 local (4 dates, but June 26/27 are future).
|
Window: June 25 → June 28 local (4 dates, but June 26/27 are future).
|
||||||
Today = June 25 → only 1 day elapsed (June 25).
|
Today = June 25 → settled_cap = June 25 → only 1 day elapsed (June 25).
|
||||||
Matches table row: 6/25→6/28 → 1 day.
|
Matches table row: 6/25→6/28 → 1 day.
|
||||||
"""
|
"""
|
||||||
eff_utc = _ams_midnight(2026, 6, 1)
|
eff_utc = _ams_midnight(2026, 6, 1)
|
||||||
@@ -1282,7 +1347,9 @@ class TestSummarizePrincipleC:
|
|||||||
|
|
||||||
start = _ams_midnight(2026, 6, 25)
|
start = _ams_midnight(2026, 6, 25)
|
||||||
end = _ams_midnight(2026, 6, 28)
|
end = _ams_midnight(2026, 6, 28)
|
||||||
result = self._run_summarize_ams(energy_db, start, end)
|
# Pin local_now to June 25 2026 noon AMS (well past 01:05 settlement).
|
||||||
|
pinned_now = datetime(2026, 6, 25, 12, 0, 0, tzinfo=_ams())
|
||||||
|
result = self._run_summarize_ams(energy_db, start, end, pinned_now=pinned_now)
|
||||||
|
|
||||||
daily_fixed = (9.87 + 9.87) / 30
|
daily_fixed = (9.87 + 9.87) / 30
|
||||||
daily_credit = 600.0 / 365
|
daily_credit = 600.0 / 365
|
||||||
@@ -1335,7 +1402,11 @@ class TestSummarizePrincipleC:
|
|||||||
start = _ams_midnight(2026, 6, 1)
|
start = _ams_midnight(2026, 6, 1)
|
||||||
end = _ams_midnight(2026, 7, 1)
|
end = _ams_midnight(2026, 7, 1)
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
import app.services.energy_cost as _ec
|
||||||
|
# Pin local_now to June 25 2026 noon AMS: today=June 25, settled_cap=June 25.
|
||||||
|
pinned_now = datetime(2026, 6, 25, 12, 0, 0, tzinfo=_ams())
|
||||||
with patch.object(tz_module, "local_tz", return_value=_ams()):
|
with patch.object(tz_module, "local_tz", return_value=_ams()):
|
||||||
|
with patch.object(_ec, "local_now", return_value=pinned_now):
|
||||||
result = summarize(energy_db, start, end)
|
result = summarize(energy_db, start, end)
|
||||||
|
|
||||||
# V1: 24 days × (6+6)/30; V2: 1 day × (12+12)/30
|
# V1: 24 days × (6+6)/30; V2: 1 day × (12+12)/30
|
||||||
@@ -1377,11 +1448,15 @@ class TestSummarizePrincipleC:
|
|||||||
_make_version(energy_db, contract, _VALUES_V2, effective_from=v2_from)
|
_make_version(energy_db, contract, _VALUES_V2, effective_from=v2_from)
|
||||||
energy_db.commit()
|
energy_db.commit()
|
||||||
|
|
||||||
# Window = June 25 only (today, 1 day elapsed at V2 rate)
|
# Window = June 25 only (today, 1 day elapsed at V2 rate).
|
||||||
|
# Pin local_now to June 25 noon AMS: today=June 25, settled_cap=June 25.
|
||||||
start = _ams_midnight(2026, 6, 25)
|
start = _ams_midnight(2026, 6, 25)
|
||||||
end = _ams_midnight(2026, 7, 1)
|
end = _ams_midnight(2026, 7, 1)
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
import app.services.energy_cost as _ec
|
||||||
|
pinned_now = datetime(2026, 6, 25, 12, 0, 0, tzinfo=_ams())
|
||||||
with patch.object(tz_module, "local_tz", return_value=_ams()):
|
with patch.object(tz_module, "local_tz", return_value=_ams()):
|
||||||
|
with patch.object(_ec, "local_now", return_value=pinned_now):
|
||||||
result = summarize(energy_db, start, end)
|
result = summarize(energy_db, start, end)
|
||||||
|
|
||||||
# Only 1 day at V2 rate
|
# Only 1 day at V2 rate
|
||||||
@@ -1390,6 +1465,120 @@ class TestSummarizePrincipleC:
|
|||||||
assert abs(result["fixed_costs"] - expected_fixed) < 1e-9
|
assert abs(result["fixed_costs"] - expected_fixed) < 1e-9
|
||||||
assert abs(result["credits"] - expected_credits) < 1e-9
|
assert abs(result["credits"] - expected_credits) < 1e-9
|
||||||
|
|
||||||
|
def test_morning_anchor_first_day_counted(self, energy_db: Session) -> None:
|
||||||
|
"""FUE-T01: anchor falling above local midnight counts its local day as day 1.
|
||||||
|
|
||||||
|
Bug scenario: meter.started_at = June 24 09:18 local time (07:18 UTC, CEST=UTC+2).
|
||||||
|
Old code: local_date(07:18 UTC) = June 24; _lmu(June 24) = June 23 22:00 UTC;
|
||||||
|
June 23 22:00 UTC < June 24 07:18 UTC → False (22:00 < 07:18? No — 22:00 UTC June 23 is
|
||||||
|
*before* 07:18 UTC June 24; so the condition >= start_utc was False);
|
||||||
|
first_counted bumped to June 25. June 24's charges were silently dropped.
|
||||||
|
|
||||||
|
Fix: first_counted = local_date(start_utc) = June 24 always, regardless of where
|
||||||
|
within the day start_utc falls. June 24 is the anchor day → its charge is counted.
|
||||||
|
|
||||||
|
Window: [June 24 07:18 UTC (= 09:18 CEST), June 26 22:00 UTC (= June 27 00:00 CEST)).
|
||||||
|
Pins local_now to June 25 noon AMS → today=June 25, settled_cap=June 25.
|
||||||
|
first_counted = June 24 (anchor day, previously dropped).
|
||||||
|
last_counted = min(June 26, June 25) = June 25.
|
||||||
|
n_days = June 25 - June 24 + 1 = 2.
|
||||||
|
"""
|
||||||
|
eff_utc = _ams_midnight(2026, 6, 1)
|
||||||
|
self._make_single_version_contract(energy_db, effective_from_utc=eff_utc)
|
||||||
|
|
||||||
|
# anchor = June 24 07:18 UTC = June 24 09:18 CEST (above local midnight)
|
||||||
|
anchor_utc = datetime(2026, 6, 24, 7, 18, 0, tzinfo=_UTC)
|
||||||
|
# end = June 26 22:00 UTC = June 27 00:00 CEST (past today June 25, capped)
|
||||||
|
end_utc = datetime(2026, 6, 26, 22, 0, 0, tzinfo=_UTC)
|
||||||
|
|
||||||
|
# Pin local_now to June 25 noon AMS: today=June 25, settled_cap=June 25.
|
||||||
|
pinned_now = datetime(2026, 6, 25, 12, 0, 0, tzinfo=_ams())
|
||||||
|
result = self._run_summarize_ams(energy_db, anchor_utc, end_utc, pinned_now=pinned_now)
|
||||||
|
|
||||||
|
daily_fixed = (9.87 + 9.87) / 30
|
||||||
|
daily_credit = 600.0 / 365
|
||||||
|
# June 24 (anchor day) + June 25 (today) = 2 days
|
||||||
|
assert abs(result["fixed_costs"] - daily_fixed * 2) < 1e-9, (
|
||||||
|
f"FUE-T01: morning anchor should count anchor day + today = 2 days of fixed costs; "
|
||||||
|
f"expected {daily_fixed * 2:.6f}, got {result['fixed_costs']}"
|
||||||
|
)
|
||||||
|
assert abs(result["credits"] - daily_credit * 2) < 1e-9, (
|
||||||
|
f"FUE-T01: morning anchor should count anchor day + today = 2 days of credits; "
|
||||||
|
f"expected {daily_credit * 2:.6f}, got {result['credits']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_daily_window_midnight_aligned_unaffected(self, energy_db: Session) -> None:
|
||||||
|
"""FUE-T01: the daily getter window [today midnight, tomorrow midnight) is not affected.
|
||||||
|
|
||||||
|
The *_today getters use windows exactly aligned to local midnight:
|
||||||
|
start = local_midnight_utc(today) = June 24 22:00 UTC (for June 25 local)
|
||||||
|
end = local_midnight_utc(tomorrow) = June 25 22:00 UTC
|
||||||
|
|
||||||
|
With either old or new logic, first_counted = June 25 (today):
|
||||||
|
- Old: local_date(June 24 22:00 UTC in Amsterdam) = June 25;
|
||||||
|
_lmu(June 25) = June 24 22:00 UTC >= June 24 22:00 UTC → True → first = June 25.
|
||||||
|
- New: first_counted = local_date(June 24 22:00 UTC) = June 25.
|
||||||
|
Both give the same result: 1 day counted.
|
||||||
|
|
||||||
|
This test explicitly verifies daily window behaviour is unchanged.
|
||||||
|
"""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
eff_utc = _ams_midnight(2026, 6, 1)
|
||||||
|
self._make_single_version_contract(energy_db, effective_from_utc=eff_utc)
|
||||||
|
|
||||||
|
# Today's window aligned to local midnight (June 25 CEST = June 24 22:00 UTC).
|
||||||
|
today_start_utc = _ams_midnight(2026, 6, 25) # June 24 22:00 UTC
|
||||||
|
tomorrow_start_utc = _ams_midnight(2026, 6, 26) # June 25 22:00 UTC
|
||||||
|
|
||||||
|
with patch.object(tz_module, "local_tz", return_value=_ams()):
|
||||||
|
result = self._run_summarize_ams(energy_db, today_start_utc, tomorrow_start_utc)
|
||||||
|
|
||||||
|
daily_fixed = (9.87 + 9.87) / 30
|
||||||
|
daily_credit = 600.0 / 365
|
||||||
|
# Exactly 1 day counted (June 25 only; June 26 is future, capped).
|
||||||
|
assert abs(result["fixed_costs"] - daily_fixed * 1) < 1e-9, (
|
||||||
|
f"FUE-T01: daily window should count exactly 1 day; "
|
||||||
|
f"expected {daily_fixed:.6f}, got {result['fixed_costs']}"
|
||||||
|
)
|
||||||
|
assert abs(result["credits"] - daily_credit * 1) < 1e-9, (
|
||||||
|
f"FUE-T01: daily window should count exactly 1 day of credits; "
|
||||||
|
f"expected {daily_credit:.6f}, got {result['credits']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_single_day_morning_anchor_counts_one_day(self, energy_db: Session) -> None:
|
||||||
|
"""FUE-T01: a window starting mid-morning on a single local day counts that 1 day.
|
||||||
|
|
||||||
|
Window: [June 24 08:00 UTC (= 10:00 CEST), June 24 22:00 UTC (= June 25 00:00 CEST)).
|
||||||
|
Both endpoints resolve to June 24 local (end is exactly midnight of June 25,
|
||||||
|
which is June 24 22:00 UTC, so _lmu(June 25) = June 24 22:00 UTC is NOT < end_utc
|
||||||
|
but equal → last_counted = June 24).
|
||||||
|
first_counted = June 24.
|
||||||
|
n_days = 1. Today (June 25) is past June 24 → elapsed.
|
||||||
|
"""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
eff_utc = _ams_midnight(2026, 6, 1)
|
||||||
|
self._make_single_version_contract(energy_db, effective_from_utc=eff_utc)
|
||||||
|
|
||||||
|
start_utc = datetime(2026, 6, 24, 8, 0, 0, tzinfo=_UTC) # 10:00 CEST = mid-morning June 24
|
||||||
|
end_utc = datetime(2026, 6, 24, 22, 0, 0, tzinfo=_UTC) # = June 25 00:00 CEST midnight
|
||||||
|
|
||||||
|
with patch.object(tz_module, "local_tz", return_value=_ams()):
|
||||||
|
result = self._run_summarize_ams(energy_db, start_utc, end_utc)
|
||||||
|
|
||||||
|
daily_fixed = (9.87 + 9.87) / 30
|
||||||
|
daily_credit = 600.0 / 365
|
||||||
|
# Exactly 1 day: June 24 (start day, counted as full day; elapsed since today is June 25).
|
||||||
|
assert abs(result["fixed_costs"] - daily_fixed * 1) < 1e-9, (
|
||||||
|
f"FUE-T01: mid-morning to midnight window on one day should count 1 day; "
|
||||||
|
f"expected {daily_fixed:.6f}, got {result['fixed_costs']}"
|
||||||
|
)
|
||||||
|
assert abs(result["credits"] - daily_credit * 1) < 1e-9, (
|
||||||
|
f"FUE-T01: mid-morning to midnight window on one day should count 1 day of credits; "
|
||||||
|
f"expected {daily_credit:.6f}, got {result['credits']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 8. compute_closed_periods
|
# 8. compute_closed_periods
|
||||||
@@ -2263,3 +2452,266 @@ class TestMeterAwareComputePeriod:
|
|||||||
assert row.degraded is False, "All-zero deltas must not trigger the D6 guard"
|
assert row.degraded is False, "All-zero deltas must not trigger the D6 guard"
|
||||||
assert row.import_cost == 0.0
|
assert row.import_cost == 0.0
|
||||||
assert row.net_cost == 0.0
|
assert row.net_cost == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# FUE-T08. summarize — settlement offset (local 01:05)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSummarizeSettlementOffset:
|
||||||
|
"""FUE-T08: daily fixed-fee/heffingskorting settled after local 01:05.
|
||||||
|
|
||||||
|
Reference date: June 26, 2026 AMS (CEST = UTC+2).
|
||||||
|
- AMS midnight June 26 = June 25 22:00 UTC
|
||||||
|
- Settlement threshold = June 25 22:00 + 01:05 = June 25 23:05 UTC
|
||||||
|
= June 26 01:05 AMS
|
||||||
|
- "before offset" now = June 26 00:30 AMS = June 25 22:30 UTC
|
||||||
|
- "after offset" now = June 26 01:10 AMS = June 25 23:10 UTC
|
||||||
|
|
||||||
|
All tests monkeypatch both local_tz (Europe/Amsterdam) and
|
||||||
|
``app.services.energy_cost.local_now`` to be fully deterministic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# UTC instants used as "now":
|
||||||
|
# June 25 22:30 UTC = June 26 00:30 AMS (before settlement threshold 23:05 UTC)
|
||||||
|
_NOW_BEFORE = datetime(2026, 6, 25, 22, 30, 0, tzinfo=UTC)
|
||||||
|
# June 25 23:10 UTC = June 26 01:10 AMS (after settlement threshold 23:05 UTC)
|
||||||
|
_NOW_AFTER = datetime(2026, 6, 25, 23, 10, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
def _setup_single_version_contract(self, session: Session) -> None:
|
||||||
|
"""Insert a manual contract effective Jan 1 2026 (covers all test dates)."""
|
||||||
|
c = _make_contract(session, kind="manual", active=True)
|
||||||
|
_make_version(session, c, _MANUAL_VALUES, effective_from=_ams_midnight(2026, 1, 1))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
def _run(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
start_utc: datetime,
|
||||||
|
end_utc: datetime,
|
||||||
|
*,
|
||||||
|
now_utc: datetime,
|
||||||
|
) -> dict:
|
||||||
|
"""Run summarize with AMS timezone and pinned local_now.
|
||||||
|
|
||||||
|
*now_utc* is converted to AMS before being used as the ``local_now``
|
||||||
|
return value, so ``.date()`` yields the correct AMS local date.
|
||||||
|
"""
|
||||||
|
from unittest.mock import patch
|
||||||
|
import app.services.energy_cost as _ec
|
||||||
|
|
||||||
|
now_ams = now_utc.astimezone(_ams())
|
||||||
|
with patch.object(tz_module, "local_tz", return_value=_ams()):
|
||||||
|
with patch.object(_ec, "local_now", return_value=now_ams):
|
||||||
|
return summarize(session, start_utc, end_utc)
|
||||||
|
|
||||||
|
# --- AC1: before 01:05 → today not settled → fixed_costs/credits == 0 ---
|
||||||
|
|
||||||
|
def test_today_window_before_offset_yields_zero(self, energy_db: Session) -> None:
|
||||||
|
"""AC1: At local 00:30 (before 01:05), today's window → credits == 0 and fixed_costs == 0.
|
||||||
|
|
||||||
|
Window: [June 26 AMS midnight, June 27 AMS midnight).
|
||||||
|
now = June 26 00:30 AMS (before 01:05) → settled_cap = June 25.
|
||||||
|
first_counted = June 26 > last_counted = June 25 → 0 days.
|
||||||
|
"""
|
||||||
|
self._setup_single_version_contract(energy_db)
|
||||||
|
start = _ams_midnight(2026, 6, 26) # June 25 22:00 UTC
|
||||||
|
end = _ams_midnight(2026, 6, 27) # June 26 22:00 UTC
|
||||||
|
|
||||||
|
result = self._run(energy_db, start, end, now_utc=self._NOW_BEFORE)
|
||||||
|
|
||||||
|
assert result["fixed_costs"] == 0.0, (
|
||||||
|
"AC1: before settlement offset, today's fixed_costs must be 0; "
|
||||||
|
f"got {result['fixed_costs']}"
|
||||||
|
)
|
||||||
|
assert result["credits"] == 0.0, (
|
||||||
|
"AC1: before settlement offset, today's credits must be 0; "
|
||||||
|
f"got {result['credits']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- AC2: >= 01:05 → today settled → 1 day fixed/credits ---
|
||||||
|
|
||||||
|
def test_today_window_after_offset_yields_one_day(self, energy_db: Session) -> None:
|
||||||
|
"""AC2: At local 01:10 (>= 01:05), today's window → 1 day of fixed/credits.
|
||||||
|
|
||||||
|
Window: [June 26 AMS midnight, June 27 AMS midnight).
|
||||||
|
now = June 26 01:10 AMS (after 01:05) → settled_cap = June 26.
|
||||||
|
first_counted = June 26 = last_counted → 1 day.
|
||||||
|
"""
|
||||||
|
self._setup_single_version_contract(energy_db)
|
||||||
|
start = _ams_midnight(2026, 6, 26)
|
||||||
|
end = _ams_midnight(2026, 6, 27)
|
||||||
|
|
||||||
|
result = self._run(energy_db, start, end, now_utc=self._NOW_AFTER)
|
||||||
|
|
||||||
|
daily_fixed = (9.87 + 9.87) / 30
|
||||||
|
daily_credit = 600.0 / 365
|
||||||
|
assert abs(result["fixed_costs"] - daily_fixed) < 1e-9, (
|
||||||
|
"AC2: after settlement offset, today's fixed_costs must equal 1 day; "
|
||||||
|
f"expected {daily_fixed:.6f}, got {result['fixed_costs']}"
|
||||||
|
)
|
||||||
|
assert abs(result["credits"] - daily_credit) < 1e-9, (
|
||||||
|
"AC2: after settlement offset, today's credits must equal 1 day; "
|
||||||
|
f"expected {daily_credit:.6f}, got {result['credits']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- AC3a: cumulative window, before offset → today (June 26) not counted ---
|
||||||
|
|
||||||
|
def test_cumulative_before_offset_excludes_today(self, energy_db: Session) -> None:
|
||||||
|
"""AC3: Cumulative window at 00:30 (before offset) excludes today from count.
|
||||||
|
|
||||||
|
Window: [June 24 AMS midnight, June 26 00:30 AMS).
|
||||||
|
first_counted = June 24.
|
||||||
|
last_counted (from window) = June 26 (lmu(June 26) = June 25 22:00 < 22:30).
|
||||||
|
settled_cap = June 25 (before offset) → min(June 26, June 25) = June 25.
|
||||||
|
Counted: June 24 + June 25 = 2 days (today June 26 excluded).
|
||||||
|
"""
|
||||||
|
self._setup_single_version_contract(energy_db)
|
||||||
|
start = _ams_midnight(2026, 6, 24) # June 23 22:00 UTC
|
||||||
|
end = self._NOW_BEFORE # June 25 22:30 UTC = June 26 00:30 AMS
|
||||||
|
|
||||||
|
result = self._run(energy_db, start, end, now_utc=self._NOW_BEFORE)
|
||||||
|
|
||||||
|
daily_fixed = (9.87 + 9.87) / 30
|
||||||
|
daily_credit = 600.0 / 365
|
||||||
|
assert abs(result["fixed_costs"] - daily_fixed * 2) < 1e-9, (
|
||||||
|
"AC3 before offset: today (June 26) must not be counted; expected 2 days; "
|
||||||
|
f"got {result['fixed_costs']}"
|
||||||
|
)
|
||||||
|
assert abs(result["credits"] - daily_credit * 2) < 1e-9, (
|
||||||
|
"AC3 before offset: credits must be 2 days; "
|
||||||
|
f"got {result['credits']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- AC3b: cumulative window, after offset → today (June 26) counted ---
|
||||||
|
|
||||||
|
def test_cumulative_after_offset_includes_today(self, energy_db: Session) -> None:
|
||||||
|
"""AC3: Cumulative window at 01:10 (after offset) includes today.
|
||||||
|
|
||||||
|
Window: [June 24 AMS midnight, June 26 01:10 AMS).
|
||||||
|
first_counted = June 24.
|
||||||
|
last_counted (from window) = June 26 (lmu(June 26) < 23:10 UTC).
|
||||||
|
settled_cap = June 26 (after offset) → last_counted = June 26.
|
||||||
|
Counted: June 24 + June 25 + June 26 = 3 days.
|
||||||
|
"""
|
||||||
|
self._setup_single_version_contract(energy_db)
|
||||||
|
start = _ams_midnight(2026, 6, 24) # June 23 22:00 UTC
|
||||||
|
end = self._NOW_AFTER # June 25 23:10 UTC = June 26 01:10 AMS
|
||||||
|
|
||||||
|
result = self._run(energy_db, start, end, now_utc=self._NOW_AFTER)
|
||||||
|
|
||||||
|
daily_fixed = (9.87 + 9.87) / 30
|
||||||
|
daily_credit = 600.0 / 365
|
||||||
|
assert abs(result["fixed_costs"] - daily_fixed * 3) < 1e-9, (
|
||||||
|
"AC3 after offset: today (June 26) must be counted; expected 3 days; "
|
||||||
|
f"got {result['fixed_costs']}"
|
||||||
|
)
|
||||||
|
assert abs(result["credits"] - daily_credit * 3) < 1e-9, (
|
||||||
|
"AC3 after offset: credits must be 3 days; "
|
||||||
|
f"got {result['credits']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- AC4: past days always fully counted regardless of offset ---
|
||||||
|
|
||||||
|
def test_past_days_always_counted_regardless_of_offset(self, energy_db: Session) -> None:
|
||||||
|
"""AC4: Past days (all before today) are fully counted even before 01:05.
|
||||||
|
|
||||||
|
Window: [June 20 AMS midnight, June 26 AMS midnight) — all before today.
|
||||||
|
now = June 26 00:30 AMS (before offset) → settled_cap = June 25.
|
||||||
|
last_counted (from window) = June 25 (lmu(June 26) = end_utc, not <).
|
||||||
|
min(June 25, June 25) = June 25 → 6 days (June 20-25), all past.
|
||||||
|
"""
|
||||||
|
self._setup_single_version_contract(energy_db)
|
||||||
|
start = _ams_midnight(2026, 6, 20) # June 19 22:00 UTC
|
||||||
|
end = _ams_midnight(2026, 6, 26) # June 25 22:00 UTC
|
||||||
|
|
||||||
|
result = self._run(energy_db, start, end, now_utc=self._NOW_BEFORE)
|
||||||
|
|
||||||
|
daily_fixed = (9.87 + 9.87) / 30
|
||||||
|
daily_credit = 600.0 / 365
|
||||||
|
# June 20-25 = 6 days, all past — unaffected by settlement offset.
|
||||||
|
assert abs(result["fixed_costs"] - daily_fixed * 6) < 1e-9, (
|
||||||
|
"AC4: past days must be fully counted regardless of settlement offset; "
|
||||||
|
f"expected 6 days = {daily_fixed * 6:.6f}, got {result['fixed_costs']}"
|
||||||
|
)
|
||||||
|
assert abs(result["credits"] - daily_credit * 6) < 1e-9, (
|
||||||
|
"AC4: past credits must be 6 days; "
|
||||||
|
f"got {result['credits']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- AC5: cross-version segments still correct with settlement offset ---
|
||||||
|
|
||||||
|
def test_cross_version_with_settlement_offset(self, energy_db: Session) -> None:
|
||||||
|
"""AC5: Cross-version day split is correct when settlement offset is active.
|
||||||
|
|
||||||
|
V1 covers June 24; V2 covers June 25+.
|
||||||
|
Window: [June 24 AMS midnight, June 26 01:10 AMS).
|
||||||
|
After offset (01:10) → settled_cap = June 26 → 3 days: V1=1, V2=2.
|
||||||
|
|
||||||
|
V1: network_fee=6, management_fee=6 → daily_fixed = 12/30
|
||||||
|
heffingskorting=300 → daily_credit = 300/365
|
||||||
|
V2: network_fee=12, management_fee=12 → daily_fixed = 24/30
|
||||||
|
heffingskorting=600 → daily_credit = 600/365
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
fixed_costs = 1×(12/30) + 2×(24/30) = 0.4 + 1.6 = 2.0
|
||||||
|
credits = 1×(300/365) + 2×(600/365) = 1500/365
|
||||||
|
"""
|
||||||
|
_VALUES_V1 = {
|
||||||
|
"energy": {"buy": {"normal": 0.10, "dal": 0.10},
|
||||||
|
"sell": {"normal": 0.05, "dal": 0.05},
|
||||||
|
"energy_tax": 0.0, "ode": 0.0},
|
||||||
|
"standing": {"network_fee": 6.0, "management_fee": 6.0},
|
||||||
|
"credits": {"heffingskorting": 300.0},
|
||||||
|
}
|
||||||
|
_VALUES_V2 = {
|
||||||
|
"energy": {"buy": {"normal": 0.20, "dal": 0.20},
|
||||||
|
"sell": {"normal": 0.08, "dal": 0.08},
|
||||||
|
"energy_tax": 0.0, "ode": 0.0},
|
||||||
|
"standing": {"network_fee": 12.0, "management_fee": 12.0},
|
||||||
|
"credits": {"heffingskorting": 600.0},
|
||||||
|
}
|
||||||
|
v1_from = _ams_midnight(2026, 6, 24) # June 23 22:00 UTC
|
||||||
|
v2_from = _ams_midnight(2026, 6, 25) # June 24 22:00 UTC
|
||||||
|
|
||||||
|
c = _make_contract(energy_db, kind="manual", active=True)
|
||||||
|
_make_version(energy_db, c, _VALUES_V1, effective_from=v1_from, effective_to=v2_from)
|
||||||
|
_make_version(energy_db, c, _VALUES_V2, effective_from=v2_from)
|
||||||
|
energy_db.commit()
|
||||||
|
|
||||||
|
start = _ams_midnight(2026, 6, 24) # June 23 22:00 UTC
|
||||||
|
end = self._NOW_AFTER # June 25 23:10 UTC = June 26 01:10 AMS
|
||||||
|
|
||||||
|
result = self._run(energy_db, start, end, now_utc=self._NOW_AFTER)
|
||||||
|
|
||||||
|
expected_fixed = 1 * 12 / 30 + 2 * 24 / 30 # V1: 1 day, V2: 2 days
|
||||||
|
expected_credits = 1 * 300 / 365 + 2 * 600 / 365
|
||||||
|
assert abs(result["fixed_costs"] - expected_fixed) < 1e-9, (
|
||||||
|
f"AC5: cross-version fixed_costs wrong; expected {expected_fixed}, "
|
||||||
|
f"got {result['fixed_costs']}"
|
||||||
|
)
|
||||||
|
assert abs(result["credits"] - expected_credits) < 1e-9, (
|
||||||
|
f"AC5: cross-version credits wrong; expected {expected_credits}, "
|
||||||
|
f"got {result['credits']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- AC6: return dict key-set unchanged ---
|
||||||
|
|
||||||
|
def test_return_dict_keys_unchanged(self, energy_db: Session) -> None:
|
||||||
|
"""AC6: summarize() return dict keys are unchanged by the settlement offset."""
|
||||||
|
self._setup_single_version_contract(energy_db)
|
||||||
|
start = _ams_midnight(2026, 6, 26)
|
||||||
|
end = _ams_midnight(2026, 6, 27)
|
||||||
|
|
||||||
|
result = self._run(energy_db, start, end, now_utc=self._NOW_AFTER)
|
||||||
|
|
||||||
|
expected_keys = {
|
||||||
|
"currency", "metered_import", "metered_export", "metered_net",
|
||||||
|
"fixed_costs", "credits", "total_payable", "period_count",
|
||||||
|
"degraded_count", "days",
|
||||||
|
}
|
||||||
|
assert set(result.keys()) == expected_keys, (
|
||||||
|
f"AC6: summarize() key set changed; expected {expected_keys}, "
|
||||||
|
f"got {set(result.keys())}"
|
||||||
|
)
|
||||||
|
|||||||
+775
-74
File diff suppressed because it is too large
Load Diff
@@ -798,7 +798,7 @@ def test_meter_columns(energy_db):
|
|||||||
inspector = inspect(energy_db)
|
inspector = inspect(energy_db)
|
||||||
columns = {col["name"]: col for col in inspector.get_columns("meter")}
|
columns = {col["name"]: col for col in inspector.get_columns("meter")}
|
||||||
|
|
||||||
non_nullable = {"id", "label", "commodity", "started_at", "reason", "created_at"}
|
non_nullable = {"id", "uuid", "label", "commodity", "started_at", "reason", "created_at"}
|
||||||
nullable = {"ended_at", "note"}
|
nullable = {"ended_at", "note"}
|
||||||
|
|
||||||
for col_name in non_nullable:
|
for col_name in non_nullable:
|
||||||
@@ -810,11 +810,20 @@ def test_meter_columns(energy_db):
|
|||||||
assert columns[col_name]["nullable"], f"{col_name} should be nullable"
|
assert columns[col_name]["nullable"], f"{col_name} should be nullable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_meter_uuid_unique_constraint(energy_db):
|
||||||
|
"""meter.uuid must have a unique constraint."""
|
||||||
|
inspector = inspect(energy_db)
|
||||||
|
unique_constraints = inspector.get_unique_constraints("meter")
|
||||||
|
unique_cols = [col for uc in unique_constraints for col in uc["column_names"]]
|
||||||
|
assert "uuid" in unique_cols, "meter.uuid must have a unique constraint"
|
||||||
|
|
||||||
|
|
||||||
def test_meter_orm_metadata():
|
def test_meter_orm_metadata():
|
||||||
"""Meter must be registered in Base.metadata with correct field types."""
|
"""Meter must be registered in Base.metadata with correct field types."""
|
||||||
assert "meter" in Base.metadata.tables, "meter not in Base.metadata"
|
assert "meter" in Base.metadata.tables, "meter not in Base.metadata"
|
||||||
table = Base.metadata.tables["meter"]
|
table = Base.metadata.tables["meter"]
|
||||||
assert "id" in table.columns
|
assert "id" in table.columns
|
||||||
|
assert "uuid" in table.columns
|
||||||
assert "label" in table.columns
|
assert "label" in table.columns
|
||||||
assert "commodity" in table.columns
|
assert "commodity" in table.columns
|
||||||
assert "started_at" in table.columns
|
assert "started_at" in table.columns
|
||||||
@@ -824,6 +833,14 @@ def test_meter_orm_metadata():
|
|||||||
assert "created_at" in table.columns
|
assert "created_at" in table.columns
|
||||||
|
|
||||||
|
|
||||||
|
def test_meter_uuid_unique_in_metadata():
|
||||||
|
"""Meter.uuid must be declared unique and not nullable in ORM metadata."""
|
||||||
|
table = Base.metadata.tables["meter"]
|
||||||
|
col = table.columns["uuid"]
|
||||||
|
assert col.unique, "Meter.uuid must be declared unique in ORM metadata"
|
||||||
|
assert not col.nullable, "Meter.uuid must be NOT NULL in ORM metadata"
|
||||||
|
|
||||||
|
|
||||||
def test_meter_insert_and_retrieve(energy_db):
|
def test_meter_insert_and_retrieve(energy_db):
|
||||||
"""A Meter row can be inserted and retrieved with all fields intact."""
|
"""A Meter row can be inserted and retrieved with all fields intact."""
|
||||||
now = datetime.now(tz=timezone.utc)
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
|||||||
@@ -205,27 +205,58 @@ def test_register_provider_direct_call():
|
|||||||
def test_build_catalog_empty_with_no_devices(expose_db):
|
def test_build_catalog_empty_with_no_devices(expose_db):
|
||||||
"""build_catalog with no enabled modbus devices must contain no modbus entities.
|
"""build_catalog with no enabled modbus devices must contain no modbus entities.
|
||||||
|
|
||||||
The energy_cost provider is always registered and always produces its 6 entities
|
FUE-T05: the energy_cost provider now requires an active electricity meter.
|
||||||
(4 original + 2 daily) regardless of device state, so the catalog will not be empty.
|
Without one, it returns [] and the catalog contains no energy entities.
|
||||||
This test checks that no *modbus* entities are present when there are no enabled
|
With one, it produces 6 entities.
|
||||||
modbus devices.
|
|
||||||
|
This test verifies both cases:
|
||||||
|
1. No modbus devices → no modbus entities.
|
||||||
|
2. No active meter → no energy entities (provider returns []).
|
||||||
|
3. After inserting an active meter → 6 energy entities present.
|
||||||
"""
|
"""
|
||||||
from app.integrations.expose import build_catalog
|
from app.integrations.expose import build_catalog
|
||||||
|
|
||||||
|
# Case: no modbus devices, no active meter → catalog is empty.
|
||||||
with Session(expose_db) as session:
|
with Session(expose_db) as session:
|
||||||
catalog = build_catalog(session)
|
catalog = build_catalog(session)
|
||||||
|
|
||||||
# The modbus provider finds no enabled devices → no modbus entities.
|
|
||||||
# The energy_cost provider always produces 6 entities, so the catalog is non-empty.
|
|
||||||
modbus_entities = [e for e in catalog if e.entity.key.startswith("modbus.")]
|
modbus_entities = [e for e in catalog if e.entity.key.startswith("modbus.")]
|
||||||
assert modbus_entities == [], (
|
assert modbus_entities == [], (
|
||||||
"Expected no modbus entities when no modbus devices are enabled"
|
"Expected no modbus entities when no modbus devices are enabled"
|
||||||
)
|
)
|
||||||
|
|
||||||
# The 6 energy_cost entities should always be present (4 original + 2 daily).
|
# No active electricity meter → energy-cost provider returns [] → no energy entities.
|
||||||
energy_keys = {e.entity.key for e in catalog if e.entity.key.startswith("energy.")}
|
energy_keys_no_meter = {e.entity.key for e in catalog if e.entity.key.startswith("energy.")}
|
||||||
assert len(energy_keys) == 6, (
|
assert len(energy_keys_no_meter) == 0, (
|
||||||
f"Expected exactly 6 energy_cost entities (4 original + 2 daily), got {energy_keys!r}"
|
f"Expected 0 energy_cost entities (no active meter), got {energy_keys_no_meter!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Insert an active electricity meter → provider should now produce 6 entities.
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
with Session(expose_db) as session:
|
||||||
|
from app.models.energy import Meter
|
||||||
|
m = Meter(
|
||||||
|
label="Test Meter for catalog",
|
||||||
|
commodity="electricity",
|
||||||
|
started_at=now,
|
||||||
|
ended_at=None,
|
||||||
|
reason="initial",
|
||||||
|
note=None,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
session.add(m)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
with Session(expose_db) as session:
|
||||||
|
catalog_with_meter = build_catalog(session)
|
||||||
|
|
||||||
|
energy_keys_with_meter = {
|
||||||
|
e.entity.key for e in catalog_with_meter if e.entity.key.startswith("energy.")
|
||||||
|
}
|
||||||
|
assert len(energy_keys_with_meter) == 6, (
|
||||||
|
f"Expected exactly 6 energy_cost entities (4 original + 2 daily) with active meter, "
|
||||||
|
f"got {energy_keys_with_meter!r}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -381,6 +381,43 @@ class TestDeclareMeter:
|
|||||||
assert fetched.commodity == "gas"
|
assert fetched.commodity == "gas"
|
||||||
assert fetched.note == "Rotameter serial XYZ"
|
assert fetched.note == "Rotameter serial XYZ"
|
||||||
|
|
||||||
|
def test_declare_meter_generates_uuid(self, session: Session):
|
||||||
|
"""declare_meter must auto-generate a non-empty uuid via ORM default."""
|
||||||
|
import re
|
||||||
|
UUID4_RE = re.compile(
|
||||||
|
r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
m = declare_meter(
|
||||||
|
session,
|
||||||
|
label="Meter with UUID",
|
||||||
|
started_at=_T0,
|
||||||
|
reason="initial",
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
fetched = session.get(Meter, m.id)
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.uuid is not None, "uuid must not be None after declare_meter"
|
||||||
|
assert fetched.uuid != "", "uuid must not be empty"
|
||||||
|
assert UUID4_RE.match(fetched.uuid), (
|
||||||
|
f"uuid {fetched.uuid!r} does not look like a valid UUID v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_declare_meter_each_gets_distinct_uuid(self, session: Session):
|
||||||
|
"""Each declared meter must receive a distinct UUID (not duplicated)."""
|
||||||
|
m1 = declare_meter(session, label="M1", started_at=_T0, reason="initial")
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
t1 = _T0 + timedelta(days=10)
|
||||||
|
m2 = declare_meter(session, label="M2", started_at=t1, reason="meter_swap")
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
assert m1.uuid != m2.uuid, (
|
||||||
|
f"Two declared meters must have distinct UUIDs; both got {m1.uuid!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 5. Different commodities are independent
|
# 5. Different commodities are independent
|
||||||
|
|||||||
@@ -217,6 +217,57 @@ class TestReadBlocks:
|
|||||||
|
|
||||||
mock_client.close.assert_called_once()
|
mock_client.close.assert_called_once()
|
||||||
|
|
||||||
|
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
||||||
|
def test_default_function_code_uses_fc04_input_registers(
|
||||||
|
self, mock_client_cls: MagicMock
|
||||||
|
) -> None:
|
||||||
|
"""With no function_code given, read_blocks uses FC04 (read_input_registers)."""
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
mock_client.connect.return_value = True
|
||||||
|
mock_client.read_input_registers.return_value = _make_ok_response([0x4366, 0x3334])
|
||||||
|
|
||||||
|
read_blocks("127.0.0.1", 502, 1, [{"start": 0x0000, "count": 2}])
|
||||||
|
|
||||||
|
mock_client.read_input_registers.assert_called_once_with(0x0000, count=2, device_id=1)
|
||||||
|
mock_client.read_holding_registers.assert_not_called()
|
||||||
|
|
||||||
|
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
||||||
|
def test_function_code_3_uses_fc03_holding_registers(
|
||||||
|
self, mock_client_cls: MagicMock
|
||||||
|
) -> None:
|
||||||
|
"""function_code=3 dispatches FC03 (read_holding_registers), e.g. DDSU666."""
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
mock_client.connect.return_value = True
|
||||||
|
mock_client.read_holding_registers.return_value = _make_ok_response(
|
||||||
|
[0x4366, 0x3334, 0x3F80, 0x0000]
|
||||||
|
)
|
||||||
|
|
||||||
|
blocks = [{"start": 0x2000, "count": 4}]
|
||||||
|
result = read_blocks("127.0.0.1", 502, 1, blocks, function_code=3)
|
||||||
|
|
||||||
|
assert result == {0x2000: 0x4366, 0x2001: 0x3334, 0x2002: 0x3F80, 0x2003: 0x0000}
|
||||||
|
mock_client.read_holding_registers.assert_called_once_with(
|
||||||
|
0x2000, count=4, device_id=1
|
||||||
|
)
|
||||||
|
mock_client.read_input_registers.assert_not_called()
|
||||||
|
|
||||||
|
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
||||||
|
def test_invalid_function_code_raises_before_connecting(
|
||||||
|
self, mock_client_cls: MagicMock
|
||||||
|
) -> None:
|
||||||
|
"""An unsupported function code is rejected without opening a connection."""
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
with pytest.raises(ModbusDriverError, match="Unsupported read function code"):
|
||||||
|
read_blocks("127.0.0.1", 502, 1, [{"start": 0, "count": 2}], function_code=16)
|
||||||
|
|
||||||
|
# No client should have been constructed or connected for a bad FC.
|
||||||
|
mock_client_cls.assert_not_called()
|
||||||
|
mock_client.connect.assert_not_called()
|
||||||
|
|
||||||
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
||||||
def test_unit_id_passed_as_device_id(self, mock_client_cls: MagicMock) -> None:
|
def test_unit_id_passed_as_device_id(self, mock_client_cls: MagicMock) -> None:
|
||||||
"""unit_id is forwarded as device_id= keyword argument (pymodbus 3.13.x)."""
|
"""unit_id is forwarded as device_id= keyword argument (pymodbus 3.13.x)."""
|
||||||
|
|||||||
@@ -137,6 +137,11 @@ class TestLoadProfileTibber:
|
|||||||
profile = load_profile("tibber")
|
profile = load_profile("tibber")
|
||||||
assert profile.energy.sell_adjust.default == 0
|
assert profile.energy.sell_adjust.default == 0
|
||||||
|
|
||||||
|
def test_sell_fee_has_default_verkoopvergoeding(self) -> None:
|
||||||
|
profile = load_profile("tibber")
|
||||||
|
assert profile.energy.sell_fee.unit == "EUR/kWh"
|
||||||
|
assert profile.energy.sell_fee.default == 0.0248
|
||||||
|
|
||||||
def test_management_fee_has_default(self) -> None:
|
def test_management_fee_has_default(self) -> None:
|
||||||
profile = load_profile("tibber")
|
profile = load_profile("tibber")
|
||||||
assert profile.standing.management_fee.default is not None
|
assert profile.standing.management_fee.default is not None
|
||||||
@@ -350,6 +355,19 @@ class TestValidateValuesTibber:
|
|||||||
filled = validate_values("tibber", values)
|
filled = validate_values("tibber", values)
|
||||||
assert filled["energy"]["sell_adjust"] == 0
|
assert filled["energy"]["sell_adjust"] == 0
|
||||||
|
|
||||||
|
def test_sell_fee_default_applied_when_absent(self) -> None:
|
||||||
|
values = {
|
||||||
|
"energy": {
|
||||||
|
"energy_tax": 0.1108,
|
||||||
|
"sell_adjust": 0.0,
|
||||||
|
# sell_fee absent — has default 0.0248 (verkoopvergoeding)
|
||||||
|
},
|
||||||
|
"standing": {"management_fee": 5.99, "network_fee": 9.87},
|
||||||
|
"credits": {"heffingskorting": 600.0},
|
||||||
|
}
|
||||||
|
filled = validate_values("tibber", values)
|
||||||
|
assert filled["energy"]["sell_fee"] == 0.0248
|
||||||
|
|
||||||
def test_management_fee_default_applied_when_absent(self) -> None:
|
def test_management_fee_default_applied_when_absent(self) -> None:
|
||||||
values = {
|
values = {
|
||||||
"energy": {"energy_tax": 0.1108, "sell_adjust": 0.0},
|
"energy": {"energy_tax": 0.1108, "sell_adjust": 0.0},
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Acceptance criteria covered
|
|||||||
2. Manual strategy: dual-tariff import/export/net calculated correctly (hand-verified).
|
2. Manual strategy: dual-tariff import/export/net calculated correctly (hand-verified).
|
||||||
3. Manual strategy: Decimal precision — no float binary rounding errors.
|
3. Manual strategy: Decimal precision — no float binary rounding errors.
|
||||||
4. Tibber strategy: queries the most recent TibberPrice with starts_at ≤ t0.
|
4. Tibber strategy: queries the most recent TibberPrice with starts_at ≤ t0.
|
||||||
5. Tibber strategy: buy=total, sell=total−energy_tax−sell_adjust.
|
5. Tibber strategy: buy=total, sell=total−energy_tax−sell_fee−sell_adjust.
|
||||||
6. Tibber strategy: negative total → negative export_revenue (not clamped).
|
6. Tibber strategy: negative total → negative export_revenue (not clamped).
|
||||||
7. Tibber strategy: raises TibberPriceNotFoundError when no matching row exists.
|
7. Tibber strategy: raises TibberPriceNotFoundError when no matching row exists.
|
||||||
8. ``register_strategy`` / ``get_strategy`` round-trip works.
|
8. ``register_strategy`` / ``get_strategy`` round-trip works.
|
||||||
@@ -371,6 +371,68 @@ class TestTibberStrategy:
|
|||||||
# sell = 0.25 - 0.10 - 0.02 = 0.13; export_revenue = 2 × 0.13 = 0.26
|
# sell = 0.25 - 0.10 - 0.02 = 0.13; export_revenue = 2 × 0.13 = 0.26
|
||||||
assert result["export_revenue"] == Decimal("2") * Decimal("0.13")
|
assert result["export_revenue"] == Decimal("2") * Decimal("0.13")
|
||||||
|
|
||||||
|
def test_sell_deducts_sell_fee(self, tibber_db) -> None:
|
||||||
|
"""verkoopvergoeding (sell_fee) is subtracted from the feed-in price.
|
||||||
|
|
||||||
|
Under net metering the energy tax is refunded (sell_adjust = −energy_tax),
|
||||||
|
so sell should equal total − sell_fee. Verifies the fee is a first-class,
|
||||||
|
always-deducted term and does NOT cancel against the buy-side inkoopvergoeding
|
||||||
|
that is already baked into total.
|
||||||
|
"""
|
||||||
|
t0 = _ts(10, 0)
|
||||||
|
with Session(tibber_db) as session:
|
||||||
|
_insert_tibber_price(session, starts_at=_ts(9, 45), total=0.3073)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Net-metering config: sell_adjust = −energy_tax refunds the tax;
|
||||||
|
# sell_fee = 0.0248 (Tibber verkoopvergoeding) is still deducted.
|
||||||
|
values = {
|
||||||
|
"energy": {
|
||||||
|
"energy_tax": 0.11085,
|
||||||
|
"sell_fee": 0.0248,
|
||||||
|
"sell_adjust": -0.11085,
|
||||||
|
},
|
||||||
|
"standing": {"management_fee": 5.99, "network_fee": 9.87},
|
||||||
|
"credits": {"heffingskorting": 600.0},
|
||||||
|
}
|
||||||
|
with Session(tibber_db) as session:
|
||||||
|
deltas = PeriodDeltas(
|
||||||
|
d1=Decimal("0"), d2=Decimal("0"),
|
||||||
|
r1=Decimal("0"), r2=Decimal("1"),
|
||||||
|
)
|
||||||
|
result = self._call(deltas, t0, session, values=values)
|
||||||
|
|
||||||
|
# sell = 0.3073 − 0.11085 − 0.0248 − (−0.11085) = 0.3073 − 0.0248 = 0.2825
|
||||||
|
expected_sell = (
|
||||||
|
Decimal("0.3073") - Decimal("0.11085") - Decimal("0.0248") - Decimal("-0.11085")
|
||||||
|
)
|
||||||
|
assert expected_sell == Decimal("0.2825")
|
||||||
|
assert result["export_revenue"] == Decimal("1") * expected_sell
|
||||||
|
assert result["pricing"]["sell_fee"] == "0.0248"
|
||||||
|
assert Decimal(result["pricing"]["sell"]) == Decimal("0.2825")
|
||||||
|
|
||||||
|
def test_sell_fee_absent_defaults_to_zero(self, tibber_db) -> None:
|
||||||
|
"""A version without sell_fee (pre-migration) reads it as 0 — no silent deduction."""
|
||||||
|
t0 = _ts(10, 0)
|
||||||
|
with Session(tibber_db) as session:
|
||||||
|
_insert_tibber_price(session, starts_at=_ts(9, 45), total=0.25)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
values = {
|
||||||
|
"energy": {"energy_tax": 0.10, "sell_adjust": 0.0}, # no sell_fee key
|
||||||
|
"standing": {"management_fee": 5.99, "network_fee": 9.87},
|
||||||
|
"credits": {"heffingskorting": 600.0},
|
||||||
|
}
|
||||||
|
with Session(tibber_db) as session:
|
||||||
|
deltas = PeriodDeltas(
|
||||||
|
d1=Decimal("0"), d2=Decimal("0"),
|
||||||
|
r1=Decimal("0"), r2=Decimal("1"),
|
||||||
|
)
|
||||||
|
result = self._call(deltas, t0, session, values=values)
|
||||||
|
# sell = 0.25 − 0.10 − 0 − 0 = 0.15
|
||||||
|
assert result["export_revenue"] == Decimal("0.15")
|
||||||
|
assert result["pricing"]["sell_fee"] == "0"
|
||||||
|
|
||||||
def test_uses_most_recent_price_before_t0(self, tibber_db) -> None:
|
def test_uses_most_recent_price_before_t0(self, tibber_db) -> None:
|
||||||
"""Correct row: starts_at ≤ t0, most recent wins."""
|
"""Correct row: starts_at ≤ t0, most recent wins."""
|
||||||
t0 = _ts(10, 0)
|
t0 = _ts(10, 0)
|
||||||
|
|||||||
+79
-24
@@ -69,15 +69,18 @@ _THREE_NODES = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
_PRICE_RANGE_RESPONSE = {
|
def _price_info_response(today: list[dict], tomorrow: list[dict] | None = None) -> dict:
|
||||||
|
"""Build a priceInfo(resolution: QUARTER_HOURLY) { today tomorrow } response."""
|
||||||
|
return {
|
||||||
"data": {
|
"data": {
|
||||||
"viewer": {
|
"viewer": {
|
||||||
"homes": [
|
"homes": [
|
||||||
{
|
{
|
||||||
"id": "home-id-1",
|
"id": "home-id-1",
|
||||||
"currentSubscription": {
|
"currentSubscription": {
|
||||||
"priceInfoRange": {
|
"priceInfo": {
|
||||||
"nodes": _THREE_NODES,
|
"today": today,
|
||||||
|
"tomorrow": tomorrow if tomorrow is not None else [],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -86,6 +89,9 @@ _PRICE_RANGE_RESPONSE = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_PRICE_RANGE_RESPONSE = _price_info_response(_THREE_NODES)
|
||||||
|
|
||||||
_CURRENT_PRICE_RESPONSE = {
|
_CURRENT_PRICE_RESPONSE = {
|
||||||
"data": {
|
"data": {
|
||||||
"viewer": {
|
"viewer": {
|
||||||
@@ -173,23 +179,8 @@ def test_fetch_price_range_parses_nodes(monkeypatch):
|
|||||||
|
|
||||||
def test_fetch_price_range_does_not_assume_node_count(monkeypatch):
|
def test_fetch_price_range_does_not_assume_node_count(monkeypatch):
|
||||||
"""Parser handles an arbitrary number of nodes (not hardcoded to 96)."""
|
"""Parser handles an arbitrary number of nodes (not hardcoded to 96)."""
|
||||||
# Build a response with a single node only.
|
# Build a response with a single today node and no tomorrow yet.
|
||||||
one_node_response = {
|
one_node_response = _price_info_response([_THREE_NODES[0]])
|
||||||
"data": {
|
|
||||||
"viewer": {
|
|
||||||
"homes": [
|
|
||||||
{
|
|
||||||
"id": "home-id-1",
|
|
||||||
"currentSubscription": {
|
|
||||||
"priceInfoRange": {
|
|
||||||
"nodes": [_THREE_NODES[0]],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
transport = _make_transport(200, one_node_response)
|
transport = _make_transport(200, one_node_response)
|
||||||
|
|
||||||
def _patched_post(url, *, json, headers, timeout): # noqa: A002
|
def _patched_post(url, *, json, headers, timeout): # noqa: A002
|
||||||
@@ -202,6 +193,68 @@ def test_fetch_price_range_does_not_assume_node_count(monkeypatch):
|
|||||||
assert len(points) == 1
|
assert len(points) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_price_range_concatenates_today_and_tomorrow(monkeypatch):
|
||||||
|
"""today and tomorrow node lists are both parsed (today first, then tomorrow)."""
|
||||||
|
tomorrow_nodes = [
|
||||||
|
{
|
||||||
|
"startsAt": "2026-06-24T00:00:00.000+02:00",
|
||||||
|
"total": 0.40,
|
||||||
|
"energy": 0.32,
|
||||||
|
"tax": 0.08,
|
||||||
|
"currency": "EUR",
|
||||||
|
"level": "EXPENSIVE",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
response = _price_info_response(_THREE_NODES, tomorrow_nodes)
|
||||||
|
transport = _make_transport(200, response)
|
||||||
|
|
||||||
|
def _patched_post(url, *, json, headers, timeout): # noqa: A002
|
||||||
|
client = httpx.Client(transport=transport)
|
||||||
|
return client.post(url, json=json, headers=headers, timeout=timeout)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post)
|
||||||
|
|
||||||
|
points = fetch_price_range(_FAKE_TOKEN)
|
||||||
|
# 3 today + 1 tomorrow, in order.
|
||||||
|
assert len(points) == 4
|
||||||
|
# First node is today's first; last node is tomorrow's.
|
||||||
|
assert points[0].starts_at == datetime(2026, 6, 22, 22, 0, 0, tzinfo=UTC)
|
||||||
|
# 2026-06-24T00:00:00+02:00 → 2026-06-23T22:00:00Z
|
||||||
|
assert points[-1].starts_at == datetime(2026, 6, 23, 22, 0, 0, tzinfo=UTC)
|
||||||
|
assert points[-1].total == pytest.approx(0.40)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_price_range_tomorrow_null_returns_today_only(monkeypatch):
|
||||||
|
"""A null tomorrow (before day-ahead publication) yields today's nodes only."""
|
||||||
|
response = {
|
||||||
|
"data": {
|
||||||
|
"viewer": {
|
||||||
|
"homes": [
|
||||||
|
{
|
||||||
|
"id": "home-id-1",
|
||||||
|
"currentSubscription": {
|
||||||
|
"priceInfo": {
|
||||||
|
"today": _THREE_NODES,
|
||||||
|
"tomorrow": None,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transport = _make_transport(200, response)
|
||||||
|
|
||||||
|
def _patched_post(url, *, json, headers, timeout): # noqa: A002
|
||||||
|
client = httpx.Client(transport=transport)
|
||||||
|
return client.post(url, json=json, headers=headers, timeout=timeout)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post)
|
||||||
|
|
||||||
|
points = fetch_price_range(_FAKE_TOKEN)
|
||||||
|
assert len(points) == 3
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_price_range_home_id_selection(monkeypatch):
|
def test_fetch_price_range_home_id_selection(monkeypatch):
|
||||||
"""When home_id is specified, the matching home is selected."""
|
"""When home_id is specified, the matching home is selected."""
|
||||||
two_homes_response = {
|
two_homes_response = {
|
||||||
@@ -211,16 +264,18 @@ def test_fetch_price_range_home_id_selection(monkeypatch):
|
|||||||
{
|
{
|
||||||
"id": "home-id-first",
|
"id": "home-id-first",
|
||||||
"currentSubscription": {
|
"currentSubscription": {
|
||||||
"priceInfoRange": {
|
"priceInfo": {
|
||||||
"nodes": [_THREE_NODES[0]],
|
"today": [_THREE_NODES[0]],
|
||||||
|
"tomorrow": [],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "home-id-second",
|
"id": "home-id-second",
|
||||||
"currentSubscription": {
|
"currentSubscription": {
|
||||||
"priceInfoRange": {
|
"priceInfo": {
|
||||||
"nodes": [_THREE_NODES[1], _THREE_NODES[2]],
|
"today": [_THREE_NODES[1], _THREE_NODES[2]],
|
||||||
|
"tomorrow": [],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user