diff --git a/app/integrations/expose.py b/app/integrations/expose.py index a40145c..a00ffce 100644 --- a/app/integrations/expose.py +++ b/app/integrations/expose.py @@ -550,13 +550,17 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]: """Return a getter for the cumulative import cost plus prorated standing charges. 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 ``anchor_utc`` is the **max** of: + - the earliest version's effective_from (contract billing start), and + - the earliest non-degraded EnergyCostPeriod.period_start (recording start). + + This prevents counting fixed costs for the period before any data was recorded + (e.g. contract starts Jan 1 but data recording only starts Jun 17 — we do not + want to include ~€270 of standing charges for a period with no meter data). value = summarize(anchor → now).metered_import + summarize(anchor → now).fixed_costs - Returns None when no non-degraded period exists in [anchor, now]. + Returns None when no non-degraded period exists. No arithmetic is done here — all fixed-cost/credit accounting lives in summarize(). """ def _getter(sess: "Session") -> Any: @@ -580,8 +584,18 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]: # 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) + # anchor = max(contract billing start, earliest recording start). + # This prevents accumulating fixed costs for days with no meter data. + contract_anchor_utc = _cu(versions[0].effective_from) + earliest_period_start = sess.query(_func.min(_ECP.period_start)).filter( + _ECP.degraded.is_(False) + ).scalar() + if earliest_period_start is not None: + recording_anchor_utc = _cu(earliest_period_start) + anchor_utc = max(contract_anchor_utc, recording_anchor_utc) + else: + anchor_utc = contract_anchor_utc + now_utc = _dt.now(UTC) result = _summarize(sess, anchor_utc, now_utc) @@ -595,6 +609,7 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]: """Return a getter for the cumulative export revenue plus prorated tax credit. Principle B/D: delegates entirely to ``summarize(sess, anchor_utc, now_utc)``. + Anchor is the same max(contract_start, recording_start) logic as import getter. value = summarize(anchor → now).metered_export + summarize(anchor → now).credits @@ -620,7 +635,17 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]: # No active contract — return pure SUM(export_revenue) without credits. return float(has_data) - anchor_utc = _cu(versions[0].effective_from) + # anchor = max(contract billing start, earliest recording start). + contract_anchor_utc = _cu(versions[0].effective_from) + earliest_period_start = sess.query(_func.min(_ECP.period_start)).filter( + _ECP.degraded.is_(False) + ).scalar() + if earliest_period_start is not None: + recording_anchor_utc = _cu(earliest_period_start) + anchor_utc = max(contract_anchor_utc, recording_anchor_utc) + else: + anchor_utc = contract_anchor_utc + now_utc = _dt.now(UTC) result = _summarize(sess, anchor_utc, now_utc) @@ -628,6 +653,85 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]: return _getter + # --- value_getter: today's import cost (local-day window, resets at local midnight) --- + + def _make_import_cost_today_getter() -> Callable[["Session"], Any]: + """Return a getter for today's import cost (metered + standing for today). + + Window: [local today 00:00, local tomorrow 00:00) in UTC. + After local midnight the window rolls to the new day → value resets to that + day's running cost, implementing "resets at local midnight" semantics for HA. + + Uses timezone module via module-attribute access to preserve monkeypatch safety. + Returns None when no non-degraded period exists or no active contract. + """ + def _getter(sess: "Session") -> Any: + from app.services.contracts import active_contract_versions + from app.models.energy import EnergyCostPeriod as _ECP + from app.services.energy_cost import summarize as _summarize + from app.services import timezone as _tz_mod + from sqlalchemy import func as _func + from datetime import timedelta as _td + + # Quick check: any non-degraded period at all? + has_data = sess.query(_func.sum(_ECP.import_cost)).filter( + _ECP.degraded.is_(False) + ).scalar() + if has_data is None: + return None + + versions = active_contract_versions(sess) + if not versions: + return None + + # Today's window in UTC, using monkeypatch-safe module attribute calls. + today_local = _tz_mod.local_now().date() + tomorrow_local = today_local + _td(days=1) + today_start_utc = _tz_mod.local_midnight_utc(today_local) + tomorrow_start_utc = _tz_mod.local_midnight_utc(tomorrow_local) + + result = _summarize(sess, today_start_utc, tomorrow_start_utc) + return result["metered_import"] + result["fixed_costs"] + + return _getter + + # --- value_getter: today's export revenue (local-day window, resets at local midnight) --- + + def _make_export_revenue_today_getter() -> Callable[["Session"], Any]: + """Return a getter for today's export revenue (metered + tax credit for today). + + Same window semantics as import_cost_today. + Returns None when no non-degraded period exists or no active contract. + """ + def _getter(sess: "Session") -> Any: + from app.services.contracts import active_contract_versions + from app.models.energy import EnergyCostPeriod as _ECP + from app.services.energy_cost import summarize as _summarize + from app.services import timezone as _tz_mod + from sqlalchemy import func as _func + from datetime import timedelta as _td + + # Quick check: any non-degraded period at all? + has_data = sess.query(_func.sum(_ECP.export_revenue)).filter( + _ECP.degraded.is_(False) + ).scalar() + if has_data is None: + return None + + versions = active_contract_versions(sess) + if not versions: + return None + + today_local = _tz_mod.local_now().date() + tomorrow_local = today_local + _td(days=1) + today_start_utc = _tz_mod.local_midnight_utc(today_local) + tomorrow_start_utc = _tz_mod.local_midnight_utc(tomorrow_local) + + result = _summarize(sess, today_start_utc, tomorrow_start_utc) + return result["metered_export"] + result["credits"] + + return _getter + # Price unit string: "/kWh" price_unit = f"{currency}/kWh" @@ -672,6 +776,40 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]: value_getter=_make_export_revenue_getter(), state_class="total", ), + # Daily entities: reset at local midnight, state_class=total_increasing. + # + # device_class=monetary is correct here. The HA developer docs (long-term + # statistics section) prohibit MONETARY from being combined with + # state_class=measurement — they do NOT prohibit it with total or + # total_increasing. monetary+total_increasing is valid (the cumulative + # entities above use monetary+total with no issue). + # + # total_increasing is appropriate because these daily quantities are + # non-negative (import/export metered cost ≥ 0, fixed standing fee/tax + # credit ≥ 0) and monotonically non-decreasing within any given local day. + # When the local midnight rolls over the value drops back to ~1 day's fixed + # fee; HA interprets that decrease as a new cycle, giving correct daily + # aggregation without any explicit last_reset pipeline. + ExposableEntity( + key="energy.import_cost_today", + component="sensor", + device=device_info, + device_class="monetary", + unit=currency, + name="Energy Import Cost Today (incl. standing)", + value_getter=_make_import_cost_today_getter(), + state_class="total_increasing", + ), + ExposableEntity( + key="energy.export_revenue_today", + component="sensor", + device=device_info, + device_class="monetary", + unit=currency, + name="Energy Export Revenue Today (incl. tax credit)", + value_getter=_make_export_revenue_today_getter(), + state_class="total_increasing", + ), ] return entities diff --git a/frontend/src/energy/ContractForm.test.tsx b/frontend/src/energy/ContractForm.test.tsx index 31f7a95..812c175 100644 --- a/frontend/src/energy/ContractForm.test.tsx +++ b/frontend/src/energy/ContractForm.test.tsx @@ -246,4 +246,93 @@ describe('ContractForm', () => { expect(onClose).toHaveBeenCalled() }) + + it('prefills fields from the open version when in add-version mode', async () => { + // FU11: add-version mode should auto-prefill rate fields from the contract's + // current open version so the user only has to change what's different. + const CONTRACT_DETAIL = { + id: 42, + name: 'My Contract', + kind: 'manual', + active: true, + currency: 'EUR', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + versions: [ + { + id: 1, + contract_id: 42, + effective_from: '2026-01-01T00:00:00Z', + effective_to: null, // open version + values: { + energy: { + buy: { normal: 0.133, dal: 0.127 }, + sell: { normal: 0.05, dal: 0.05 }, + energy_tax: 0.11, + ode: 0.0, + }, + standing: { + network_fee: 30.0, + management_fee: 60.0, + }, + credits: { + heffingskorting: 365.0, + }, + }, + created_at: '2026-01-01T00:00:00Z', + }, + ], + } + + mockGet.mockImplementation((path: string) => { + if (path === '/api/energy/profiles') { + return Promise.resolve({ data: PROFILES_RESPONSE }) + } + if (path === '/api/energy/contracts/{contract_id}') { + return Promise.resolve({ data: CONTRACT_DETAIL }) + } + return Promise.resolve({ data: null }) + }) + + const onClose = vi.fn() + const onSaved = vi.fn() + + renderWithProviders( + , + ) + + const user = userEvent.setup() + + // Wait for the form to render and profile fields to appear + await waitFor(() => { + expect(screen.getByTestId('contract-section-energy')).toBeInTheDocument() + }, { timeout: 3000 }) + + // Wait for prefill to apply: both profile and contract detail queries must resolve. + // Fill in the required effective_from date and submit; the submitted values + // body.values should contain the prefilled rates from the open version. + mockPost.mockResolvedValue({ data: {} }) + + const effectiveInput = screen.getByTestId('contract-effective-from') + await user.type(effectiveInput, '2026-07-01') + + await user.click(screen.getByTestId('contract-form-submit')) + + // The submitted body should contain the prefilled values from CONTRACT_DETAIL + await waitFor(() => { + expect(mockPost).toHaveBeenCalledWith( + '/api/energy/contracts/{contract_id}/versions', + expect.objectContaining({ + body: expect.objectContaining({ + values: expect.objectContaining({ + standing: expect.objectContaining({ + network_fee: 30, + management_fee: 60, + }), + }), + }), + }), + ) + }, { timeout: 3000 }) + }) }) diff --git a/frontend/src/energy/ContractForm.tsx b/frontend/src/energy/ContractForm.tsx index 20e2b55..20af0eb 100644 --- a/frontend/src/energy/ContractForm.tsx +++ b/frontend/src/energy/ContractForm.tsx @@ -11,7 +11,7 @@ * + profile value fields for the contract's kind. */ -import { useState } from 'react' +import { useState, useMemo } from 'react' import { Modal, Stack, @@ -27,7 +27,7 @@ import { Divider, Title, } from '@mantine/core' -import { useEnergyProfiles, useCreateContract, useAddContractVersion } from './hooks' +import { useEnergyProfiles, useCreateContract, useAddContractVersion, useContractDetail } from './hooks' // --------------------------------------------------------------------------- // Props @@ -136,6 +136,9 @@ export function ContractForm({ contractId, defaultKind, onClose, onSaved }: Cont // Profiles query const profilesQuery = useEnergyProfiles() + // Contract detail query — only used in add-version mode to prefill from the open version. + const contractDetailQuery = useContractDetail(contractId ?? 0) + // Create mode fields const [name, setName] = useState('') const [selectedKind, setSelectedKind] = useState(defaultKind ?? null) @@ -143,8 +146,9 @@ export function ContractForm({ contractId, defaultKind, onClose, onSaved }: Cont // ISO date string "YYYY-MM-DD" for effective_from; empty = default to now const [effectiveFromStr, setEffectiveFromStr] = useState('') - // Dynamic profile field values: key = "
.", value = number - const [fieldValues, setFieldValues] = useState>({}) + // Dynamic profile field values: key = "
.", value = number. + // User edits are stored here; for add-version mode this is merged with prefillValues. + const [userEdits, setUserEdits] = useState>({}) const [error, setError] = useState(null) @@ -166,24 +170,90 @@ export function ContractForm({ contractId, defaultKind, onClose, onSaved }: Cont // Find the selected profile structure const selectedProfile = profiles.find((p: Record) => p.kind === effectiveKind) - // Extract section → leaf fields mapping from the selected profile - const sectionFields: Record = {} - if (selectedProfile) { + // Extract section → leaf fields mapping from the selected profile (memoized for stable reference). + const sectionFields = useMemo>(() => { + if (!selectedProfile) return {} const profileObj = selectedProfile as Record + const result: Record = {} for (const [key, val] of Object.entries(profileObj)) { - // Skip metadata fields if (key === 'kind' || key === 'label') continue if (typeof val === 'object' && val !== null && !isLeaf(val)) { - sectionFields[key] = extractLeafFields(val as Record) + result[key] = extractLeafFields(val as Record) } } - } + return result + }, [selectedProfile]) + + // Add-version prefill: compute the seeded values from the contract's open version. + // Returns the flat fieldValues dict or null when data is not ready. + const prefillValues = useMemo | null>(() => { + if (!isAddVersion) return null + if (contractDetailQuery.isLoading || contractDetailQuery.isError) return null + if (Object.keys(sectionFields).length === 0) return null + + const detail = contractDetailQuery.data + if (!detail || !detail.versions || detail.versions.length === 0) return null + + const versions = detail.versions as Array<{ + effective_from: string + effective_to: string | null + values: Record + }> + let openVersion = versions.find((v) => v.effective_to == null) + if (!openVersion) { + openVersion = [...versions].sort((a, b) => + a.effective_from < b.effective_from ? 1 : -1 + )[0] + } + if (!openVersion?.values) return null + + const seeded: Record = {} + for (const [section, fields] of Object.entries(sectionFields)) { + const sectionVal = (openVersion.values as Record)[section] + if (typeof sectionVal !== 'object' || sectionVal === null) continue + for (const leaf of fields) { + const parts = leaf.fieldPath.split('.') + let cursor: unknown = sectionVal + for (const part of parts) { + if (typeof cursor !== 'object' || cursor === null) { cursor = undefined; break } + cursor = (cursor as Record)[part] + } + if (cursor != null && (typeof cursor === 'number' || typeof cursor === 'string')) { + const numVal = typeof cursor === 'number' ? cursor : parseFloat(String(cursor)) + seeded[`${section}.${leaf.fieldPath}`] = isNaN(numVal) ? 0 : numVal + } + } + } + return Object.keys(seeded).length > 0 ? seeded : null + }, [ + isAddVersion, + contractDetailQuery.isLoading, + contractDetailQuery.isError, + contractDetailQuery.data, + sectionFields, + ]) + + // The effective field values: user edits override prefill; prefill is the base. + // In add-version mode: when the user has not yet edited any field (userEdits is empty), + // prefillValues is used as the initial base. Once the user edits any field, their + // full merged value (prefill + override) is stored in userEdits. + const fieldValues: Record = + isAddVersion && prefillValues !== null && Object.keys(userEdits).length === 0 + ? prefillValues + : userEdits function handleFieldChange(key: string, val: number | string) { - setFieldValues((prev) => ({ ...prev, [key]: val })) + // On first edit in add-version mode, copy prefill into userEdits so we don't + // lose the other prefilled values when the user changes one field. + setUserEdits((prev) => { + const base = isAddVersion && prefillValues !== null && Object.keys(prev).length === 0 + ? { ...prefillValues } + : prev + return { ...base, [key]: val } + }) } - // Initialize default values when profile changes + // Initialize default values when profile changes (create mode only) function initDefaults(kind: string | null) { if (!kind) return const profile = profiles.find((p: Record) => p.kind === kind) @@ -199,7 +269,7 @@ export function ContractForm({ contractId, defaultKind, onClose, onSaved }: Cont } } } - setFieldValues(defaults) + setUserEdits(defaults) } async function handleSubmit(e: React.FormEvent) { @@ -264,7 +334,7 @@ export function ContractForm({ contractId, defaultKind, onClose, onSaved }: Cont size="md" data-testid="contract-form-modal" > - {profilesQuery.isLoading && ( + {(profilesQuery.isLoading || (isAddVersion && contractDetailQuery.isLoading)) && (
@@ -276,7 +346,13 @@ export function ContractForm({ contractId, defaultKind, onClose, onSaved }: Cont )} - {!profilesQuery.isLoading && ( + {isAddVersion && contractDetailQuery.isError && !contractDetailQuery.isLoading && ( + + Could not load existing contract values for prefill — fields start at 0. + + )} + + {!profilesQuery.isLoading && !(isAddVersion && contractDetailQuery.isLoading) && (
{/* Create mode fields */} diff --git a/tests/test_energy_expose.py b/tests/test_energy_expose.py index 92d0eea..5604166 100644 --- a/tests/test_energy_expose.py +++ b/tests/test_energy_expose.py @@ -1,24 +1,29 @@ -"""Tests for M6-T08 + FU6: energy_cost expose provider + state publish. +"""Tests for M6-T08 + FU6 + FU11: energy_cost expose provider + state publish. Coverage: - 1. build_catalog contains all 4 energy_cost entities. + 1. build_catalog contains all 6 energy_cost entities (4 original + 2 daily). 2. Cumulative entities (import_cost_total / export_revenue_total) have state_class="total" and device_class="monetary". - 3. All 4 energy_cost entities default to enabled=False (no toggle row). - 4. value_getter: buy/sell price from pricing snapshot for tibber and manual kinds. - 5. value_getter: cumulative SUM(import_cost) / SUM(export_revenue) from + 3. Daily entities (import_cost_today / export_revenue_today) have + state_class="total_increasing" and device_class="monetary". + 4. All 6 energy_cost entities default to enabled=False (no toggle row). + 5. value_getter: buy/sell price from pricing snapshot for tibber and manual kinds. + 6. value_getter: cumulative SUM(import_cost) / SUM(export_revenue) from non-degraded rows; degraded rows excluded. - 6. value_getter returns None when no non-degraded period exists. - 7. MQTT not enabled → publish_states is a no-op (no raises, no publish calls). - 8. Integration: build_discovery_payload on an energy_cost entity does NOT raise + 7. value_getter returns None when no non-degraded period exists. + 8. MQTT not enabled → publish_states is a no-op (no raises, no publish calls). + 9. Integration: build_discovery_payload on an energy_cost entity does NOT raise IndexError (validates 2-element identifiers). - 9. Keys are stable fixed strings (not derived from mutable data or DB ids). - 10. Provider registered: energy_cost entities appear alongside modbus entities + 10. Keys are stable fixed strings (not derived from mutable data or DB ids). + 11. Provider registered: energy_cost entities appear alongside modbus entities in the full catalog. - 11. Standing charges prorated into import_cost_total getter. - 12. Tax credit (heffingskorting) prorated into export_revenue_total getter. - 13. effective_from in future → standing/credit cumulative = 0. - 14. No active contract → getters fall back to pure SUM. + 12. Standing charges prorated into import_cost_total getter. + 13. Tax credit (heffingskorting) prorated into export_revenue_total getter. + 14. effective_from in future → standing/credit cumulative = 0. + 15. No active contract → getters fall back to pure SUM (cumulative) or None (daily). + 16. FU11: anchor = max(contract_start, recording_start) — pre-recording fixed costs excluded. + 17. FU11: daily getter uses today's local window; returns None without active contract. + 18. FU11: daily getter None when no non-degraded periods. """ from __future__ import annotations @@ -127,8 +132,8 @@ def energy_db(tmp_path: Path): # --------------------------------------------------------------------------- -def test_build_catalog_contains_4_energy_cost_entities(energy_db) -> None: - """build_catalog must include all 4 energy_cost sensor entities.""" +def test_build_catalog_contains_6_energy_cost_entities(energy_db) -> None: + """build_catalog must include all 6 energy_cost sensor entities (4 original + 2 daily).""" from app.integrations.expose import build_catalog with Session(energy_db) as session: @@ -140,6 +145,8 @@ def test_build_catalog_contains_4_energy_cost_entities(energy_db) -> None: "energy.sell_price_now", "energy.import_cost_total", "energy.export_revenue_total", + "energy.import_cost_today", + "energy.export_revenue_today", } assert expected_keys == energy_keys, ( f"Expected energy keys {expected_keys!r}, got {energy_keys!r}" @@ -184,14 +191,14 @@ def test_cumulative_entities_have_monetary_device_class(energy_db) -> None: def test_all_energy_entities_are_sensors(energy_db) -> None: - """All 4 energy_cost entities must have component='sensor'.""" + """All 6 energy_cost entities must have component='sensor'.""" from app.integrations.expose import build_catalog with Session(energy_db) as session: catalog = build_catalog(session) energy_entries = [e for e in catalog if e.entity.key.startswith("energy.")] - assert len(energy_entries) == 4 + assert len(energy_entries) == 6 for entry in energy_entries: assert entry.entity.component == "sensor", ( f"Expected component='sensor' for {entry.entity.key!r}, " @@ -205,14 +212,14 @@ def test_all_energy_entities_are_sensors(energy_db) -> None: def test_energy_cost_entities_default_to_disabled(energy_db) -> None: - """All 4 energy_cost entities must default to enabled=False (no toggle row).""" + """All 6 energy_cost entities must default to enabled=False (no toggle row).""" from app.integrations.expose import build_catalog with Session(energy_db) as session: catalog = build_catalog(session) energy_entries = [e for e in catalog if e.entity.key.startswith("energy.")] - assert len(energy_entries) == 4 + assert len(energy_entries) == 6 for entry in energy_entries: assert entry.enabled is False, ( f"Entity {entry.entity.key!r} must default to enabled=False " @@ -629,7 +636,7 @@ def test_build_discovery_payload_no_index_error_for_energy_entities(energy_db) - catalog = build_catalog(session) energy_entries = [e for e in catalog if e.entity.key.startswith("energy.")] - assert len(energy_entries) == 4, "Expected 4 energy_cost entities in catalog" + assert len(energy_entries) == 6, "Expected 6 energy_cost entities in catalog" for entry in energy_entries: # Must not raise — specifically no IndexError from identifiers[1] @@ -666,7 +673,7 @@ def test_energy_cost_entities_omit_availability_so_ha_shows_them(energy_db) -> N catalog = build_catalog(session) energy_entries = [e for e in catalog if e.entity.key.startswith("energy.")] - assert len(energy_entries) == 4 + assert len(energy_entries) == 6 for entry in energy_entries: assert entry.entity.device.provides_availability is False @@ -761,6 +768,8 @@ def test_energy_entity_keys_are_stable_fixed_strings(energy_db) -> None: "energy.sell_price_now", "energy.import_cost_total", "energy.export_revenue_total", + "energy.import_cost_today", + "energy.export_revenue_today", } assert keys1 == expected_keys, ( f"Energy keys must be exactly {expected_keys!r}, got {keys1!r}" @@ -871,19 +880,18 @@ _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. + Principle B/D + FU11 anchor guardrail: the getter delegates to + summarize(anchor → now), where anchor = max(contract_start, recording_start). With network_fee=30 EUR/month + management_fee=60 EUR/month, daily_standing = 3.0 EUR/day. 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. + - effective_from = 10 full UTC days ago (exact UTC midnight). + - First period placed AT effective_from (same UTC midnight), so recording_start = + contract_start → anchor = max(contract_start, recording_start) = contract_start. + - Under UTC pinned, Principle C counts days [effective_from.date(), today] = 11 days. """ from decimal import Decimal from datetime import timedelta @@ -901,8 +909,9 @@ def test_import_cost_total_includes_standing_charges(energy_db) -> None: expected_days = (now_utc.date() - effective_from.date()).days + 1 # = 11 import_cost_sum = 5.00 # EUR - # Place period rows AFTER effective_from so summarize(anchor, now) picks them up. - t0 = effective_from + timedelta(hours=1) + # Place period rows AT effective_from (= recording_start = contract_start → same anchor). + # This ensures anchor = max(effective_from, effective_from) = effective_from exactly. + t0 = effective_from # first period starts exactly at contract effective_from t1 = t0 + timedelta(minutes=15) with Session(energy_db) as session: @@ -944,9 +953,12 @@ 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. - Principle B/D: delegates to summarize(anchor → now). + Principle B/D + FU11 anchor guardrail: delegates to summarize(anchor → now), + where anchor = max(contract_start, recording_start). With heffingskorting=365 EUR/year, daily_credit = 365/365 = 1.0 EUR/day. Timezone pinned to UTC for deterministic local-day counting. + + Setup: period placed AT effective_from → recording_start = contract_start → same anchor. """ from decimal import Decimal from datetime import timedelta @@ -961,8 +973,8 @@ def test_export_revenue_total_includes_tax_credit(energy_db) -> None: # Under UTC pinned: expected_days = days from effective_from.date() to today inclusive. expected_days = (now_utc.date() - effective_from.date()).days + 1 # = 5 - # Place period rows AFTER effective_from. - t0 = effective_from + timedelta(hours=1) + # Place period rows AT effective_from → recording_start = contract_start = same anchor. + t0 = effective_from t1 = t0 + timedelta(minutes=15) export_sum = 2.50 # EUR @@ -1373,3 +1385,313 @@ def test_tibber_sell_price_not_affected_by_tariff(energy_db, reset_tariff) -> No assert value == pytest.approx(0.1500), ( f"Tibber sell price must be 0.1500 regardless of tariff={tariff_val!r}, got {value!r}" ) + + +# --------------------------------------------------------------------------- +# FU11 new tests: daily entities metadata + getter behaviour +# --------------------------------------------------------------------------- + + +def test_daily_entities_in_catalog(energy_db) -> None: + """FU11: catalog must include import_cost_today and export_revenue_today.""" + from app.integrations.expose import build_catalog + + with Session(energy_db) as session: + catalog = build_catalog(session) + + daily_keys = { + "energy.import_cost_today", + "energy.export_revenue_today", + } + found = {e.entity.key for e in catalog if e.entity.key in daily_keys} + assert found == daily_keys, f"Expected daily keys {daily_keys!r} in catalog, got {found!r}" + + +def test_daily_entities_have_total_increasing_state_class(energy_db) -> None: + """FU11: daily entities must have state_class='total_increasing' (not 'total').""" + from app.integrations.expose import build_catalog + + with Session(energy_db) as session: + catalog = build_catalog(session) + + daily_keys = {"energy.import_cost_today", "energy.export_revenue_today"} + for entry in catalog: + if entry.entity.key in daily_keys: + assert entry.entity.state_class == "total_increasing", ( + f"Daily entity {entry.entity.key!r} must have state_class='total_increasing', " + f"got {entry.entity.state_class!r}" + ) + + +def test_daily_entities_have_monetary_device_class(energy_db) -> None: + """FU11: daily entities must have device_class='monetary' and state_class='total_increasing'. + + HA developer docs prohibit MONETARY with state_class=measurement, but explicitly + allow it with total and total_increasing. monetary+total_increasing is the correct + combination for a non-negative, monotonically non-decreasing daily quantity that + resets at local midnight. + """ + from app.integrations.expose import build_catalog + + with Session(energy_db) as session: + catalog = build_catalog(session) + + daily_keys = {"energy.import_cost_today", "energy.export_revenue_today"} + for entry in catalog: + if entry.entity.key in daily_keys: + assert entry.entity.device_class == "monetary", ( + f"Daily entity {entry.entity.key!r} must have device_class='monetary', " + f"got {entry.entity.device_class!r}" + ) + + +def test_daily_entities_default_disabled(energy_db) -> None: + """FU11: daily entities must default to enabled=False (no toggle row seeded).""" + from app.integrations.expose import build_catalog + + with Session(energy_db) as session: + catalog = build_catalog(session) + + daily_keys = {"energy.import_cost_today", "energy.export_revenue_today"} + for entry in catalog: + if entry.entity.key in daily_keys: + assert entry.enabled is False, ( + f"Daily entity {entry.entity.key!r} must default to enabled=False, " + f"got enabled={entry.enabled!r}" + ) + + +def test_import_cost_today_getter_uses_local_day_window(energy_db) -> None: + """FU11: import_cost_today getter returns value for today's local window. + + Setup: period at local 'today' (within today's UTC window), active contract. + Timezone pinned to UTC. Verifies: value = metered_import + fixed_costs for 1 day + (Principle C: today counts as elapsed if we're past local midnight — which UTC is). + """ + 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) + # Place a period in today's window (1 hour ago, well within today UTC midnight→tomorrow) + # When TZ=UTC, local today = UTC today, so a period 1h ago is in today's window. + t0 = now_utc.replace(minute=0, second=0, microsecond=0) - timedelta(hours=1) + # Ensure t0 is still today UTC (handle edge case where now is < 1h past UTC midnight) + if t0.date() < now_utc.date(): + t0 = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) + + # Contract starting well before today + effective_from = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30) + + import_cost_today = 1.23 + + with Session(energy_db) as session: + _make_contract_with_version( + session, + values=_STANDING_VALUES, + effective_from=effective_from, + active=True, + ) + _make_period(session, period_start=t0, import_cost=import_cost_today, degraded=False) + session.commit() + + with Session(energy_db) as session: + # Pin timezone to UTC so today_local = UTC date (deterministic on any CI host). + with patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")): + catalog = build_catalog(session) + today_entry = next( + e for e in catalog if e.entity.key == "energy.import_cost_today" + ) + value = today_entry.entity.value_getter(session) + + # Principle C: today counts as 1 day (we're within it, and it's "today"). + # daily_standing = (30+60)/30 = 3.0 EUR/day × 1 day = 3.0 + from decimal import Decimal + daily_standing = Decimal("90") / Decimal("30") # = 3.0 + expected = float(Decimal(str(import_cost_today)) + daily_standing * 1) + + assert value == pytest.approx(expected, rel=1e-6), ( + f"import_cost_today expected {expected}, got {value!r}" + ) + + +def test_export_revenue_today_getter_uses_local_day_window(energy_db) -> None: + """FU11: export_revenue_today getter returns value for today's local window.""" + 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) + t0 = now_utc.replace(minute=0, second=0, microsecond=0) - timedelta(hours=1) + if t0.date() < now_utc.date(): + t0 = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) + + effective_from = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30) + export_today = 0.75 + + with Session(energy_db) as session: + _make_contract_with_version( + session, + values=_STANDING_VALUES, + effective_from=effective_from, + active=True, + ) + _make_period(session, period_start=t0, export_revenue=export_today, degraded=False) + session.commit() + + with Session(energy_db) as session: + with patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")): + catalog = build_catalog(session) + today_entry = next( + e for e in catalog if e.entity.key == "energy.export_revenue_today" + ) + value = today_entry.entity.value_getter(session) + + from decimal import Decimal + daily_credit = Decimal("365") / Decimal("365") # = 1.0 EUR/day × 1 day + expected = float(Decimal(str(export_today)) + daily_credit * 1) + + assert value == pytest.approx(expected, rel=1e-6), ( + f"export_revenue_today expected {expected}, got {value!r}" + ) + + +def test_daily_getter_returns_none_when_no_active_contract(energy_db) -> None: + """FU11: daily getters return None when no active contract exists.""" + from app.integrations.expose import build_catalog + + t0 = datetime(2026, 6, 25, 10, 0, tzinfo=timezone.utc) + + with Session(energy_db) as session: + # Inactive contract + _make_contract_with_version( + session, + values=_STANDING_VALUES, + effective_from=t0, + active=False, + ) + _make_period(session, period_start=t0, import_cost=1.0, export_revenue=0.5, degraded=False) + session.commit() + + with Session(energy_db) as session: + catalog = build_catalog(session) + import_today_entry = next( + e for e in catalog if e.entity.key == "energy.import_cost_today" + ) + export_today_entry = next( + e for e in catalog if e.entity.key == "energy.export_revenue_today" + ) + import_val = import_today_entry.entity.value_getter(session) + export_val = export_today_entry.entity.value_getter(session) + + assert import_val is None, ( + f"import_cost_today must be None with no active contract, got {import_val!r}" + ) + assert export_val is None, ( + f"export_revenue_today must be None with no active contract, got {export_val!r}" + ) + + +def test_daily_getter_returns_none_when_no_non_degraded_periods(energy_db) -> None: + """FU11: daily getters return None when only degraded rows exist.""" + from app.integrations.expose import build_catalog + + t0 = datetime(2026, 6, 25, 10, 0, tzinfo=timezone.utc) + + with Session(energy_db) as session: + _make_contract_with_version( + session, + values=_STANDING_VALUES, + effective_from=t0 - datetime.resolution * 0, # same time is fine here + active=True, + ) + _make_period(session, period_start=t0, import_cost=0.0, degraded=True) + session.commit() + + with Session(energy_db) as session: + catalog = build_catalog(session) + import_today_entry = next( + e for e in catalog if e.entity.key == "energy.import_cost_today" + ) + value = import_today_entry.entity.value_getter(session) + + assert value is None, ( + f"import_cost_today must be None with only degraded rows, got {value!r}" + ) + + +# --------------------------------------------------------------------------- +# FU11 new test: anchor = max(contract_start, recording_start) +# --------------------------------------------------------------------------- + + +def test_cumulative_anchor_uses_recording_start_when_later_than_contract(energy_db) -> None: + """FU11: anchor = max(contract_start, recording_start) — no pre-recording fixed costs. + + Scenario: contract effective_from = 180 days ago (lots of standing charges if used + as anchor), but first non-degraded period_start = 7 days ago. Expected: anchor is + 7 days ago → only 8 days of standing charges (7 + today under Principle C). + + Without the fix, anchor would be 180 days ago → ~180 days × 3 EUR/day = €540+. + """ + 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) + midnight_today = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) + + # Contract starts 180 days ago (far before any data) + contract_start = midnight_today - timedelta(days=180) + # First (and only) data period starts 7 days ago + recording_start = midnight_today - timedelta(days=7) + + t0 = recording_start # first period at recording start + + with Session(energy_db) as session: + _make_contract_with_version( + session, + values=_STANDING_VALUES, + effective_from=contract_start, + active=True, + ) + _make_period(session, period_start=t0, import_cost=5.00, degraded=False) + session.commit() + + with Session(energy_db) as 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) + + # Anchor = recording_start (7 days ago UTC midnight). + # Principle C (UTC pinned): count local days from recording_start.date() to today inclusive. + # That's 7+1 = 8 days. + expected_days = (now_utc.date() - recording_start.date()).days + 1 # = 8 + daily_standing = Decimal("90") / Decimal("30") # = 3.0 EUR/day + expected = float(Decimal("5.00") + daily_standing * expected_days) + + # Without the fix, value would include ~180 days × 3 EUR/day = ~€540+ of extra standing. + # With the fix, value ≈ 5.00 + 3.0 × 8 = 29.00. + assert value == pytest.approx(expected, rel=1e-6), ( + f"Expected anchor=recording_start anchor, " + f"import_cost_total={expected} (metered=5.00 + standing={float(daily_standing*expected_days)} " + f"over {expected_days} days), got {value!r}" + ) + + # Also verify that using the old anchor (contract start) would give a very different result. + old_expected_days = (now_utc.date() - contract_start.date()).days + 1 # ~181 days + old_expected = float(Decimal("5.00") + daily_standing * old_expected_days) + assert abs(value - old_expected) > 100, ( + f"Old (wrong) anchor would yield ~{old_expected:.2f}, " + f"the new value {value:.2f} should differ by >€100" + ) diff --git a/tests/test_expose_catalog.py b/tests/test_expose_catalog.py index 29bafa8..fb6a7a8 100644 --- a/tests/test_expose_catalog.py +++ b/tests/test_expose_catalog.py @@ -205,9 +205,10 @@ def test_register_provider_direct_call(): def test_build_catalog_empty_with_no_devices(expose_db): """build_catalog with no enabled modbus devices must contain no modbus entities. - The energy_cost provider is always registered and always produces its 4 entities - regardless of device state, so the catalog will not be empty. This test checks - that no *modbus* entities are present when there are no enabled modbus devices. + The energy_cost provider is always registered and always produces its 6 entities + (4 original + 2 daily) regardless of device state, so the catalog will not be empty. + This test checks that no *modbus* entities are present when there are no enabled + modbus devices. """ from app.integrations.expose import build_catalog @@ -215,16 +216,16 @@ def test_build_catalog_empty_with_no_devices(expose_db): catalog = build_catalog(session) # The modbus provider finds no enabled devices → no modbus entities. - # The energy_cost provider always produces 4 entities, so the catalog is non-empty. + # 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.")] assert modbus_entities == [], ( "Expected no modbus entities when no modbus devices are enabled" ) - # The 4 energy_cost entities should always be present. + # The 6 energy_cost entities should always be present (4 original + 2 daily). energy_keys = {e.entity.key for e in catalog if e.entity.key.startswith("energy.")} - assert len(energy_keys) == 4, ( - f"Expected exactly 4 energy_cost entities, got {energy_keys!r}" + assert len(energy_keys) == 6, ( + f"Expected exactly 6 energy_cost entities (4 original + 2 daily), got {energy_keys!r}" )