Compare commits
3
Commits
e3d0d17cac
...
da05fd2f09
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da05fd2f09 | ||
|
|
188168b16a | ||
|
|
f1e7ce3133 |
@@ -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
@@ -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 1–30 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 1–30 uses V1 for days 1-24 and V2 for day 25.
|
||||
|
||||
The end-day (last_counted) is included when the end's local midnight falls
|
||||
strictly before end_utc; combined with the always-counted start day this
|
||||
makes the begin/end handling symmetric. A short same-day window therefore
|
||||
counts its single local day. A window contributes 0 days only when the
|
||||
counted range is empty (first_counted > last_counted) — e.g. a window lying
|
||||
entirely in the future, since last_counted is capped at today_local.
|
||||
|
||||
Daily getters (``*_today``) use windows exactly aligned to local midnight,
|
||||
so their ``first_counted`` is always today — unaffected by this fix.
|
||||
|
||||
Days that have not yet started in local time (D > today_local) are
|
||||
never counted. Switching versions never resets the counter.
|
||||
|
||||
**Timezone note**: the ``days`` field in the returned dict still represents
|
||||
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.
|
||||
|
||||
Vendored
+4
-3
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:00→02:00).
|
||||
effective_from = June 23 00:00 UTC = June 23 02:00 CEST = local date June 23.
|
||||
Today (2026-06-25) ≥ June 23, so the day is elapsed → 1 day counted.
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user