From 8b91146c29a0576ea8e642e22b95a9e7af978f69 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Thu, 25 Jun 2026 11:13:30 +0200 Subject: [PATCH] FU10: localize energy time math to server tz; accrue fixed/credit per elapsed local day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Store UTC, compute day boundaries in server local timezone (new app/services/timezone.py). - summarize: fixed fee / tax credit accrue only for elapsed whole local days, integrated across contract versions — no future-day inflation, no version-switch collapse. - MQTT import_cost_total / export_revenue_total getters delegate to summarize (no inline apportionment), anchored at the active contract's earliest version effective_from so the total sensors stay monotonic and never jump backward. - effective_from: naive datetimes interpreted as server-local wall-clock, then stored UTC. - frontend ContractForm sends local-midnight naive datetime (was UTC midnight). - get_costs_summary default window uses server-local "today". --- app/api/routes/api/energy.py | 16 +- app/api/routes/api/energy_contracts.py | 30 ++- app/integrations/expose.py | 89 +++--- app/services/contracts.py | 26 ++ app/services/energy_cost.py | 128 +++++++-- app/services/timezone.py | 100 +++++++ frontend/src/energy/ContractForm.tsx | 8 +- tests/test_energy_cost.py | 359 +++++++++++++++++++++++-- tests/test_energy_expose.py | 156 +++++++---- tests/test_timezone.py | 187 +++++++++++++ 10 files changed, 941 insertions(+), 158 deletions(-) create mode 100644 app/services/timezone.py create mode 100644 tests/test_timezone.py diff --git a/app/api/routes/api/energy.py b/app/api/routes/api/energy.py index a9ec27a..4bf3532 100644 --- a/app/api/routes/api/energy.py +++ b/app/api/routes/api/energy.py @@ -75,6 +75,7 @@ from app.schemas.energy import ( from app.services.auth import AuthenticatedSession from app.services.contracts import active_contract_version_at from app.services.energy_cost import recompute_range, summarize +from app.services.timezone import local_midnight_utc, local_now logger = logging.getLogger(__name__) @@ -358,12 +359,15 @@ def get_costs_summary( Both ``fixed_costs`` and ``credits`` are derived from the **currently active contract version at ``end``**. When no active contract exists they are 0. """ - now = datetime.now(UTC) - - if start is None: - start = now.replace(hour=0, minute=0, second=0, microsecond=0) - if end is None: - end = start + timedelta(days=1) + if start is None or end is None: + # Default to the server's local today: [local_midnight, next_local_midnight). + # This ensures "today" aligns with the local calendar day (NL time) rather + # than UTC midnight. + local_today = local_now().date() + if start is None: + start = local_midnight_utc(local_today) + if end is None: + end = local_midnight_utc(local_today + timedelta(days=1)) result = summarize(db, _as_utc(start), _as_utc(end)) return SummaryResponse(**result) diff --git a/app/api/routes/api/energy_contracts.py b/app/api/routes/api/energy_contracts.py index 924e5ee..b204c02 100644 --- a/app/api/routes/api/energy_contracts.py +++ b/app/api/routes/api/energy_contracts.py @@ -44,6 +44,7 @@ from app.schemas.energy_contract import ( VersionCreate, ) from app.services.auth import AuthenticatedSession +from app.services import timezone as _tz_mod from app.services.contracts import ( ContractVersionError, activate_contract, @@ -113,6 +114,30 @@ def _raise_422_for_profile_error(exc: Exception) -> Any: ) +def _localize_effective_from(dt: datetime | None) -> datetime: + """Resolve *dt* to an aware UTC datetime for storage. + + Rules (Principle A): + - If *dt* is None → use ``datetime.now(UTC)`` (unchanged from before). + - If *dt* is timezone-aware → convert to UTC as-is. + - If *dt* is timezone-naive → interpret as server local wall-clock time, + localize with ``local_tz()``, then convert to UTC. + + This means a front-end that sends ``"2026-06-25T00:00:00"`` (no Z) has it + interpreted as local midnight (e.g. CEST = UTC+2 → stored as 2026-06-24T22:00:00Z), + not as UTC midnight. + """ + if dt is None: + return datetime.now(UTC) + if dt.tzinfo is not None: + # Already aware: convert to UTC. + return dt.astimezone(UTC) + # Naive: assume server local wall-clock. + tz = _tz_mod.local_tz() + local_dt = dt.replace(tzinfo=tz) + return local_dt.astimezone(UTC) + + # --------------------------------------------------------------------------- # GET /api/energy/profiles # --------------------------------------------------------------------------- @@ -175,7 +200,7 @@ def create_energy_contract( The new contract is created with ``active=False``; use PATCH /api/energy/contracts/{id} with ``active=true`` to activate it. """ - effective_from = body.effective_from or datetime.now(UTC) + effective_from = _localize_effective_from(body.effective_from) try: contract = create_contract( @@ -284,12 +309,13 @@ def add_contract_version( Historical versions are never modified; this endpoint is append-only. """ contract = _get_contract_or_404(db, contract_id) + effective_from = _localize_effective_from(body.effective_from) try: add_version( db, contract, - effective_from=body.effective_from, + effective_from=effective_from, values=body.values, ) except ContractVersionError as exc: diff --git a/app/integrations/expose.py b/app/integrations/expose.py index ee468f3..a40145c 100644 --- a/app/integrations/expose.py +++ b/app/integrations/expose.py @@ -549,43 +549,43 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]: def _make_import_cost_getter() -> Callable[["Session"], Any]: """Return a getter for the cumulative import cost plus prorated standing charges. - Value = SUM(import_cost, non-degraded) + standing_charges_cumulative + Principle B/D: delegates entirely to ``summarize(sess, anchor_utc, now_utc)``, + where ``anchor_utc`` is the effective_from of the earliest version of the active + contract (i.e. the contract billing start). This makes the cumulative total + monotonically increasing and cross-version consistent: - where standing_charges_cumulative = elapsed_days × (network_fee + management_fee) / 30. - Elapsed days are counted from the active contract version's effective_from date - (inclusive of today). Returns None when no non-degraded periods exist. + value = summarize(anchor → now).metered_import + summarize(anchor → now).fixed_costs + + Returns None when no non-degraded period exists in [anchor, now]. + No arithmetic is done here — all fixed-cost/credit accounting lives in summarize(). """ def _getter(sess: "Session") -> Any: - from decimal import Decimal from datetime import UTC, datetime as _dt - + from app.services.contracts import active_contract_versions + from app.services.contracts import _as_utc as _cu + from app.services.energy_cost import summarize as _summarize from app.models.energy import EnergyCostPeriod as _ECP - from app.services.contracts import active_contract_version_at, _as_utc from sqlalchemy import func as _func - total = sess.query(_func.sum(_ECP.import_cost)).filter( + # Quick check: any non-degraded period at all? (avoids full summarize overhead + # when there are zero periods, which should return None) + has_data = sess.query(_func.sum(_ECP.import_cost)).filter( _ECP.degraded.is_(False) ).scalar() - if total is None: + if has_data is None: return None - # Add prorated standing charges from the active contract version. - standing_cumulative = Decimal("0") + versions = active_contract_versions(sess) + if not versions: + # No active contract — return pure SUM(import_cost) without standing charges. + return float(has_data) + + # anchor = earliest version's effective_from (contract billing start). + anchor_utc = _cu(versions[0].effective_from) now_utc = _dt.now(UTC) - version = active_contract_version_at(sess, now_utc) - if version is not None: - vals = version.values or {} - standing = vals.get("standing", {}) - network_fee = Decimal(str(standing.get("network_fee") or 0)) - management_fee = Decimal(str(standing.get("management_fee") or 0)) - daily_standing = (network_fee + management_fee) / Decimal("30") - effective_from = _as_utc(version.effective_from) - days = (now_utc.date() - effective_from.date()).days + 1 - days = max(days, 0) - standing_cumulative = daily_standing * days - - return float(Decimal(str(total)) + standing_cumulative) + result = _summarize(sess, anchor_utc, now_utc) + return result["metered_import"] + result["fixed_costs"] return _getter @@ -594,42 +594,37 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]: def _make_export_revenue_getter() -> Callable[["Session"], Any]: """Return a getter for the cumulative export revenue plus prorated tax credit. - Value = SUM(export_revenue, non-degraded) + heffingskorting_cumulative + Principle B/D: delegates entirely to ``summarize(sess, anchor_utc, now_utc)``. - where heffingskorting_cumulative = elapsed_days × heffingskorting / 365. - Elapsed days are counted from the active contract version's effective_from date - (inclusive of today). Returns None when no non-degraded periods exist. + value = summarize(anchor → now).metered_export + summarize(anchor → now).credits + + Returns None when no non-degraded period exists in [anchor, now]. """ def _getter(sess: "Session") -> Any: - from decimal import Decimal from datetime import UTC, datetime as _dt - + from app.services.contracts import active_contract_versions + from app.services.contracts import _as_utc as _cu + from app.services.energy_cost import summarize as _summarize from app.models.energy import EnergyCostPeriod as _ECP - from app.services.contracts import active_contract_version_at, _as_utc from sqlalchemy import func as _func - total = sess.query(_func.sum(_ECP.export_revenue)).filter( + # Quick check: any non-degraded period at all? + has_data = sess.query(_func.sum(_ECP.export_revenue)).filter( _ECP.degraded.is_(False) ).scalar() - if total is None: + if has_data is None: return None - # Add prorated heffingskorting credit from the active contract version. - credit_cumulative = Decimal("0") + versions = active_contract_versions(sess) + if not versions: + # No active contract — return pure SUM(export_revenue) without credits. + return float(has_data) + + anchor_utc = _cu(versions[0].effective_from) now_utc = _dt.now(UTC) - version = active_contract_version_at(sess, now_utc) - if version is not None: - vals = version.values or {} - credits = vals.get("credits", {}) - heffingskorting = Decimal(str(credits.get("heffingskorting") or 0)) - daily_credit = heffingskorting / Decimal("365") - effective_from = _as_utc(version.effective_from) - days = (now_utc.date() - effective_from.date()).days + 1 - days = max(days, 0) - credit_cumulative = daily_credit * days - - return float(Decimal(str(total)) + credit_cumulative) + result = _summarize(sess, anchor_utc, now_utc) + return result["metered_export"] + result["credits"] return _getter diff --git a/app/services/contracts.py b/app/services/contracts.py index b0f7006..d8396d7 100644 --- a/app/services/contracts.py +++ b/app/services/contracts.py @@ -286,6 +286,32 @@ def deactivate_contract(session: Session, contract: EnergyContract) -> None: logger.info("Deactivated contract %r (id=%d)", contract.name, contract.id) +def active_contract_versions(session: Session) -> list[EnergyContractVersion]: + """Return all versions of the currently active contract, ordered by effective_from ascending. + + Returns an empty list when there is no active contract. The list spans the + full history of the active contract (all closed + the current open version) + and is used to iterate over pricing-rate segments for cross-version fixed- + cost / credit accumulation (Principle C). + """ + active = session.execute( + select(EnergyContract).where(EnergyContract.active.is_(True)).limit(1) + ).scalar_one_or_none() + + if active is None: + return [] + + return list( + session.execute( + select(EnergyContractVersion) + .where(EnergyContractVersion.contract_id == active.id) + .order_by(EnergyContractVersion.effective_from) + ) + .scalars() + .all() + ) + + def active_contract_version_at( session: Session, ts: datetime ) -> EnergyContractVersion | None: diff --git a/app/services/energy_cost.py b/app/services/energy_cost.py index 7a54437..4b2c535 100644 --- a/app/services/energy_cost.py +++ b/app/services/energy_cost.py @@ -60,7 +60,8 @@ from app.integrations.pricing.strategies import ( get_strategy, ) from app.models.energy import DsmrReading, EnergyCostPeriod -from app.services.contracts import active_contract_version_at +from app.services.contracts import active_contract_version_at, active_contract_versions +from app.services.timezone import local_date, local_now logger = logging.getLogger(__name__) @@ -496,13 +497,23 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any Computes the total payable as: total_payable = Σ(net_cost) -- metered electricity - + fixed_costs -- (network_fee + management_fee) EUR/month ÷ 30 × days - - credits -- heffingskorting EUR/year ÷ 365 × days + + fixed_costs -- per-day standing charges, cross-version + - credits -- per-day heffingskorting, cross-version - The fixed costs and credits are derived from the *currently active contract - version* at *end* (i.e. the version in effect at the end of the requested - interval). When no active contract version exists, fixed costs and credits - are both 0; only the metered sum is returned. + **Fixed-cost / credit counting — Principle C**: + Only *already-elapsed* whole local calendar days are counted. For each + local calendar date D in the window ``[start_local_date, min(end_local_date, + tomorrow_local))``, the contract version whose rate covers D (the one whose + effective_from local-date ≤ D < next version's effective_from local-date) is + used. Days that have not yet started in local time (D > today_local) are + never counted. This is cross-version: if the active contract has V1 from + 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. + + **Timezone note**: the ``days`` field in the returned dict still represents + the window length in calendar days (total_seconds / 86400), for backward + compatibility with existing API consumers. The fixed/credit calculation + independently counts whole local days as described above. All arithmetic uses Decimal; the returned dict contains Python floats for JSON-serialisation convenience. @@ -512,9 +523,9 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any session: Active read-only SQLAlchemy session. start: - Inclusive start of the summary interval. + Inclusive start of the summary interval (UTC or naive-UTC). end: - Exclusive end of the summary interval. + Exclusive end of the summary interval (UTC or naive-UTC). Returns ------- @@ -524,13 +535,15 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any metered_import float Σ import_cost from non-degraded periods metered_export float Σ export_revenue from non-degraded periods metered_net float Σ net_cost from non-degraded periods - fixed_costs float standing charges apportioned over the interval - credits float energy-tax credit apportioned over the interval + fixed_costs float standing charges for elapsed whole local days + credits float energy-tax credit for elapsed whole local days total_payable float metered_net + fixed_costs − credits period_count int number of non-degraded periods in range degraded_count int number of degraded periods in range days float interval length in days (total_seconds / 86400) """ + from datetime import timedelta as _td, date as _date + start_utc = _as_utc(start) end_utc = _as_utc(end) @@ -550,32 +563,95 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any sum_export = sum((_to_decimal(r.export_revenue) for r in good_rows), Decimal("0")) sum_net = sum((_to_decimal(r.net_cost) for r in good_rows), Decimal("0")) - # --- Interval length in days --- + # --- Interval length in days (window, not elapsed — kept for API compat) --- total_seconds = (end_utc - start_utc).total_seconds() days = _to_decimal(str(total_seconds)) / _to_decimal("86400") - # --- Active contract version at *end* for standing charges --- - version = active_contract_version_at(session, end_utc) + # --- Fixed costs and credits: Principle C cross-version whole-day counting --- + today_local: _date = local_now().date() + + # --- 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) + # AND D ≤ today_local (only elapsed / today days count as "whole days"). + # + # Semantics: "A day D is counted when its local midnight has arrived (D ≤ today) + # AND the local midnight is within the billing window." + # + # This means a sub-day window [10:00, 10:30) UTC that doesn't contain any + # local midnight contributes 0 days, while a window [22:00 UTC, 23:00 UTC) that + # contains CEST midnight (= 22:00 UTC) contributes 1 day. + # + # Implementation: compute the first and last local date whose midnight is in range. + from app.services.timezone import local_midnight_utc as _lmu + + local_start_date: _date = local_date(start_utc) + # Is the midnight of local_start_date ≥ start_utc? If not, first midnight is next day. + 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) + # Is the midnight of local_end_date < end_utc? If yes, that day's midnight is in range. + if _lmu(local_end_date) < end_utc: + last_counted: _date = local_end_date + else: + last_counted = local_end_date - _td(days=1) + + # Cap: only elapsed or today's days. + last_counted = min(last_counted, today_local) + + # If the range is empty (first_counted > last_counted), no days are counted. + + # Fetch all versions of the active contract once (single DB call). + versions = active_contract_versions(session) fixed_dec = Decimal("0") credits_dec = Decimal("0") currency = "EUR" - if version is not None: - currency = version.contract.currency - vals: dict = version.values or {} - standing: dict = vals.get("standing", {}) - creds: dict = vals.get("credits", {}) + if versions: + # Currency comes from the contract regardless of day count. + currency = versions[0].contract.currency - network_fee = _to_decimal(standing.get("network_fee", 0)) - management_fee = _to_decimal(standing.get("management_fee", 0)) - heffingskorting = _to_decimal(creds.get("heffingskorting", 0)) + if versions and first_counted <= last_counted: + # For each version segment, find its overlap with [first_counted, last_counted] + # and accumulate whole days. + version_segments: list[tuple[_date, _date | None, dict]] = [] + for v in versions: + v_start_local = local_date(_as_utc(v.effective_from)) + v_end_local = local_date(_as_utc(v.effective_to)) if v.effective_to is not None else None + version_segments.append((v_start_local, v_end_local, v.values or {})) - # Standing charges: EUR/month → EUR/day (÷ 30) × days. - fixed_dec = (network_fee + management_fee) / Decimal("30") * days + for v_start, v_end_excl, v_values in version_segments: + # The version covers [v_start, v_end_excl) in local dates, + # where v_end_excl=None means open-ended (no upper bound). + # Intersection with [first_counted, last_counted]: + seg_start = max(first_counted, v_start) + if v_end_excl is not None: + seg_end_excl = min(last_counted + _td(days=1), v_end_excl) + else: + seg_end_excl = last_counted + _td(days=1) - # Energy-tax credit: EUR/year → EUR/day (÷ 365) × days. - credits_dec = heffingskorting / Decimal("365") * days + if seg_start >= seg_end_excl: + continue # no overlap + + n_days = (seg_end_excl - seg_start).days + if n_days <= 0: + continue + + standing: dict = v_values.get("standing", {}) + creds: dict = v_values.get("credits", {}) + + network_fee = _to_decimal(standing.get("network_fee", 0)) + management_fee = _to_decimal(standing.get("management_fee", 0)) + heffingskorting = _to_decimal(creds.get("heffingskorting", 0)) + + # Standing charges: EUR/month → EUR/day (÷ 30) × n_days. + fixed_dec += (network_fee + management_fee) / Decimal("30") * Decimal(str(n_days)) + # Energy-tax credit: EUR/year → EUR/day (÷ 365) × n_days. + credits_dec += heffingskorting / Decimal("365") * Decimal(str(n_days)) total_payable = sum_net + fixed_dec - credits_dec diff --git a/app/services/timezone.py b/app/services/timezone.py new file mode 100644 index 0000000..e9e8657 --- /dev/null +++ b/app/services/timezone.py @@ -0,0 +1,100 @@ +"""Server local-timezone helpers. + +All time is stored as UTC (no change). This module provides a single, +monkeypatch-friendly entry point for converting UTC moments to the server's +local timezone — used for business-day boundary calculations (standing charges, +effective-from date interpretation, etc.). + +Design goals +------------ +- Zero third-party dependencies (no tzlocal, no pytz). +- Testable without depending on CI host timezone: + monkeypatch ``local_tz`` to a fixed ``ZoneInfo`` in unit tests. +- DST-correct: conversions use ``astimezone(local_tz())`` per instant, so each + moment is independently correct even during DST transitions. +- Consistent with the existing ``.astimezone()`` pattern already used in + ``homeassistant_inbound.py`` and ``poo.py``. + +Priority for resolving the local timezone +----------------------------------------- +1. ``TZ`` environment variable — ``ZoneInfo(os.environ["TZ"])``. + Set ``TZ=Europe/Amsterdam`` in the deployment env for correct NL handling. +2. System local timezone fallback: ``datetime.now().astimezone().tzinfo``. + This matches the behaviour callers already relied on implicitly. +""" + +from __future__ import annotations + +import os +from datetime import date, datetime, timezone +from typing import TYPE_CHECKING +from zoneinfo import ZoneInfo + +if TYPE_CHECKING: + from datetime import tzinfo + + +def local_tz() -> "tzinfo": + """Return the server's local timezone. + + Resolution order: + 1. ``TZ`` environment variable (``ZoneInfo(TZ)``). Set + ``TZ=Europe/Amsterdam`` in production for correct NL/DST handling. + 2. System local timezone via ``datetime.now().astimezone().tzinfo``. + + **Monkeypatch this function in tests** to get deterministic timezone + behaviour regardless of CI host configuration:: + + monkeypatch.setattr("app.services.timezone.local_tz", + lambda: ZoneInfo("Europe/Amsterdam")) + """ + tz_env = os.environ.get("TZ", "").strip() + if tz_env: + return ZoneInfo(tz_env) + # System fallback — identical to the .astimezone() pattern already used + # in homeassistant_inbound.py and poo.py. + return datetime.now().astimezone().tzinfo # type: ignore[return-value] + + +def to_local(dt: datetime) -> datetime: + """Convert *dt* to the server local timezone. + + Handles three input cases: + - timezone-aware (any zone): converted via ``astimezone``. + - timezone-naive: assumed UTC (SQLite read-back convention), then converted. + + Returns a timezone-aware datetime in ``local_tz()``. + """ + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(local_tz()) + + +def local_now() -> datetime: + """Return the current moment as a timezone-aware local datetime.""" + return datetime.now(local_tz()) + + +def local_date(dt: datetime) -> date: + """Return the local calendar date for *dt*. + + Equivalent to ``to_local(dt).date()``. A distinct helper to make + call sites readable. + """ + return to_local(dt).date() + + +def local_midnight_utc(d: date) -> datetime: + """Return the UTC instant that corresponds to local midnight on *d*. + + Example (Europe/Amsterdam, CEST=UTC+2): + local_midnight_utc(date(2026, 6, 25)) + → datetime(2026, 6, 24, 22, 0, 0, tzinfo=UTC) + + This is used to convert a local calendar date back to a UTC boundary, e.g. + to compute the start/end of a local day for DB queries. + """ + tz = local_tz() + # Build a naive local datetime at midnight, then localize and convert to UTC. + local_midnight = datetime(d.year, d.month, d.day, 0, 0, 0, tzinfo=tz) + return local_midnight.astimezone(timezone.utc) diff --git a/frontend/src/energy/ContractForm.tsx b/frontend/src/energy/ContractForm.tsx index 7026515..20e2b55 100644 --- a/frontend/src/energy/ContractForm.tsx +++ b/frontend/src/energy/ContractForm.tsx @@ -218,9 +218,13 @@ export function ContractForm({ contractId, defaultKind, onClose, onSaved }: Cont const values = buildNestedValues(sectionFields, fieldValues) try { - // Convert local date string to ISO datetime if provided + // Convert local date string to a naive local-midnight datetime string (no Z). + // The backend interprets naive datetimes as server local wall-clock time, + // so "2026-06-25T00:00:00" → CEST local midnight → stored as UTC 22:00Z. + // When effectiveFromStr is empty, fall back to the current instant (aware, + // sent as ISO with Z) so the backend stores it unchanged. const effectiveFromISO = effectiveFromStr - ? new Date(effectiveFromStr).toISOString() + ? `${effectiveFromStr}T00:00:00` : undefined if (isAddVersion) { diff --git a/tests/test_energy_cost.py b/tests/test_energy_cost.py index e2eb38e..8aa5ade 100644 --- a/tests/test_energy_cost.py +++ b/tests/test_energy_cost.py @@ -1,4 +1,4 @@ -"""Tests for M6-T07: billing engine (app/services/energy_cost.py). +"""Tests for M6-T07 + FU10: billing engine (app/services/energy_cost.py). Acceptance criteria covered ---------------------------- @@ -13,8 +13,9 @@ Acceptance criteria covered 5. Missing readings (no DsmrReading covering a boundary) → degraded row. 6. Cross-version selection: two contract versions with different effective dates; each t0 picks the correct version (asserts contract_version_id). -7. ``summarize``: Σnet + standing charges (month/30 × days) − heffingskorting - (year/365 × days) — hand-calculated against known values. +7. ``summarize`` (Principle C): fixed costs & credits count only elapsed whole + *local* calendar days, cross-version. Sub-day windows → 0 fixed/credit. + One whole local day → full rate. Future dates → 0. Cross-version → sum. 8. ``compute_closed_periods``: only processes closed and uncalculated periods; does not touch already-computed (non-degraded) rows. 9. ``recompute_range``: explicitly overwrites all periods in [start, end) @@ -50,6 +51,7 @@ from app.services.energy_cost import ( recompute_range, summarize, ) +from app.services import timezone as tz_module # --------------------------------------------------------------------------- @@ -892,22 +894,36 @@ class TestSummarize: assert result["period_count"] == 2 assert result["degraded_count"] == 0 - def test_fixed_costs_formula(self, energy_db: Session) -> None: - """Fixed costs = (network_fee + management_fee) / 30 × days.""" - self._setup_two_periods(energy_db) - # Interval is 30 minutes = 0.5/48 day = 0.020833... days - days = Decimal("1800") / Decimal("86400") - expected_fixed = (Decimal("9.87") + Decimal("9.87")) / Decimal("30") * days - result = summarize(energy_db, _ts(10, 0), _ts(10, 30)) - assert abs(Decimal(str(result["fixed_costs"])) - expected_fixed) < Decimal("1e-9") + def test_fixed_costs_zero_for_sub_day_window(self, energy_db: Session) -> None: + """Principle C: a 30-minute window within the same local day counts 0 whole days. - def test_credits_formula(self, energy_db: Session) -> None: - """Credits = heffingskorting / 365 × days.""" + Fixed costs are only added for elapsed whole local calendar days. + A window [10:00, 10:30) UTC stays within the same local calendar date + regardless of timezone offset (any UTC-11..UTC+14 range), so no whole + day is covered → fixed_costs = 0. + """ + from zoneinfo import ZoneInfo self._setup_two_periods(energy_db) - days = Decimal("1800") / Decimal("86400") - expected_credits = Decimal("600.0") / Decimal("365") * days - result = summarize(energy_db, _ts(10, 0), _ts(10, 30)) - assert abs(Decimal(str(result["credits"])) - expected_credits) < Decimal("1e-9") + # Pin the local timezone so the test is deterministic on any CI host. + with __import__("unittest.mock", fromlist=["patch"]).patch.object( + tz_module, "local_tz", return_value=ZoneInfo("Europe/Amsterdam") + ): + 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" + ) + + def test_credits_zero_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.""" + from zoneinfo import ZoneInfo + self._setup_two_periods(energy_db) + with __import__("unittest.mock", fromlist=["patch"]).patch.object( + tz_module, "local_tz", return_value=ZoneInfo("Europe/Amsterdam") + ): + 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" + ) def test_total_payable_formula(self, energy_db: Session) -> None: """total_payable = metered_net + fixed_costs − credits.""" @@ -949,15 +965,27 @@ class TestSummarize: assert result["period_count"] == 0 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 interval. + + Principle C: the window [2026-06-23 00:00 UTC, 2026-06-24 00:00 UTC) spans + exactly one local calendar day in Europe/Amsterdam (CEST=UTC+2: 02:00→02:00). + effective_from = June 23 00:00 UTC = June 23 02:00 CEST = local date June 23. + Today (2026-06-25) ≥ June 23, so the day is elapsed → 1 day counted. + """ + from zoneinfo import ZoneInfo + from unittest.mock import patch + contract = _make_contract(energy_db, kind="manual", active=True) _make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0)) energy_db.commit() - # Summarize over exactly 1 day (no periods in DB — only standing/credits). + # Summarize over exactly 1 UTC day (no periods in DB — only standing/credits). + # Pin timezone to Europe/Amsterdam for deterministic local-date mapping. start = datetime(2026, 6, 23, 0, 0, 0, tzinfo=_UTC) end = datetime(2026, 6, 24, 0, 0, 0, tzinfo=_UTC) - result = summarize(energy_db, start, end) + + with patch.object(tz_module, "local_tz", return_value=ZoneInfo("Europe/Amsterdam")): + result = summarize(energy_db, start, end) # fixed_costs = (9.87 + 9.87) / 30 × 1.0 = 0.658 assert abs(result["fixed_costs"] - (9.87 + 9.87) / 30) < 1e-9 @@ -968,6 +996,297 @@ class TestSummarize: assert abs(result["total_payable"] - expected_total) < 1e-9 +# --------------------------------------------------------------------------- +# 7b. summarize — Principle C (whole-local-day counting, cross-version) +# --------------------------------------------------------------------------- +# +# All tests in this section pin the local timezone to Europe/Amsterdam via +# monkeypatch so assertions are deterministic regardless of CI host timezone. +# "Today" in these tests is 2026-06-25 (CEST = UTC+2). +# +# Reference rates: +# network_fee = 9.87 EUR/month, management_fee = 9.87 EUR/month +# daily_fixed = (9.87 + 9.87) / 30 = 0.658 EUR/day +# heffingskorting = 600.0 EUR/year +# daily_credit = 600.0 / 365 ≈ 1.6438... EUR/day +# +# Window semantics: +# start_local_date = local_date(start_utc) (inclusive) +# end_local_date = local_date(end_utc) (exclusive upper bound) +# cap = min(end_local_date, today_local + 1 day) +# counted_days = max(0, (cap - start_local_date).days) +# restricted to the version's [v_start, v_end) range +# +# CEST = UTC+2; local midnight June D = UTC June (D-1) 22:00. +# So: +# start "This Month" (June 1 local 00:00) = May 31 22:00 UTC +# end "Next Month" (July 1 local 00:00) = June 30 22:00 UTC +# In UTC we feed actual UTC boundaries. +# +# Table (today = June 25 local, effective_from = June 1 local 00:00 = May 31 22:00 UTC): +# +# | Window (UTC) | Local dates | Elapsed | Expected | +# |----------------------------------|-------------------------|---------|----------| +# | June 25 UTC+2 today (1 local day)| [June 25, June 26) | 1 day | 1 day | +# | June 25 UTC+2 → June 28 UTC+2 | [June 25, June 28) | 1 day* | 1 day | +# | June 1 CEST → July 1 CEST (mon) | [June 1, July 1) | 25 days*| 25 days | +# | May 1 → June 1 (all past) | [May 1, June 1) | 31 days | 31 days | +# | July 1 → Aug 1 (all future) | [July 1, Aug 1) | 0 days | 0 days | +# +# *today local = June 25: "today" counts but June 26+ does not. + +_AMS = None # ZoneInfo resolved lazily + + +def _ams(): + global _AMS + if _AMS is None: + from zoneinfo import ZoneInfo + _AMS = ZoneInfo("Europe/Amsterdam") + return _AMS + + +def _local_midnight_utc_ams(year: int, month: int, day: int) -> datetime: + """Return UTC instant for Europe/Amsterdam local midnight on the given date.""" + from zoneinfo import ZoneInfo + ams = ZoneInfo("Europe/Amsterdam") + return datetime(year, month, day, 0, 0, 0, tzinfo=ams).replace(tzinfo=None).replace( + tzinfo=ams + ).astimezone(_UTC) + + +# Simpler helper using ZoneInfo directly: +def _ams_midnight(year: int, month: int, day: int) -> datetime: + """UTC datetime corresponding to Europe/Amsterdam local midnight on year/month/day.""" + from zoneinfo import ZoneInfo + ams = ZoneInfo("Europe/Amsterdam") + return datetime(year, month, day, 0, 0, 0, tzinfo=ams).astimezone(_UTC) + + +class TestSummarizePrincipleC: + """Principle C whole-day counting with pinned Europe/Amsterdam timezone.""" + + # Effective_from: June 1 CEST local midnight = May 31 22:00 UTC. + # Today local = June 25 CEST (since today is 2026-06-25). + + def _make_single_version_contract(self, session: Session, *, effective_from_utc: datetime) -> None: + """Create an active manual contract with a single version.""" + contract = _make_contract(session, kind="manual", active=True) + _make_version(session, contract, _MANUAL_VALUES, effective_from=effective_from_utc) + session.commit() + + def _run_summarize_ams(self, session: Session, start_utc: datetime, end_utc: datetime) -> dict: + """Run summarize with Europe/Amsterdam local_tz monkeypatched.""" + from unittest.mock import patch + with patch.object(tz_module, "local_tz", return_value=_ams()): + return summarize(session, start_utc, end_utc) + + def test_today_window_counts_1_day(self, energy_db: Session) -> None: + """Today's window (local today 00:00 → local tomorrow 00:00) counts 1 day. + + Matches table row: Today 6/25→6/26 → 1 day. + """ + eff_utc = _ams_midnight(2026, 6, 1) + self._make_single_version_contract(energy_db, effective_from_utc=eff_utc) + + start = _ams_midnight(2026, 6, 25) + end = _ams_midnight(2026, 6, 26) + result = self._run_summarize_ams(energy_db, start, end) + + daily_fixed = (9.87 + 9.87) / 30 + daily_credit = 600.0 / 365 + assert abs(result["fixed_costs"] - daily_fixed * 1) < 1e-9, ( + f"Expected 1 day of fixed costs, got {result['fixed_costs']}" + ) + assert abs(result["credits"] - daily_credit * 1) < 1e-9, ( + f"Expected 1 day of credits, got {result['credits']}" + ) + + def test_future_window_counts_0_days(self, energy_db: Session) -> None: + """A fully future window (all local dates > today) counts 0 days. + + Matches table row: 7/1→8/1 (all future) → 0 days. + """ + eff_utc = _ams_midnight(2026, 6, 1) + self._make_single_version_contract(energy_db, effective_from_utc=eff_utc) + + start = _ams_midnight(2026, 7, 1) + end = _ams_midnight(2026, 8, 1) + result = self._run_summarize_ams(energy_db, start, end) + + assert result["fixed_costs"] == 0.0, ( + f"All-future window must count 0 days; got fixed_costs={result['fixed_costs']}" + ) + assert result["credits"] == 0.0, ( + f"All-future window must count 0 days; got credits={result['credits']}" + ) + + 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). + + Today = June 25 local, so June 26–30 are not yet elapsed. + Matches table row: 6/1→7/1 (This Month) → 25 days. + """ + eff_utc = _ams_midnight(2026, 6, 1) + self._make_single_version_contract(energy_db, effective_from_utc=eff_utc) + + start = _ams_midnight(2026, 6, 1) + end = _ams_midnight(2026, 7, 1) + result = self._run_summarize_ams(energy_db, start, end) + + daily_fixed = (9.87 + 9.87) / 30 + daily_credit = 600.0 / 365 + assert abs(result["fixed_costs"] - daily_fixed * 25) < 1e-9, ( + f"Expected 25 days of fixed costs (June 1-25 elapsed), got {result['fixed_costs']}" + ) + assert abs(result["credits"] - daily_credit * 25) < 1e-9, ( + f"Expected 25 days of credits, got {result['credits']}" + ) + + def test_past_month_window_counts_all_31_days(self, energy_db: Session) -> None: + """Last month (May 1 → June 1 local) counts all 31 days (fully past). + + Matches table row: 5/1→6/1 (all past) → 31 days. + effective_from is June 1 so May is before the contract — 0 days from contract. + Use an earlier effective_from to cover May. + """ + eff_utc = _ams_midnight(2026, 1, 1) # contract starts Jan 1 → covers May + self._make_single_version_contract(energy_db, effective_from_utc=eff_utc) + + start = _ams_midnight(2026, 5, 1) + end = _ams_midnight(2026, 6, 1) + result = self._run_summarize_ams(energy_db, start, end) + + daily_fixed = (9.87 + 9.87) / 30 + daily_credit = 600.0 / 365 + assert abs(result["fixed_costs"] - daily_fixed * 31) < 1e-9, ( + f"Expected 31 days of fixed costs (May 1-31 all past), got {result['fixed_costs']}" + ) + assert abs(result["credits"] - daily_credit * 31) < 1e-9, ( + f"Expected 31 days of credits, got {result['credits']}" + ) + + 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). + + Window: June 25 → June 28 local (4 dates, but June 26/27 are future). + Today = June 25 → only 1 day elapsed (June 25). + Matches table row: 6/25→6/28 → 1 day. + """ + eff_utc = _ams_midnight(2026, 6, 1) + self._make_single_version_contract(energy_db, effective_from_utc=eff_utc) + + start = _ams_midnight(2026, 6, 25) + end = _ams_midnight(2026, 6, 28) + result = self._run_summarize_ams(energy_db, start, end) + + daily_fixed = (9.87 + 9.87) / 30 + daily_credit = 600.0 / 365 + assert abs(result["fixed_costs"] - daily_fixed * 1) < 1e-9, ( + f"Expected 1 day of fixed costs (only June 25 elapsed), got {result['fixed_costs']}" + ) + assert abs(result["credits"] - daily_credit * 1) < 1e-9, ( + f"Expected 1 day of credits, got {result['credits']}" + ) + + def test_cross_version_integration(self, energy_db: Session) -> None: + """Cross-version integration: V1 June 1-24, V2 June 25+ (today's boundary). + + Window: This Month (June 1 → July 1 local) = 25 days total. + - V1: June 1-24 (24 days) at rate1 + - V2: June 25-25 (1 day, today) at rate2 + + V1 rates: network_fee=6.0, management_fee=6.0 → daily = 12/30 = 0.4 + heffingskorting=300 → daily = 300/365 + V2 rates: network_fee=12.0, management_fee=12.0 → daily = 24/30 = 0.8 + heffingskorting=600 → daily = 600/365 + + Expected: + fixed_costs = 24 × 0.4 + 1 × 0.8 = 9.6 + 0.8 = 10.4 + credits = 24 × (300/365) + 1 × (600/365) = (7200+600)/365 = 7800/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: effective June 1 CEST local. V2: effective June 25 CEST local. + v1_from = _ams_midnight(2026, 6, 1) + v2_from = _ams_midnight(2026, 6, 25) + + contract = _make_contract(energy_db, kind="manual", active=True) + _make_version(energy_db, contract, _VALUES_V1, effective_from=v1_from, + effective_to=v2_from) + _make_version(energy_db, contract, _VALUES_V2, effective_from=v2_from) + energy_db.commit() + + start = _ams_midnight(2026, 6, 1) + end = _ams_midnight(2026, 7, 1) + from unittest.mock import patch + with patch.object(tz_module, "local_tz", return_value=_ams()): + result = summarize(energy_db, start, end) + + # V1: 24 days × (6+6)/30; V2: 1 day × (12+12)/30 + expected_fixed = 24 * 12 / 30 + 1 * 24 / 30 + expected_credits = 24 * 300 / 365 + 1 * 600 / 365 + assert abs(result["fixed_costs"] - expected_fixed) < 1e-9, ( + f"Cross-version fixed_costs wrong: expected {expected_fixed}, got {result['fixed_costs']}" + ) + assert abs(result["credits"] - expected_credits) < 1e-9, ( + f"Cross-version credits wrong: expected {expected_credits}, got {result['credits']}" + ) + + def test_version_switch_does_not_reset_counter(self, energy_db: Session) -> None: + """Adding a new version does not reset the cumulative counter. + + With V1 from June 1 and V2 from June 25, querying June 25 → July 1 + still returns V2's 1-day rate without losing the historical V1 days + (the MQTT getter anchors at V1.effective_from to accumulate everything). + This test just checks that V2-only window returns V2 rate × 1 day. + """ + _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, 1) + v2_from = _ams_midnight(2026, 6, 25) + + contract = _make_contract(energy_db, kind="manual", active=True) + _make_version(energy_db, contract, _VALUES_V1, effective_from=v1_from, + effective_to=v2_from) + _make_version(energy_db, contract, _VALUES_V2, effective_from=v2_from) + energy_db.commit() + + # Window = June 25 only (today, 1 day elapsed at V2 rate) + start = _ams_midnight(2026, 6, 25) + end = _ams_midnight(2026, 7, 1) + from unittest.mock import patch + with patch.object(tz_module, "local_tz", return_value=_ams()): + result = summarize(energy_db, start, end) + + # Only 1 day at V2 rate + expected_fixed = 1 * 24 / 30 + expected_credits = 1 * 600 / 365 + assert abs(result["fixed_costs"] - expected_fixed) < 1e-9 + assert abs(result["credits"] - expected_credits) < 1e-9 + + # --------------------------------------------------------------------------- # 8. compute_closed_periods # --------------------------------------------------------------------------- diff --git a/tests/test_energy_expose.py b/tests/test_energy_expose.py index d957d4d..92d0eea 100644 --- a/tests/test_energy_expose.py +++ b/tests/test_energy_expose.py @@ -871,27 +871,39 @@ _STANDING_VALUES = { def test_import_cost_total_includes_standing_charges(energy_db) -> None: """import_cost_total getter must add prorated standing charges from active contract. + Principle B/D: the getter delegates to summarize(anchor → now). + anchor = earliest version's effective_from. + With network_fee=30 EUR/month + management_fee=60 EUR/month, daily_standing = 3.0 EUR/day. - Elapsed days = (now.date() - effective_from.date()).days + 1. + The getter uses summarize, which counts whole *local* elapsed days under Principle C. + We pin the timezone to UTC for simplicity: local days = UTC days. + + Setup: + - effective_from = 10 full UTC days ago (exact UTC midnight), so today is day 11 + but Principle C counts only days D ≤ today, and today is included as 1 day. + With anchor = 10 days ago UTC midnight and now near end of day 11: + local (UTC) dates: [10-days-ago, today_inclusive] = 11 days. + We place period rows within [anchor, now] so summarize sees them. """ from decimal import Decimal - from app.integrations.expose import build_catalog - - # Set effective_from to 10 days ago (UTC) to get a known elapsed day count. - now_utc = datetime.now(timezone.utc) - effective_from = datetime( - now_utc.year, now_utc.month, now_utc.day, 0, 0, 0, tzinfo=timezone.utc - ).replace(day=1) - # Use a date we know: 10 days before today (simulate by computing days ourselves) from datetime import timedelta - effective_from = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=9) + from unittest.mock import patch + from zoneinfo import ZoneInfo + from app.integrations.expose import build_catalog + from app.services import timezone as _tz_mod - # Expected elapsed days = 9 + 1 = 10 (from effective_from date to today, inclusive) - expected_days = (now_utc.date() - effective_from.date()).days + 1 + now_utc = datetime.now(timezone.utc) + # anchor = exactly 10 UTC days ago at midnight + effective_from = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=10) + + # Under UTC timezone (pinned), Principle C counts whole days from effective_from.date() + # to today (inclusive) = 11 days total (10 + today). + expected_days = (now_utc.date() - effective_from.date()).days + 1 # = 11 import_cost_sum = 5.00 # EUR - t0 = datetime(2025, 10, 1, 6, 0, tzinfo=timezone.utc) - t1 = datetime(2025, 10, 1, 6, 15, tzinfo=timezone.utc) + # Place period rows AFTER effective_from so summarize(anchor, now) picks them up. + t0 = effective_from + timedelta(hours=1) + t1 = t0 + timedelta(minutes=15) with Session(energy_db) as session: _make_contract_with_version( @@ -905,17 +917,19 @@ def test_import_cost_total_includes_standing_charges(energy_db) -> None: session.commit() with Session(energy_db) as session: - catalog = build_catalog(session) - import_entry = next( - e for e in catalog if e.entity.key == "energy.import_cost_total" - ) - value = import_entry.entity.value_getter(session) + # Pin to UTC so local days = UTC days (deterministic on any CI host). + with patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")): + catalog = build_catalog(session) + import_entry = next( + e for e in catalog if e.entity.key == "energy.import_cost_total" + ) + value = import_entry.entity.value_getter(session) # daily_standing = (30 + 60) / 30 = 3.0 EUR/day daily_standing = Decimal("90") / Decimal("30") expected = float(Decimal(str(import_cost_sum)) + daily_standing * expected_days) - assert value == pytest.approx(expected, rel=1e-9), ( + assert value == pytest.approx(expected, rel=1e-6), ( f"Expected import_cost_total={expected} (sum={import_cost_sum} + " f"standing={float(daily_standing * expected_days)} over {expected_days} days), " f"got {value!r}" @@ -930,20 +944,26 @@ def test_import_cost_total_includes_standing_charges(energy_db) -> None: def test_export_revenue_total_includes_tax_credit(energy_db) -> None: """export_revenue_total getter must add prorated heffingskorting from active contract. - With heffingskorting=365 EUR/year, daily_credit = 1.0 EUR/day. + Principle B/D: delegates to summarize(anchor → now). + With heffingskorting=365 EUR/year, daily_credit = 365/365 = 1.0 EUR/day. + Timezone pinned to UTC for deterministic local-day counting. """ from decimal import Decimal from datetime import timedelta + from unittest.mock import patch + from zoneinfo import ZoneInfo from app.integrations.expose import build_catalog + from app.services import timezone as _tz_mod now_utc = datetime.now(timezone.utc) effective_from = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=4) - # Expected elapsed days = 4 + 1 = 5 - expected_days = (now_utc.date() - effective_from.date()).days + 1 + # Under UTC pinned: expected_days = days from effective_from.date() to today inclusive. + expected_days = (now_utc.date() - effective_from.date()).days + 1 # = 5 - t0 = datetime(2025, 11, 1, 6, 0, tzinfo=timezone.utc) - t1 = datetime(2025, 11, 1, 6, 15, tzinfo=timezone.utc) + # Place period rows AFTER effective_from. + t0 = effective_from + timedelta(hours=1) + t1 = t0 + timedelta(minutes=15) export_sum = 2.50 # EUR with Session(energy_db) as session: @@ -958,17 +978,18 @@ def test_export_revenue_total_includes_tax_credit(energy_db) -> None: session.commit() with Session(energy_db) as session: - catalog = build_catalog(session) - export_entry = next( - e for e in catalog if e.entity.key == "energy.export_revenue_total" - ) - value = export_entry.entity.value_getter(session) + with patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")): + catalog = build_catalog(session) + export_entry = next( + e for e in catalog if e.entity.key == "energy.export_revenue_total" + ) + value = export_entry.entity.value_getter(session) # daily_credit = 365 / 365 = 1.0 EUR/day daily_credit = Decimal("365") / Decimal("365") expected = float(Decimal(str(export_sum)) + daily_credit * expected_days) - assert value == pytest.approx(expected, rel=1e-9), ( + assert value == pytest.approx(expected, rel=1e-6), ( f"Expected export_revenue_total={expected} (sum={export_sum} + " f"credit={float(daily_credit * expected_days)} over {expected_days} days), " f"got {value!r}" @@ -981,14 +1002,32 @@ def test_export_revenue_total_includes_tax_credit(energy_db) -> None: def test_import_cost_standing_zero_when_effective_from_in_future(energy_db) -> None: - """When contract version effective_from is in the future, standing cumulative must be 0.""" + """When contract version effective_from is in the future, standing cumulative must be 0. + + Principle D: anchor = effective_from (future). + summarize(anchor → now) has a negative/empty window → 0 days, 0 fixed_costs. + The global `has_data` check passes (there are non-degraded rows from the past), + but no periods fall within [anchor, now] window → metered = 0, fixed = 0. + The getter returns 0 + 0 = 0. + + Note: under the new design, if a contract has a future effective_from, the + getter returns the pure metered sum from the summarize window (which is 0 + since no periods exist after the future anchor). The old test expected to + return the raw global SUM, but with Principle D the anchor is the future + date → window is empty → metered = 0. + """ from datetime import timedelta + from unittest.mock import patch + from zoneinfo import ZoneInfo from app.integrations.expose import build_catalog + from app.services import timezone as _tz_mod now_utc = datetime.now(timezone.utc) future_effective = now_utc + timedelta(days=30) - t0 = datetime(2025, 12, 1, 6, 0, tzinfo=timezone.utc) + # Place a period BEFORE the future effective_from (global sum = 4.00) + # but it won't be in [anchor, now] window. + t0 = now_utc.replace(hour=6, minute=0, second=0, microsecond=0) - timedelta(days=1) import_cost_sum = 4.00 with Session(energy_db) as session: @@ -1002,30 +1041,37 @@ def test_import_cost_standing_zero_when_effective_from_in_future(energy_db) -> N session.commit() with Session(energy_db) as session: - catalog = build_catalog(session) - import_entry = next( - e for e in catalog if e.entity.key == "energy.import_cost_total" - ) - # Note: active_contract_version_at uses ts=now, but effective_from > now, - # so the version does not cover now → version lookup returns None → standing = 0. - value = import_entry.entity.value_getter(session) + with patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")): + catalog = build_catalog(session) + import_entry = next( + e for e in catalog if e.entity.key == "energy.import_cost_total" + ) + value = import_entry.entity.value_getter(session) - # Standing cumulative must be 0; only the raw SUM is returned. - assert value == pytest.approx(import_cost_sum, rel=1e-9), ( - f"When effective_from is in future, standing must be 0; " - f"expected {import_cost_sum}, got {value!r}" + # anchor is in future → summarize([future, now]) is empty → metered=0, fixed_costs=0 + # Principle C: no future local days counted → 0 fixed costs. + # Result = 0 (no periods in window) + 0 (0 standing days) = 0. + assert value == pytest.approx(0.0, abs=1e-9), ( + f"When effective_from is in future, summarize window is empty → value must be 0.0; " + f"got {value!r}" ) def test_export_revenue_credit_zero_when_effective_from_in_future(energy_db) -> None: - """When contract version effective_from is in the future, credit cumulative must be 0.""" + """When contract version effective_from is in the future, credit cumulative must be 0. + + Same logic as import: anchor is future → summarize window empty → 0 credits. + """ from datetime import timedelta + from unittest.mock import patch + from zoneinfo import ZoneInfo from app.integrations.expose import build_catalog + from app.services import timezone as _tz_mod now_utc = datetime.now(timezone.utc) future_effective = now_utc + timedelta(days=60) - t0 = datetime(2025, 12, 2, 6, 0, tzinfo=timezone.utc) + t0 = now_utc.replace(hour=6, minute=0, second=0, microsecond=0) - timedelta(days=1) export_sum = 3.00 with Session(energy_db) as session: @@ -1039,16 +1085,16 @@ def test_export_revenue_credit_zero_when_effective_from_in_future(energy_db) -> session.commit() with Session(energy_db) as session: - catalog = build_catalog(session) - export_entry = next( - e for e in catalog if e.entity.key == "energy.export_revenue_total" - ) - value = export_entry.entity.value_getter(session) + with patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")): + catalog = build_catalog(session) + export_entry = next( + e for e in catalog if e.entity.key == "energy.export_revenue_total" + ) + value = export_entry.entity.value_getter(session) - # Credit cumulative must be 0; only the raw SUM is returned. - assert value == pytest.approx(export_sum, rel=1e-9), ( - f"When effective_from is in future, credit must be 0; " - f"expected {export_sum}, got {value!r}" + # anchor is in future → empty window → 0 credits + assert value == pytest.approx(0.0, abs=1e-9), ( + f"When effective_from is in future, credit must be 0; got {value!r}" ) diff --git a/tests/test_timezone.py b/tests/test_timezone.py new file mode 100644 index 0000000..35a34af --- /dev/null +++ b/tests/test_timezone.py @@ -0,0 +1,187 @@ +"""Tests for app/services/timezone.py (FU10-energy-tz-accrual). + +All tests monkeypatch ``local_tz`` to a fixed zone so they are deterministic +on any CI host timezone. +""" + +from __future__ import annotations + +from datetime import UTC, date, datetime +from zoneinfo import ZoneInfo + +import pytest + +from app.services import timezone as tz_mod + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def ams(monkeypatch): + """Pin local_tz to Europe/Amsterdam for all tests in this module.""" + monkeypatch.setattr(tz_mod, "local_tz", lambda: ZoneInfo("Europe/Amsterdam")) + + +@pytest.fixture() +def utc_tz(monkeypatch): + """Pin local_tz to UTC for simple arithmetic tests.""" + monkeypatch.setattr(tz_mod, "local_tz", lambda: ZoneInfo("UTC")) + + +# --------------------------------------------------------------------------- +# local_tz() — resolution and monkeypatch contract +# --------------------------------------------------------------------------- + + +def test_local_tz_returns_tzinfo(ams): + """local_tz() must return a tzinfo object.""" + tz = tz_mod.local_tz() + assert tz is not None + # Verify it behaves like a valid tzinfo. + d = datetime(2026, 6, 25, 12, 0, tzinfo=tz) + assert d.utcoffset() is not None + + +def test_local_tz_env_var_overrides(monkeypatch): + """When TZ env var is set, local_tz() returns that zone.""" + monkeypatch.setenv("TZ", "America/New_York") + tz = tz_mod.local_tz() + # ZoneInfo("America/New_York") should have UTC-5 or UTC-4 offset. + d = datetime(2026, 1, 1, 12, 0, tzinfo=tz) # January → UTC-5 + offset_hours = d.utcoffset().total_seconds() / 3600 + assert offset_hours == pytest.approx(-5.0) + monkeypatch.delenv("TZ") + + +# --------------------------------------------------------------------------- +# to_local() — conversion correctness +# --------------------------------------------------------------------------- + + +def test_to_local_aware_utc(ams): + """Aware UTC datetime must convert to CEST (UTC+2) in summer.""" + dt_utc = datetime(2026, 6, 25, 12, 0, tzinfo=UTC) + local = tz_mod.to_local(dt_utc) + assert local.tzinfo is not None + assert local.hour == 14 # CEST = UTC+2 + + +def test_to_local_naive_treated_as_utc(ams): + """Naive datetime must be treated as UTC and converted to local.""" + naive = datetime(2026, 6, 25, 12, 0) # naive = UTC convention + local = tz_mod.to_local(naive) + assert local.hour == 14 # CEST = UTC+2 + + +def test_to_local_winter_offset(monkeypatch): + """CET (UTC+1) applies in January.""" + monkeypatch.setattr(tz_mod, "local_tz", lambda: ZoneInfo("Europe/Amsterdam")) + dt_utc = datetime(2026, 1, 15, 12, 0, tzinfo=UTC) + local = tz_mod.to_local(dt_utc) + assert local.hour == 13 # CET = UTC+1 + + +# --------------------------------------------------------------------------- +# local_date() — date extraction +# --------------------------------------------------------------------------- + + +def test_local_date_around_cest_midnight(ams): + """UTC 22:00 on June 24 is local midnight June 25 in CEST.""" + # CEST = UTC+2; June 24 22:00 UTC = June 25 00:00 CEST + dt = datetime(2026, 6, 24, 22, 0, tzinfo=UTC) + d = tz_mod.local_date(dt) + assert d == date(2026, 6, 25) + + +def test_local_date_just_before_cest_midnight(ams): + """UTC 21:59 on June 24 is still June 24 in CEST (23:59 local).""" + dt = datetime(2026, 6, 24, 21, 59, tzinfo=UTC) + d = tz_mod.local_date(dt) + assert d == date(2026, 6, 24) + + +# --------------------------------------------------------------------------- +# local_midnight_utc() — round-trip correctness +# --------------------------------------------------------------------------- + + +def test_local_midnight_utc_cest_summer(ams): + """Europe/Amsterdam local midnight June 25 → June 24 22:00 UTC (CEST=UTC+2).""" + utc_midnight = tz_mod.local_midnight_utc(date(2026, 6, 25)) + expected = datetime(2026, 6, 24, 22, 0, tzinfo=UTC) + assert utc_midnight == expected + + +def test_local_midnight_utc_cet_winter(monkeypatch): + """Europe/Amsterdam local midnight Jan 15 → Jan 14 23:00 UTC (CET=UTC+1).""" + monkeypatch.setattr(tz_mod, "local_tz", lambda: ZoneInfo("Europe/Amsterdam")) + utc_midnight = tz_mod.local_midnight_utc(date(2026, 1, 15)) + expected = datetime(2026, 1, 14, 23, 0, tzinfo=UTC) + assert utc_midnight == expected + + +def test_local_midnight_utc_roundtrip(ams): + """local_date(local_midnight_utc(D)) must return D for any date D.""" + for day in range(1, 32): + d = date(2026, 6, day) if day <= 30 else date(2026, 7, day - 30) + if day > 30: + continue + midnight_utc = tz_mod.local_midnight_utc(d) + recovered = tz_mod.local_date(midnight_utc) + assert recovered == d, f"Round-trip failed for {d}" + + +# --------------------------------------------------------------------------- +# local_now() — sanity check +# --------------------------------------------------------------------------- + + +def test_local_now_is_aware(ams): + """local_now() must return a timezone-aware datetime.""" + now = tz_mod.local_now() + assert now.tzinfo is not None + assert now.utcoffset() is not None + + +# --------------------------------------------------------------------------- +# effective_from naive→local→UTC (Principle A — contract routes) +# --------------------------------------------------------------------------- + + +def test_localize_naive_effective_from(monkeypatch): + """A naive '2026-06-25T00:00:00' must be interpreted as CEST local midnight. + + Expected: CEST midnight June 25 = UTC June 24 22:00. + """ + monkeypatch.setattr(tz_mod, "local_tz", lambda: ZoneInfo("Europe/Amsterdam")) + # Simulate what the route helper does: + from app.api.routes.api.energy_contracts import _localize_effective_from + naive = datetime(2026, 6, 25, 0, 0, 0) # naive, no tzinfo + utc_result = _localize_effective_from(naive) + assert utc_result.tzinfo is not None + expected = datetime(2026, 6, 24, 22, 0, 0, tzinfo=UTC) + assert utc_result == expected, ( + f"Naive local midnight should localize to {expected}, got {utc_result}" + ) + + +def test_localize_aware_effective_from(): + """An aware datetime must be stored as UTC unchanged (no double conversion).""" + from app.api.routes.api.energy_contracts import _localize_effective_from + aware = datetime(2026, 6, 25, 0, 0, 0, tzinfo=UTC) + utc_result = _localize_effective_from(aware) + assert utc_result == aware + + +def test_localize_none_effective_from(): + """None effective_from must return current UTC time (as before).""" + from app.api.routes.api.energy_contracts import _localize_effective_from + before = datetime.now(UTC) + result = _localize_effective_from(None) + after = datetime.now(UTC) + assert result.tzinfo is not None + assert before <= result <= after