2 Commits
Author SHA1 Message Date
tliu93 96e85fb48e FU11: anchor cumulative cost at recording start; add daily-reset cost entities; prefill add-version form
pytest / test (push) Successful in 7m32s
frontend / frontend (push) Successful in 2m6s
- MQTT import_cost_total / export_revenue_total now anchor fixed-fee / tax-credit
  accrual at max(contract start, first recorded period), so standing charges for
  untracked pre-recording days are no longer folded into the running total.
- Add energy.import_cost_today / energy.export_revenue_today (monetary,
  total_increasing) that reset at server-local midnight, for per-day cost
  aggregation in Home Assistant alongside the running totals.
- ContractForm add-version mode prefills the contract's open version values, so
  only the fields that actually change need editing.
2026-06-25 12:45:24 +02:00
tliu93 b71009620a compose add tz 2026-06-25 11:48:45 +02:00
6 changed files with 691 additions and 63 deletions
+145 -7
View File
@@ -550,13 +550,17 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
"""Return a getter for the cumulative import cost plus prorated standing charges. """Return a getter for the cumulative import cost plus prorated standing charges.
Principle B/D: delegates entirely to ``summarize(sess, anchor_utc, now_utc)``, 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 where ``anchor_utc`` is the **max** of:
contract (i.e. the contract billing start). This makes the cumulative total - the earliest version's effective_from (contract billing start), and
monotonically increasing and cross-version consistent: - 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 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(). No arithmetic is done here — all fixed-cost/credit accounting lives in summarize().
""" """
def _getter(sess: "Session") -> Any: 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. # No active contract — return pure SUM(import_cost) without standing charges.
return float(has_data) return float(has_data)
# anchor = earliest version's effective_from (contract billing start). # anchor = max(contract billing start, earliest recording start).
anchor_utc = _cu(versions[0].effective_from) # 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) now_utc = _dt.now(UTC)
result = _summarize(sess, anchor_utc, 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. """Return a getter for the cumulative export revenue plus prorated tax credit.
Principle B/D: delegates entirely to ``summarize(sess, anchor_utc, now_utc)``. 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 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. # No active contract — return pure SUM(export_revenue) without credits.
return float(has_data) 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) now_utc = _dt.now(UTC)
result = _summarize(sess, anchor_utc, now_utc) result = _summarize(sess, anchor_utc, now_utc)
@@ -628,6 +653,85 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
return _getter 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: "<currency>/kWh" # Price unit string: "<currency>/kWh"
price_unit = f"{currency}/kWh" price_unit = f"{currency}/kWh"
@@ -672,6 +776,40 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
value_getter=_make_export_revenue_getter(), value_getter=_make_export_revenue_getter(),
state_class="total", 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 return entities
+2
View File
@@ -9,6 +9,7 @@ services:
volumes: volumes:
- ./data:/app/data - ./data:/app/data
- ./.env:/app/.env:ro - ./.env:/app/.env:ro
- /etc/localtime:/etc/localtime:ro
app: app:
container_name: home-automation-app container_name: home-automation-app
@@ -24,4 +25,5 @@ services:
volumes: volumes:
- ./data:/app/data - ./data:/app/data
- ./.env:/app/.env:ro - ./.env:/app/.env:ro
- /etc/localtime:/etc/localtime:ro
+89
View File
@@ -246,4 +246,93 @@ describe('ContractForm', () => {
expect(onClose).toHaveBeenCalled() 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(
<ContractForm contractId={42} defaultKind="manual" onClose={onClose} onSaved={onSaved} />,
)
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 })
})
}) })
+90 -14
View File
@@ -11,7 +11,7 @@
* + profile value fields for the contract's kind. * + profile value fields for the contract's kind.
*/ */
import { useState } from 'react' import { useState, useMemo } from 'react'
import { import {
Modal, Modal,
Stack, Stack,
@@ -27,7 +27,7 @@ import {
Divider, Divider,
Title, Title,
} from '@mantine/core' } from '@mantine/core'
import { useEnergyProfiles, useCreateContract, useAddContractVersion } from './hooks' import { useEnergyProfiles, useCreateContract, useAddContractVersion, useContractDetail } from './hooks'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Props // Props
@@ -136,6 +136,9 @@ export function ContractForm({ contractId, defaultKind, onClose, onSaved }: Cont
// Profiles query // Profiles query
const profilesQuery = useEnergyProfiles() 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 // Create mode fields
const [name, setName] = useState('') const [name, setName] = useState('')
const [selectedKind, setSelectedKind] = useState<string | null>(defaultKind ?? null) const [selectedKind, setSelectedKind] = useState<string | null>(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 // ISO date string "YYYY-MM-DD" for effective_from; empty = default to now
const [effectiveFromStr, setEffectiveFromStr] = useState('') const [effectiveFromStr, setEffectiveFromStr] = useState('')
// Dynamic profile field values: key = "<section>.<fieldPath>", value = number // Dynamic profile field values: key = "<section>.<fieldPath>", value = number.
const [fieldValues, setFieldValues] = useState<Record<string, number | string>>({}) // User edits are stored here; for add-version mode this is merged with prefillValues.
const [userEdits, setUserEdits] = useState<Record<string, number | string>>({})
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -166,24 +170,90 @@ export function ContractForm({ contractId, defaultKind, onClose, onSaved }: Cont
// Find the selected profile structure // Find the selected profile structure
const selectedProfile = profiles.find((p: Record<string, unknown>) => p.kind === effectiveKind) const selectedProfile = profiles.find((p: Record<string, unknown>) => p.kind === effectiveKind)
// Extract section → leaf fields mapping from the selected profile // Extract section → leaf fields mapping from the selected profile (memoized for stable reference).
const sectionFields: Record<string, LeafField[]> = {} const sectionFields = useMemo<Record<string, LeafField[]>>(() => {
if (selectedProfile) { if (!selectedProfile) return {}
const profileObj = selectedProfile as Record<string, unknown> const profileObj = selectedProfile as Record<string, unknown>
const result: Record<string, LeafField[]> = {}
for (const [key, val] of Object.entries(profileObj)) { for (const [key, val] of Object.entries(profileObj)) {
// Skip metadata fields
if (key === 'kind' || key === 'label') continue if (key === 'kind' || key === 'label') continue
if (typeof val === 'object' && val !== null && !isLeaf(val)) { if (typeof val === 'object' && val !== null && !isLeaf(val)) {
sectionFields[key] = extractLeafFields(val as Record<string, unknown>) result[key] = extractLeafFields(val as Record<string, unknown>)
}
}
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<Record<string, number | string> | 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<string, unknown>
}>
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<string, number | string> = {}
for (const [section, fields] of Object.entries(sectionFields)) {
const sectionVal = (openVersion.values as Record<string, unknown>)[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<string, unknown>)[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<string, number | string> =
isAddVersion && prefillValues !== null && Object.keys(userEdits).length === 0
? prefillValues
: userEdits
function handleFieldChange(key: string, val: number | string) { 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) { function initDefaults(kind: string | null) {
if (!kind) return if (!kind) return
const profile = profiles.find((p: Record<string, unknown>) => p.kind === kind) const profile = profiles.find((p: Record<string, unknown>) => 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) { async function handleSubmit(e: React.FormEvent) {
@@ -264,7 +334,7 @@ export function ContractForm({ contractId, defaultKind, onClose, onSaved }: Cont
size="md" size="md"
data-testid="contract-form-modal" data-testid="contract-form-modal"
> >
{profilesQuery.isLoading && ( {(profilesQuery.isLoading || (isAddVersion && contractDetailQuery.isLoading)) && (
<Center py="md"> <Center py="md">
<Loader size="sm" data-testid="contract-form-loading" /> <Loader size="sm" data-testid="contract-form-loading" />
</Center> </Center>
@@ -276,7 +346,13 @@ export function ContractForm({ contractId, defaultKind, onClose, onSaved }: Cont
</Alert> </Alert>
)} )}
{!profilesQuery.isLoading && ( {isAddVersion && contractDetailQuery.isError && !contractDetailQuery.isLoading && (
<Alert color="yellow" mb="sm" data-testid="contract-form-detail-error">
Could not load existing contract values for prefill fields start at 0.
</Alert>
)}
{!profilesQuery.isLoading && !(isAddVersion && contractDetailQuery.isLoading) && (
<form onSubmit={handleSubmit} data-testid="contract-form"> <form onSubmit={handleSubmit} data-testid="contract-form">
<Stack gap="sm"> <Stack gap="sm">
{/* Create mode fields */} {/* Create mode fields */}
+356 -34
View File
@@ -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: 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 2. Cumulative entities (import_cost_total / export_revenue_total) have
state_class="total" and device_class="monetary". state_class="total" and device_class="monetary".
3. All 4 energy_cost entities default to enabled=False (no toggle row). 3. Daily entities (import_cost_today / export_revenue_today) have
4. value_getter: buy/sell price from pricing snapshot for tibber and manual kinds. state_class="total_increasing" and device_class="monetary".
5. value_getter: cumulative SUM(import_cost) / SUM(export_revenue) from 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. non-degraded rows; degraded rows excluded.
6. value_getter returns None when no non-degraded period exists. 7. 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. 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 9. Integration: build_discovery_payload on an energy_cost entity does NOT raise
IndexError (validates 2-element identifiers). IndexError (validates 2-element identifiers).
9. Keys are stable fixed strings (not derived from mutable data or DB ids). 10. Keys are stable fixed strings (not derived from mutable data or DB ids).
10. Provider registered: energy_cost entities appear alongside modbus entities 11. Provider registered: energy_cost entities appear alongside modbus entities
in the full catalog. in the full catalog.
11. Standing charges prorated into import_cost_total getter. 12. Standing charges prorated into import_cost_total getter.
12. Tax credit (heffingskorting) prorated into export_revenue_total getter. 13. Tax credit (heffingskorting) prorated into export_revenue_total getter.
13. effective_from in future → standing/credit cumulative = 0. 14. effective_from in future → standing/credit cumulative = 0.
14. No active contract → getters fall back to pure SUM. 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 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: def test_build_catalog_contains_6_energy_cost_entities(energy_db) -> None:
"""build_catalog must include all 4 energy_cost sensor entities.""" """build_catalog must include all 6 energy_cost sensor entities (4 original + 2 daily)."""
from app.integrations.expose import build_catalog from app.integrations.expose import build_catalog
with Session(energy_db) as session: 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.sell_price_now",
"energy.import_cost_total", "energy.import_cost_total",
"energy.export_revenue_total", "energy.export_revenue_total",
"energy.import_cost_today",
"energy.export_revenue_today",
} }
assert expected_keys == energy_keys, ( assert expected_keys == energy_keys, (
f"Expected energy keys {expected_keys!r}, got {energy_keys!r}" 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: 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 from app.integrations.expose import build_catalog
with Session(energy_db) as session: with Session(energy_db) as session:
catalog = build_catalog(session) catalog = build_catalog(session)
energy_entries = [e for e in catalog if e.entity.key.startswith("energy.")] 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: for entry in energy_entries:
assert entry.entity.component == "sensor", ( assert entry.entity.component == "sensor", (
f"Expected component='sensor' for {entry.entity.key!r}, " 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: 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 from app.integrations.expose import build_catalog
with Session(energy_db) as session: with Session(energy_db) as session:
catalog = build_catalog(session) catalog = build_catalog(session)
energy_entries = [e for e in catalog if e.entity.key.startswith("energy.")] 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: for entry in energy_entries:
assert entry.enabled is False, ( assert entry.enabled is False, (
f"Entity {entry.entity.key!r} must default to enabled=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) catalog = build_catalog(session)
energy_entries = [e for e in catalog if e.entity.key.startswith("energy.")] 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: for entry in energy_entries:
# Must not raise — specifically no IndexError from identifiers[1] # 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) catalog = build_catalog(session)
energy_entries = [e for e in catalog if e.entity.key.startswith("energy.")] 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: for entry in energy_entries:
assert entry.entity.device.provides_availability is False 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.sell_price_now",
"energy.import_cost_total", "energy.import_cost_total",
"energy.export_revenue_total", "energy.export_revenue_total",
"energy.import_cost_today",
"energy.export_revenue_today",
} }
assert keys1 == expected_keys, ( assert keys1 == expected_keys, (
f"Energy keys must be exactly {expected_keys!r}, got {keys1!r}" 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: def test_import_cost_total_includes_standing_charges(energy_db) -> None:
"""import_cost_total getter must add prorated standing charges from active contract. """import_cost_total getter must add prorated standing charges from active contract.
Principle B/D: the getter delegates to summarize(anchor → now). Principle B/D + FU11 anchor guardrail: the getter delegates to
anchor = earliest version's effective_from. 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. 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. 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. We pin the timezone to UTC for simplicity: local days = UTC days.
Setup: Setup:
- effective_from = 10 full UTC days ago (exact UTC midnight), so today is day 11 - effective_from = 10 full UTC days ago (exact UTC midnight).
but Principle C counts only days D ≤ today, and today is included as 1 day. - First period placed AT effective_from (same UTC midnight), so recording_start =
With anchor = 10 days ago UTC midnight and now near end of day 11: contract_start → anchor = max(contract_start, recording_start) = contract_start.
local (UTC) dates: [10-days-ago, today_inclusive] = 11 days. - Under UTC pinned, Principle C counts days [effective_from.date(), today] = 11 days.
We place period rows within [anchor, now] so summarize sees them.
""" """
from decimal import Decimal from decimal import Decimal
from datetime import timedelta 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 expected_days = (now_utc.date() - effective_from.date()).days + 1 # = 11
import_cost_sum = 5.00 # EUR import_cost_sum = 5.00 # EUR
# Place period rows AFTER effective_from so summarize(anchor, now) picks them up. # Place period rows AT effective_from (= recording_start = contract_start → same anchor).
t0 = effective_from + timedelta(hours=1) # 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) t1 = t0 + timedelta(minutes=15)
with Session(energy_db) as session: 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: def test_export_revenue_total_includes_tax_credit(energy_db) -> None:
"""export_revenue_total getter must add prorated heffingskorting from active contract. """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. With heffingskorting=365 EUR/year, daily_credit = 365/365 = 1.0 EUR/day.
Timezone pinned to UTC for deterministic local-day counting. 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 decimal import Decimal
from datetime import timedelta 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. # Under UTC pinned: expected_days = days from effective_from.date() to today inclusive.
expected_days = (now_utc.date() - effective_from.date()).days + 1 # = 5 expected_days = (now_utc.date() - effective_from.date()).days + 1 # = 5
# Place period rows AFTER effective_from. # Place period rows AT effective_from → recording_start = contract_start = same anchor.
t0 = effective_from + timedelta(hours=1) t0 = effective_from
t1 = t0 + timedelta(minutes=15) t1 = t0 + timedelta(minutes=15)
export_sum = 2.50 # EUR 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), ( assert value == pytest.approx(0.1500), (
f"Tibber sell price must be 0.1500 regardless of tariff={tariff_val!r}, got {value!r}" 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"
)
+8 -7
View File
@@ -205,9 +205,10 @@ def test_register_provider_direct_call():
def test_build_catalog_empty_with_no_devices(expose_db): def test_build_catalog_empty_with_no_devices(expose_db):
"""build_catalog with no enabled modbus devices must contain no modbus entities. """build_catalog with no enabled modbus devices must contain no modbus entities.
The energy_cost provider is always registered and always produces its 4 entities The energy_cost provider is always registered and always produces its 6 entities
regardless of device state, so the catalog will not be empty. This test checks (4 original + 2 daily) regardless of device state, so the catalog will not be empty.
that no *modbus* entities are present when there are no enabled modbus devices. This test checks that no *modbus* entities are present when there are no enabled
modbus devices.
""" """
from app.integrations.expose import build_catalog 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) catalog = build_catalog(session)
# The modbus provider finds no enabled devices → no modbus entities. # 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.")] modbus_entities = [e for e in catalog if e.entity.key.startswith("modbus.")]
assert modbus_entities == [], ( assert modbus_entities == [], (
"Expected no modbus entities when no modbus devices are enabled" "Expected no modbus entities when no modbus devices are enabled"
) )
# The 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.")} energy_keys = {e.entity.key for e in catalog if e.entity.key.startswith("energy.")}
assert len(energy_keys) == 4, ( assert len(energy_keys) == 6, (
f"Expected exactly 4 energy_cost entities, got {energy_keys!r}" f"Expected exactly 6 energy_cost entities (4 original + 2 daily), got {energy_keys!r}"
) )