3 Commits
10 changed files with 365 additions and 78 deletions
+4 -3
View File
@@ -185,9 +185,10 @@ def declare_energy_meter(
**Retroactive recompute**: if ``started_at`` is in the past, billing
records from that point forward are re-judged via ``recompute_range`` to
reflect the new meter attribution. The response includes the count of
recomputed periods in ``recomputed_periods`` (not part of ``MeterResponse``
— the recompute is transparent; callers should re-fetch costs if needed).
reflect the new meter attribution. The recompute is transparent — the
response body is the created meter (``MeterResponse``) only and does **not**
include a recompute count; callers should re-fetch costs if they need the
updated totals.
"""
started_at_utc = _localize_started_at(body.started_at)
+57 -23
View File
@@ -678,15 +678,33 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
+ fixed_costs -- per-day standing charges, cross-version
- credits -- per-day heffingskorting, cross-version
**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 130 uses V1 for days 1-24 and V2
for day 25. Switching versions never resets the counter.
**Fixed-cost / credit counting — Principle C (symmetric begin/end)**:
Both the local calendar day in which *start* falls and the local calendar
day in which *end* falls are counted as full days. Fixed charges
(network_fee, management_fee) and the energy-tax credit (heffingskorting)
are assessed on a "service-is-active" basis — if the meter was online on a
given calendar day, the full day's charge/credit applies, regardless of
whether the window starts at midnight or mid-morning.
For each local calendar date D in the range
``[local_date(start), min(local_date(end), today_local)]``:
- D ≤ today_local (only elapsed / today days count as "whole days").
- The contract version whose effective_from local-date ≤ D is used.
- This is cross-version: if V1 is from June 1 and V2 from June 25,
querying June 130 uses V1 for days 1-24 and V2 for day 25.
The end-day (last_counted) is included when the end's local midnight falls
strictly before end_utc; combined with the always-counted start day this
makes the begin/end handling symmetric. A short same-day window therefore
counts its single local day. A window contributes 0 days only when the
counted range is empty (first_counted > last_counted) — e.g. a window lying
entirely in the future, since last_counted is capped at today_local.
Daily getters (``*_today``) use windows exactly aligned to local midnight,
so their ``first_counted`` is always today — unaffected by this fix.
Days that have not yet started in local time (D > today_local) are
never counted. Switching versions never resets the counter.
**Timezone note**: the ``days`` field in the returned dict still represents
the window length in calendar days (total_seconds / 86400), for backward
@@ -750,25 +768,41 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
# --- 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").
# Both the window-start day and the window-end day are counted as complete
# local calendar days, regardless of whether the window starts/ends at midnight.
#
# Semantics: "A day D is counted when its local midnight has arrived (D ≤ today)
# AND the local midnight is within the billing window."
# Principle C (symmetric begin/end):
# • first_counted = local calendar date of start_utc (start day always counted)
# • last_counted = local calendar date of end_utc (end day counted if its
# local midnight is strictly before end_utc)
# • Both are then capped at today_local (only elapsed / today days count).
#
# This means a sub-day window [10:00, 10:30) UTC that doesn't contain any
# local midnight contributes 0 days, while a window [22:00 UTC, 23:00 UTC) that
# contains CEST midnight (= 22:00 UTC) contributes 1 day.
# Why symmetric? Fixed charges (network_fee, management_fee) and energy-tax credits
# (heffingskorting) are assessed on a "service-is-active" basis, not on how many
# hours the service was actually running within that calendar day. If the meter
# anchor (started_at) falls at 09:18 on June 24, the full June 24 standing charge
# and credit still apply because the service was online for that day.
#
# Implementation: compute the first and last local date whose midnight is in range.
# Previous asymmetric behaviour: a start_utc that was *later* than the local
# midnight of local_start_date caused first_counted to be bumped to the *next*
# day, silently dropping the anchor day's charges/credits. This was incorrect
# for cumulative entities (import_cost_total / export_revenue_total) whose anchor
# is often an above-midnight started_at. The end-side had always been symmetric
# (counted if local midnight < end_utc), creating an inconsistency.
#
# Daily getters (window = [local today 00:00, local tomorrow 00:00)) are
# unaffected: local_date(local_midnight_utc(today)) == today, so first_counted
# is still today regardless of the fix.
#
# A short same-day window now counts its single local day (the start day is
# always counted, symmetric with the end side). A window contributes 0 days
# only when the counted range is empty (first_counted > last_counted) — e.g. a
# window lying entirely in the future, where last_counted is capped at
# today_local while first_counted is later.
from app.services.timezone import local_midnight_utc as _lmu
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)
# Start side: always count the local calendar day in which start_utc falls.
first_counted: _date = local_date(start_utc)
local_end_date: _date = local_date(end_utc)
# Is the midnight of local_end_date < end_utc? If yes, that day's midnight is in range.
+4 -3
View File
@@ -591,9 +591,10 @@ export interface paths {
*
* **Retroactive recompute**: if ``started_at`` is in the past, billing
* records from that point forward are re-judged via ``recompute_range`` to
* reflect the new meter attribution. The response includes the count of
* recomputed periods in ``recomputed_periods`` (not part of ``MeterResponse``
* — the recompute is transparent; callers should re-fetch costs if needed).
* reflect the new meter attribution. The recompute is transparent — the
* response body is the created meter (``MeterResponse``) only and does **not**
* include a recompute count; callers should re-fetch costs if they need the
* updated totals.
*/
post: operations["declare_energy_meter_api_energy_meters_post"];
delete?: never;
+89
View File
@@ -258,6 +258,95 @@ describe('MeterManager — declare new meter', () => {
})
})
describe('MeterManager — edit meter date initialisation', () => {
beforeEach(() => vi.clearAllMocks())
it('initialises date input from started_at (Z-suffix, UTC midnight → local date)', async () => {
// ACTIVE_METER.started_at = '2024-01-15T00:00:00Z' (UTC midnight).
// The test suite is pinned to TZ=UTC (via vite.config.ts test.env), so
// the local date is deterministically '2024-01-15' on any CI runner.
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } })
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)).toBeInTheDocument())
await user.click(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`))
await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument())
const dateInput = screen.getByTestId('edit-meter-started-at') as HTMLInputElement
expect(dateInput.value).toBe('2024-01-15')
})
it('initialises date input from started_at (naive, no tz marker)', async () => {
// A naive timestamp without timezone marker — parseBackendTimestamp appends 'Z'
// so it is treated as UTC. With TZ=UTC (pinned in vite.config.ts), the local date
// equals the UTC date exactly.
const naiveMeter = { ...ACTIVE_METER, started_at: '2024-03-20T00:00:00' }
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [naiveMeter], total: 1 } })
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId(`meter-edit-${naiveMeter.id}`)).toBeInTheDocument())
await user.click(screen.getByTestId(`meter-edit-${naiveMeter.id}`))
await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument())
const dateInput = screen.getByTestId('edit-meter-started-at') as HTMLInputElement
expect(dateInput.value).toBe('2024-03-20')
})
it('initialises date input from started_at with explicit UTC offset (+02:00) — regression for old buggy regex', async () => {
// The old hand-written regex /[zZ+-]\d*$/ would fail to match '+02:00' (the ':00'
// suffix broke the pattern) and would incorrectly append 'Z', producing an Invalid Date.
// The new code uses parseBackendTimestamp which uses the correct TZ_MARKER_RE regex
// and handles explicit offsets properly.
const offsetMeter = { ...ACTIVE_METER, started_at: '2024-01-15T02:00:00+02:00' }
// UTC equivalent: 2024-01-15T00:00:00Z → with TZ=UTC (pinned) local date = '2024-01-15'
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [offsetMeter], total: 1 } })
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId(`meter-edit-${offsetMeter.id}`)).toBeInTheDocument())
await user.click(screen.getByTestId(`meter-edit-${offsetMeter.id}`))
await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument())
const dateInput = screen.getByTestId('edit-meter-started-at') as HTMLInputElement
// Must not be empty (which would indicate Invalid Date from the old buggy path)
expect(dateInput.value).not.toBe('')
expect(dateInput.value).toBe('2024-01-15')
})
it('does not include started_at in PATCH body when date is unchanged (round-trip idempotence)', async () => {
// Open the edit form and immediately submit without changing any fields except label.
// The date should be considered unchanged → no started_at in the PATCH body.
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } })
mockPatch.mockResolvedValue({ data: { ...ACTIVE_METER, label: 'New label' } })
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)).toBeInTheDocument())
await user.click(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`))
await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument())
// Change only label; leave date untouched
const labelInput = screen.getByTestId('edit-meter-label')
await user.clear(labelInput)
await user.type(labelInput, 'New label')
await user.click(screen.getByTestId('edit-meter-submit'))
await waitFor(() => {
expect(mockPatch).toHaveBeenCalled()
})
const patchBody = mockPatch.mock.calls[0][1].body
expect(patchBody).not.toHaveProperty('started_at')
})
})
describe('MeterManager — edit meter', () => {
beforeEach(() => vi.clearAllMocks())
+20 -14
View File
@@ -38,7 +38,7 @@ import {
type MeterReason,
} from './hooks'
import { ApiError } from '../api/client'
import { formatLocalDate } from '../utils/datetime'
import { formatLocalDate, parseBackendTimestamp } from '../utils/datetime'
// ---------------------------------------------------------------------------
// Helpers
@@ -60,6 +60,19 @@ function toLocalMidnightNaive(dateStr: string): string {
return `${dateStr}T00:00:00`
}
/**
* Format a Date object as a "YYYY-MM-DD" string using the browser's local timezone.
* Used to populate <input type="date"> fields.
* Returns '' if the Date is invalid.
*/
function toLocalDateInputString(d: Date): string {
if (isNaN(d.getTime())) return ''
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
// ---------------------------------------------------------------------------
// Declare meter form (modal)
// ---------------------------------------------------------------------------
@@ -204,19 +217,12 @@ interface EditMeterFormProps {
function EditMeterForm({ meter, onClose, onSaved }: EditMeterFormProps) {
const [label, setLabel] = useState(meter.label)
const [note, setNote] = useState(meter.note ?? '')
// Convert UTC started_at to a local date string for the <input type="date">.
// We parse as UTC (appending Z if needed) and format to "YYYY-MM-DD" in local tz.
const initialDateStr = (() => {
const iso = meter.started_at.includes('T') && !meter.started_at.match(/[zZ+-]\d*$/)
? meter.started_at + 'Z'
: meter.started_at
const d = new Date(iso)
if (isNaN(d.getTime())) return ''
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
})()
// Convert started_at to a local date string for the <input type="date">.
// parseBackendTimestamp correctly handles naive strings (no tz marker → treated as UTC,
// matching the backend's storage convention), Z-suffixed strings, and strings with
// explicit offsets like +02:00. We then extract the local-timezone date components
// so the displayed date matches the local wall-clock date of the meter start.
const initialDateStr = toLocalDateInputString(parseBackendTimestamp(meter.started_at))
const [dateStr, setDateStr] = useState(initialDateStr)
const [error, setError] = useState<string | null>(null)
+8
View File
@@ -20,5 +20,13 @@ export default defineConfig({
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test-setup.ts'],
env: {
// Lock the test timezone to UTC so that date-formatting assertions
// (which rely on toLocalDateInputString / getFullYear etc.) produce
// deterministic results regardless of the CI runner's local timezone.
// All fixture timestamps are UTC midnight, so the expected YYYY-MM-DD
// values in MeterManager.test.tsx are correct under UTC.
TZ: 'UTC',
},
},
})
+1 -1
View File
@@ -1405,7 +1405,7 @@
"api-energy-meters"
],
"summary": "Declare Energy Meter",
"description": "Declare a new meter epoch (swap, home move, or initial declaration).\n\nCloses the current active meter for the given commodity at ``started_at``\nand opens a new active meter. If no active meter exists, the new meter is\nsimply created without closing anything.\n\n**Validation**: ``started_at`` must be **≥** the current active meter's\nown ``started_at`` (no chronological backdate below the active epoch's\nstart). Equal timestamps are allowed (replaces the current meter at the\nsame logical moment). Violation → 422.\n\n**Retroactive recompute**: if ``started_at`` is in the past, billing\nrecords from that point forward are re-judged via ``recompute_range`` to\nreflect the new meter attribution. The response includes the count of\nrecomputed periods in ``recomputed_periods`` (not part of ``MeterResponse``\n— the recompute is transparent; callers should re-fetch costs if needed).",
"description": "Declare a new meter epoch (swap, home move, or initial declaration).\n\nCloses the current active meter for the given commodity at ``started_at``\nand opens a new active meter. If no active meter exists, the new meter is\nsimply created without closing anything.\n\n**Validation**: ``started_at`` must be **≥** the current active meter's\nown ``started_at`` (no chronological backdate below the active epoch's\nstart). Equal timestamps are allowed (replaces the current meter at the\nsame logical moment). Violation → 422.\n\n**Retroactive recompute**: if ``started_at`` is in the past, billing\nrecords from that point forward are re-judged via ``recompute_range`` to\nreflect the new meter attribution. The recompute is transparent — the\nresponse body is the created meter (``MeterResponse``) only and does **not**\ninclude a recompute count; callers should re-fetch costs if they need the\nupdated totals.",
"operationId": "declare_energy_meter_api_energy_meters_post",
"parameters": [
{
+5 -3
View File
@@ -1125,11 +1125,13 @@ paths:
records from that point forward are re-judged via ``recompute_range`` to
reflect the new meter attribution. The response includes the count of
reflect the new meter attribution. The recompute is transparent — the
recomputed periods in ``recomputed_periods`` (not part of ``MeterResponse``
response body is the created meter (``MeterResponse``) only and does **not**
— the recompute is transparent; callers should re-fetch costs if needed).'
include a recompute count; callers should re-fetch costs if they need the
updated totals.'
operationId: declare_energy_meter_api_energy_meters_post
parameters:
- name: X-CSRF-Token
+2 -2
View File
@@ -543,7 +543,7 @@ def test_patch_meter_no_recompute_when_started_at_not_changed(meters_client):
# ---------------------------------------------------------------------------
# Timeline continuity (integration: no recompute mock)
# Timeline continuity (recompute mocked to avoid slow computation over empty quarters)
# ---------------------------------------------------------------------------
@@ -615,7 +615,7 @@ def test_declare_meter_invalid_reason_returns_422(meters_client):
# ---------------------------------------------------------------------------
# Retroactive recompute: integration (no mock) — window coverage check
# Retroactive recompute & boundary update (recompute mocked) — window coverage check
# ---------------------------------------------------------------------------
+175 -29
View File
@@ -996,35 +996,52 @@ class TestSummarize:
assert result["period_count"] == 2
assert result["degraded_count"] == 0
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_fixed_costs_one_day_for_sub_day_window(self, energy_db: Session) -> None:
"""FUE-T01 (symmetric begin/end): a 30-minute window within one local day counts 1 day.
Fixed costs are only added for elapsed whole local calendar days.
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.
Under the symmetric-begin/end fix, fixed charges are assessed on a
"service-is-active" basis: if the window falls within a local calendar day,
that entire day's charge applies. The begin side no longer requires a local
midnight to fall *inside* the window the local date of start_utc itself is
always counted as the first day.
Window [10:00, 10:30) UTC in Europe/Amsterdam (CEST = UTC+2):
local date of start: June 23 (12:00 CEST)
local date of end: June 23 (12:30 CEST)
first_counted = last_counted = June 23 1 day
fixed_costs = (9.87 + 9.87) / 30 × 1
"""
from zoneinfo import ZoneInfo
from unittest.mock import patch
self._setup_two_periods(energy_db)
# 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")
):
with 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"
expected_fixed = (9.87 + 9.87) / 30 # 1 day
assert abs(result["fixed_costs"] - expected_fixed) < 1e-9, (
f"FUE-T01: sub-day window in one local day should count 1 day of fixed costs; "
f"expected {expected_fixed:.6f}, got {result['fixed_costs']}"
)
def test_credits_zero_for_sub_day_window(self, energy_db: Session) -> None:
"""Principle C: a 30-minute window within the same local day counts 0 whole days for credits."""
def test_credits_one_day_for_sub_day_window(self, energy_db: Session) -> None:
"""FUE-T01 (symmetric begin/end): a 30-minute window within one local day counts 1 day of credits.
Same rationale as test_fixed_costs_one_day_for_sub_day_window.
credits = 600.0 / 365 × 1 day.
"""
from zoneinfo import ZoneInfo
from unittest.mock import patch
self._setup_two_periods(energy_db)
with __import__("unittest.mock", fromlist=["patch"]).patch.object(
tz_module, "local_tz", return_value=ZoneInfo("Europe/Amsterdam")
):
with patch.object(tz_module, "local_tz", return_value=ZoneInfo("Europe/Amsterdam")):
result = summarize(energy_db, _ts(10, 0), _ts(10, 30))
assert result["credits"] == 0.0, (
"Sub-day window should contribute 0 whole-day credits under Principle C"
expected_credits = 600.0 / 365 # 1 day
assert abs(result["credits"] - expected_credits) < 1e-9, (
f"FUE-T01: sub-day window in one local day should count 1 day of credits; "
f"expected {expected_credits:.6f}, got {result['credits']}"
)
def test_total_payable_formula(self, energy_db: Session) -> None:
@@ -1068,12 +1085,20 @@ 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 UTC 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:0002: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.
FUE-T01 (symmetric begin/end): the window [2026-06-23 00:00 UTC, 2026-06-24 00:00 UTC)
in Europe/Amsterdam (CEST=UTC+2):
- local date of start (June 23 00:00 UTC = June 23 02:00 CEST) = June 23
first_counted = June 23 (anchor day always counted)
- local midnight of June 24 = June 23 22:00 UTC, which is < end_utc (June 24 00:00 UTC)
last_counted = June 24
- today (2026-06-25) June 24 cap doesn't apply
2 local calendar days counted (June 23 + June 24)
Note: a 1-day UTC window centered at non-midnight UTC spans **two** local days
in Amsterdam (CEST=UTC+2) because local midnight (June 23 22:00 UTC) falls
inside the window. This is correct under the symmetric begin/end principle.
"""
from zoneinfo import ZoneInfo
from unittest.mock import patch
@@ -1091,12 +1116,18 @@ class TestSummarize:
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
# credits = 600 / 365
assert abs(result["credits"] - 600.0 / 365) < 1e-9
# total = 0 + 0.658 (600/365)
expected_total = (9.87 + 9.87) / 30 - 600.0 / 365
# 2 local days (June 23 start day + June 24 because local midnight of June 24
# = June 23 22:00 UTC falls within [start, end)).
n_days = 2
assert abs(result["fixed_costs"] - (9.87 + 9.87) / 30 * n_days) < 1e-9, (
f"FUE-T01: 1-day UTC window = 2 local days in Amsterdam; "
f"expected fixed={((9.87 + 9.87) / 30 * n_days):.6f}, got {result['fixed_costs']}"
)
assert abs(result["credits"] - 600.0 / 365 * n_days) < 1e-9, (
f"FUE-T01: expected credits={600.0 / 365 * n_days:.6f}, got {result['credits']}"
)
# total = 0 + fixed credits
expected_total = (9.87 + 9.87) / 30 * n_days - 600.0 / 365 * n_days
assert abs(result["total_payable"] - expected_total) < 1e-9
@@ -1390,6 +1421,121 @@ class TestSummarizePrincipleC:
assert abs(result["fixed_costs"] - expected_fixed) < 1e-9
assert abs(result["credits"] - expected_credits) < 1e-9
def test_morning_anchor_first_day_counted(self, energy_db: Session) -> None:
"""FUE-T01: anchor falling above local midnight counts its local day as day 1.
Bug scenario: meter.started_at = June 24 09:18 local time (07:18 UTC, CEST=UTC+2).
Old code: local_date(07:18 UTC) = June 24; _lmu(June 24) = June 23 22:00 UTC;
June 23 22:00 UTC < June 24 07:18 UTC False (22:00 < 07:18? No 22:00 UTC June 23 is
*before* 07:18 UTC June 24; so the condition >= start_utc was False);
first_counted bumped to June 25. June 24's charges were silently dropped.
Fix: first_counted = local_date(start_utc) = June 24 always, regardless of where
within the day start_utc falls. June 24 is the anchor day its charge is counted.
Window: [June 24 07:18 UTC (= 09:18 CEST), June 26 22:00 UTC (= June 27 00:00 CEST)).
Today local = June 25 (2026-06-25).
first_counted = June 24 (anchor day, previously dropped).
last_counted = min(June 26, June 25) = June 25.
n_days = June 25 - June 24 + 1 = 2.
"""
from unittest.mock import patch
eff_utc = _ams_midnight(2026, 6, 1)
self._make_single_version_contract(energy_db, effective_from_utc=eff_utc)
# anchor = June 24 07:18 UTC = June 24 09:18 CEST (above local midnight)
anchor_utc = datetime(2026, 6, 24, 7, 18, 0, tzinfo=_UTC)
# end = June 26 22:00 UTC = June 27 00:00 CEST (past today June 25, capped)
end_utc = datetime(2026, 6, 26, 22, 0, 0, tzinfo=_UTC)
with patch.object(tz_module, "local_tz", return_value=_ams()):
result = self._run_summarize_ams(energy_db, anchor_utc, end_utc)
daily_fixed = (9.87 + 9.87) / 30
daily_credit = 600.0 / 365
# June 24 (anchor day) + June 25 (today) = 2 days
assert abs(result["fixed_costs"] - daily_fixed * 2) < 1e-9, (
f"FUE-T01: morning anchor should count anchor day + today = 2 days of fixed costs; "
f"expected {daily_fixed * 2:.6f}, got {result['fixed_costs']}"
)
assert abs(result["credits"] - daily_credit * 2) < 1e-9, (
f"FUE-T01: morning anchor should count anchor day + today = 2 days of credits; "
f"expected {daily_credit * 2:.6f}, got {result['credits']}"
)
def test_daily_window_midnight_aligned_unaffected(self, energy_db: Session) -> None:
"""FUE-T01: the daily getter window [today midnight, tomorrow midnight) is not affected.
The *_today getters use windows exactly aligned to local midnight:
start = local_midnight_utc(today) = June 24 22:00 UTC (for June 25 local)
end = local_midnight_utc(tomorrow) = June 25 22:00 UTC
With either old or new logic, first_counted = June 25 (today):
- Old: local_date(June 24 22:00 UTC in Amsterdam) = June 25;
_lmu(June 25) = June 24 22:00 UTC >= June 24 22:00 UTC True first = June 25.
- New: first_counted = local_date(June 24 22:00 UTC) = June 25.
Both give the same result: 1 day counted.
This test explicitly verifies daily window behaviour is unchanged.
"""
from unittest.mock import patch
eff_utc = _ams_midnight(2026, 6, 1)
self._make_single_version_contract(energy_db, effective_from_utc=eff_utc)
# Today's window aligned to local midnight (June 25 CEST = June 24 22:00 UTC).
today_start_utc = _ams_midnight(2026, 6, 25) # June 24 22:00 UTC
tomorrow_start_utc = _ams_midnight(2026, 6, 26) # June 25 22:00 UTC
with patch.object(tz_module, "local_tz", return_value=_ams()):
result = self._run_summarize_ams(energy_db, today_start_utc, tomorrow_start_utc)
daily_fixed = (9.87 + 9.87) / 30
daily_credit = 600.0 / 365
# Exactly 1 day counted (June 25 only; June 26 is future, capped).
assert abs(result["fixed_costs"] - daily_fixed * 1) < 1e-9, (
f"FUE-T01: daily window should count exactly 1 day; "
f"expected {daily_fixed:.6f}, got {result['fixed_costs']}"
)
assert abs(result["credits"] - daily_credit * 1) < 1e-9, (
f"FUE-T01: daily window should count exactly 1 day of credits; "
f"expected {daily_credit:.6f}, got {result['credits']}"
)
def test_single_day_morning_anchor_counts_one_day(self, energy_db: Session) -> None:
"""FUE-T01: a window starting mid-morning on a single local day counts that 1 day.
Window: [June 24 08:00 UTC (= 10:00 CEST), June 24 22:00 UTC (= June 25 00:00 CEST)).
Both endpoints resolve to June 24 local (end is exactly midnight of June 25,
which is June 24 22:00 UTC, so _lmu(June 25) = June 24 22:00 UTC is NOT < end_utc
but equal last_counted = June 24).
first_counted = June 24.
n_days = 1. Today (June 25) is past June 24 elapsed.
"""
from unittest.mock import patch
eff_utc = _ams_midnight(2026, 6, 1)
self._make_single_version_contract(energy_db, effective_from_utc=eff_utc)
start_utc = datetime(2026, 6, 24, 8, 0, 0, tzinfo=_UTC) # 10:00 CEST = mid-morning June 24
end_utc = datetime(2026, 6, 24, 22, 0, 0, tzinfo=_UTC) # = June 25 00:00 CEST midnight
with patch.object(tz_module, "local_tz", return_value=_ams()):
result = self._run_summarize_ams(energy_db, start_utc, end_utc)
daily_fixed = (9.87 + 9.87) / 30
daily_credit = 600.0 / 365
# Exactly 1 day: June 24 (start day, counted as full day; elapsed since today is June 25).
assert abs(result["fixed_costs"] - daily_fixed * 1) < 1e-9, (
f"FUE-T01: mid-morning to midnight window on one day should count 1 day; "
f"expected {daily_fixed:.6f}, got {result['fixed_costs']}"
)
assert abs(result["credits"] - daily_credit * 1) < 1e-9, (
f"FUE-T01: mid-morning to midnight window on one day should count 1 day of credits; "
f"expected {daily_credit:.6f}, got {result['credits']}"
)
# ---------------------------------------------------------------------------
# 8. compute_closed_periods