M7-T02: add meter service layer (declare/swap/close/edit, meter_at, mutual exclusion)
This commit is contained in:
@@ -0,0 +1,480 @@
|
||||
"""Service layer for Meter epoch CRUD, swap/close, and time-range lookup.
|
||||
|
||||
All functions accept an explicit SQLAlchemy Session; callers are responsible
|
||||
for committing or rolling back the transaction.
|
||||
|
||||
Design decisions
|
||||
----------------
|
||||
- ``meter_at``: half-open interval ``[started_at, ended_at)`` lookup;
|
||||
returns the meter whose epoch covers *ts* for the given commodity.
|
||||
SQLite naive datetime is normalised via ``_as_utc()`` before comparison.
|
||||
|
||||
- ``declare_meter``: validates that the new ``started_at`` is **not earlier
|
||||
than** the current active meter's ``started_at`` (rejects back-dating below
|
||||
the active meter's own start). Equal timestamps are allowed because the
|
||||
typical "swap now" use-case sets ``started_at`` to the current moment, which
|
||||
coincides with the active meter's ``started_at`` only in degenerate test
|
||||
scenarios — but blocking equal values would make that workflow impossible.
|
||||
The old active meter is closed (``ended_at = started_at``) and a new active
|
||||
meter is opened in the same operation, guaranteeing continuity: the old
|
||||
meter's ``ended_at`` equals the new meter's ``started_at`` (contiguous,
|
||||
no gap, no overlap).
|
||||
|
||||
- ``update_meter``: when ``started_at`` is modified, the service keeps the
|
||||
timeline contiguous by also updating the **previous** meter's ``ended_at``
|
||||
(the one whose ``ended_at`` matched the old ``started_at``) to the new
|
||||
``started_at``. Validation ensures the new ``started_at``:
|
||||
* is strictly after the previous meter's own ``started_at``
|
||||
(cannot push the boundary before the previous meter even started);
|
||||
* is strictly before the current meter's ``ended_at``, if set
|
||||
(cannot push the boundary past where the current meter was already
|
||||
closed).
|
||||
|
||||
- Mutual exclusion (at most one active meter per commodity) is enforced by the
|
||||
service layer; no DB-level unique partial index is added to keep migrations
|
||||
simple and to allow the application to return a meaningful error message.
|
||||
|
||||
SQLite timezone note
|
||||
--------------------
|
||||
SQLite stores ``DateTime(timezone=True)`` columns as naive UTC strings; on
|
||||
read-back they come out as **timezone-naive** datetimes. Wherever this code
|
||||
compares timestamps from the DB against timezone-aware values, it calls
|
||||
``_as_utc()`` to make both sides comparable without tripping on
|
||||
"offset-naive vs offset-aware" TypeErrors.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.energy import Meter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _as_utc(dt: datetime) -> datetime:
|
||||
"""Return *dt* as a timezone-aware UTC datetime.
|
||||
|
||||
SQLite's ``DateTime(timezone=True)`` column type stores datetimes as naive
|
||||
UTC strings and gives them back as naive datetimes on read. This helper
|
||||
re-attaches the UTC timezone info when it is missing, making cross-origin
|
||||
comparisons safe.
|
||||
"""
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=UTC)
|
||||
return dt
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Custom exceptions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MeterError(ValueError):
|
||||
"""Base class for meter service validation errors."""
|
||||
|
||||
|
||||
class MeterOverlapError(MeterError):
|
||||
"""Raised when a new meter's ``started_at`` would create an overlap or backdate.
|
||||
|
||||
Specifically: the new meter's ``started_at`` must be greater than or equal
|
||||
to the current active meter's ``started_at``. Allowing a value strictly
|
||||
earlier than the active meter's start would mean the new epoch begins before
|
||||
the current one, which is chronologically inconsistent.
|
||||
"""
|
||||
|
||||
|
||||
class MeterIntervalError(MeterError):
|
||||
"""Raised when an ``update_meter`` call would produce an inconsistent interval.
|
||||
|
||||
Examples of inconsistent intervals:
|
||||
- New ``started_at`` ≥ this meter's ``ended_at`` (epoch would be empty/inverted).
|
||||
- New ``started_at`` ≤ the previous meter's own ``started_at`` (previous meter
|
||||
would become empty/inverted after its ``ended_at`` is updated).
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal query helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _active_meter(session: Session, commodity: str) -> Optional[Meter]:
|
||||
"""Return the current active (``ended_at IS NULL``) meter for *commodity*, or None."""
|
||||
return session.execute(
|
||||
select(Meter)
|
||||
.where(
|
||||
Meter.commodity == commodity,
|
||||
Meter.ended_at.is_(None),
|
||||
)
|
||||
.limit(1)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def _meter_before(session: Session, meter: Meter) -> Optional[Meter]:
|
||||
"""Return the meter whose ``ended_at`` equals *meter*'s ``started_at``.
|
||||
|
||||
This is the meter that was closed when *meter* was opened; its ``ended_at``
|
||||
needs to stay equal to *meter*'s ``started_at`` to maintain timeline
|
||||
continuity. Returns None if *meter* is the first epoch for its commodity.
|
||||
|
||||
The lookup compares naive/aware datetimes via string to avoid SQLite timezone
|
||||
quirks: both are formatted as ISO 8601 UTC strings for the WHERE clause.
|
||||
We rely on the fact that the service layer always stores the same timestamp
|
||||
object as both ``prev.ended_at`` and ``new.started_at``, so their string
|
||||
representations are identical.
|
||||
"""
|
||||
# Normalise the target to a tz-aware UTC datetime for comparison.
|
||||
# We scan in Python (rather than SQL) to avoid SQLite naive-vs-aware
|
||||
# mismatch issues when comparing DateTime columns against tz-aware values.
|
||||
target = _as_utc(meter.started_at)
|
||||
|
||||
# Fetch all closed meters of the same commodity and find the one whose
|
||||
# ended_at equals this meter's started_at (the standard contiguous handoff).
|
||||
candidates = session.execute(
|
||||
select(Meter)
|
||||
.where(
|
||||
Meter.commodity == meter.commodity,
|
||||
Meter.ended_at.is_not(None),
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
for candidate in candidates:
|
||||
if candidate.id == meter.id:
|
||||
continue
|
||||
candidate_ended = _as_utc(candidate.ended_at)
|
||||
if candidate_ended == target:
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core service functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def meter_at(
|
||||
session: Session,
|
||||
ts: datetime,
|
||||
commodity: str = "electricity",
|
||||
) -> Optional[Meter]:
|
||||
"""Return the meter epoch that covers *ts* for *commodity*.
|
||||
|
||||
A meter covers *ts* when:
|
||||
``started_at ≤ ts`` AND (``ended_at IS NULL`` OR ``ts < ended_at``)
|
||||
|
||||
This is the standard half-open interval ``[started_at, ended_at)`` lookup,
|
||||
consistent with ``active_contract_version_at`` in ``contracts.py``.
|
||||
|
||||
Returns ``None`` when no meter covers *ts* (e.g. before the first epoch
|
||||
was declared, or after a gap).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
session:
|
||||
Active SQLAlchemy session (read-only usage).
|
||||
ts:
|
||||
UTC datetime to look up.
|
||||
commodity:
|
||||
Energy commodity (default ``"electricity"``).
|
||||
|
||||
Returns
|
||||
-------
|
||||
Meter | None
|
||||
|
||||
Implementation note — why the upper bound is pushed into SQL
|
||||
------------------------------------------------------------
|
||||
When ``declare_meter`` is called with ``started_at == active.started_at``
|
||||
(the "equal-timestamp swap" allowed by §3.5), the old meter is closed as a
|
||||
zero-width epoch ``[T0, T0)`` and the new active meter also has
|
||||
``started_at == T0``. Two rows share the same ``started_at``; SQLite's
|
||||
row-ordering for ``ORDER BY started_at DESC LIMIT 1`` is then determined by
|
||||
rowid (i.e. insertion order), which returns the older zero-width row first.
|
||||
|
||||
If the upper bound were checked in Python *after* fetching that one row, the
|
||||
condition ``ts < ended_at`` would be False for **any** ``ts >= T0`` (because
|
||||
``ended_at == T0``), causing the function to return ``None`` and making the
|
||||
new active meter permanently invisible.
|
||||
|
||||
Pushing both bounds into the SQL ``WHERE`` clause eliminates the ambiguity:
|
||||
the zero-width row is excluded by ``ts < ended_at`` before ``LIMIT 1`` is
|
||||
applied, so only the genuinely covering row survives.
|
||||
|
||||
SQLite naive-vs-aware datetime note: SQLAlchemy's SQLite dialect strips the
|
||||
``tzinfo`` from aware datetimes when binding parameters (it does *not*
|
||||
convert to UTC first). Since all datetimes in this codebase are UTC (either
|
||||
naive-UTC from the DB or aware-UTC from ``datetime.now(UTC)``), stripping
|
||||
the tzinfo leaves the wall-clock value unchanged and comparisons remain
|
||||
correct. This is consistent with how ``contracts.py`` handles the same
|
||||
situation (see OBS 2 in the M7-T02 review notes).
|
||||
"""
|
||||
# Push both the lower and upper bounds into the SQL WHERE clause so that
|
||||
# zero-width epochs (ended_at == started_at) are excluded *before* LIMIT 1
|
||||
# is applied. This prevents an equal-timestamp swap from making the new
|
||||
# active meter invisible (see implementation note above).
|
||||
stmt = (
|
||||
select(Meter)
|
||||
.where(
|
||||
Meter.commodity == commodity,
|
||||
Meter.started_at <= ts,
|
||||
(Meter.ended_at.is_(None)) | (Meter.ended_at > ts),
|
||||
)
|
||||
.order_by(Meter.started_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
|
||||
def declare_meter(
|
||||
session: Session,
|
||||
*,
|
||||
label: str,
|
||||
started_at: datetime,
|
||||
reason: str,
|
||||
commodity: str = "electricity",
|
||||
note: Optional[str] = None,
|
||||
) -> Meter:
|
||||
"""Declare a meter swap or initial meter epoch.
|
||||
|
||||
Closes the current active meter for *commodity* (if one exists) by setting
|
||||
its ``ended_at`` to *started_at*, then opens a new active meter. The
|
||||
resulting timeline is **contiguous**: old meter's ``ended_at`` equals new
|
||||
meter's ``started_at``.
|
||||
|
||||
If there is no current active meter (first-ever declaration for this
|
||||
commodity), the new meter is simply opened without closing anything.
|
||||
|
||||
Validation
|
||||
----------
|
||||
- If a current active meter exists, *started_at* must be **≥** that meter's
|
||||
own ``started_at``. A value strictly earlier would place the new epoch
|
||||
entirely before the current active meter, which is a chronological
|
||||
contradiction ("back-dating before the active epoch's start"). Raises
|
||||
``MeterOverlapError`` when this constraint is violated.
|
||||
|
||||
Note: equal timestamps (``started_at == active.started_at``) are allowed
|
||||
because that scenario effectively replaces the current meter at the same
|
||||
logical moment (e.g. correcting a mis-entry), which is a valid use-case.
|
||||
The old meter is then closed with ``ended_at == started_at`` (a zero-width
|
||||
epoch), which is intentional and auditable.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
session:
|
||||
Active SQLAlchemy session. Caller must commit after this returns.
|
||||
label:
|
||||
Human-readable label for the new meter.
|
||||
started_at:
|
||||
UTC datetime at which this meter epoch starts. May be in the past.
|
||||
reason:
|
||||
Why this epoch was created (``"initial"``, ``"meter_swap"``,
|
||||
``"home_move"``, or ``"other"``).
|
||||
commodity:
|
||||
Energy commodity (default ``"electricity"``).
|
||||
note:
|
||||
Optional free-form note.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Meter
|
||||
The newly created, not-yet-committed active meter.
|
||||
|
||||
Raises
|
||||
------
|
||||
MeterOverlapError
|
||||
If *started_at* is strictly earlier than the current active meter's
|
||||
``started_at`` (chronological backdate below the active epoch's start).
|
||||
"""
|
||||
active = _active_meter(session, commodity)
|
||||
|
||||
if active is not None:
|
||||
# Reject back-dating: new started_at must be ≥ current active's started_at.
|
||||
if _as_utc(started_at) < _as_utc(active.started_at):
|
||||
raise MeterOverlapError(
|
||||
f"New meter started_at ({started_at.isoformat()}) must be ≥ the current "
|
||||
f"active {commodity!r} meter's started_at "
|
||||
f"({active.started_at.isoformat()}). "
|
||||
"Declare a started_at on or after the active meter's start to avoid "
|
||||
"a chronologically inconsistent epoch ordering."
|
||||
)
|
||||
# Close the current active meter at the swap point (contiguous handoff).
|
||||
active.ended_at = started_at
|
||||
logger.info(
|
||||
"Closed active %r meter id=%d (ended_at=%s)",
|
||||
commodity,
|
||||
active.id,
|
||||
started_at.isoformat(),
|
||||
)
|
||||
|
||||
now = datetime.now(UTC)
|
||||
new_meter = Meter(
|
||||
label=label,
|
||||
commodity=commodity,
|
||||
started_at=started_at,
|
||||
ended_at=None,
|
||||
reason=reason,
|
||||
note=note,
|
||||
created_at=now,
|
||||
)
|
||||
session.add(new_meter)
|
||||
|
||||
logger.info(
|
||||
"Declared new %r meter %r (started_at=%s, reason=%s)",
|
||||
commodity,
|
||||
label,
|
||||
started_at.isoformat(),
|
||||
reason,
|
||||
)
|
||||
return new_meter
|
||||
|
||||
|
||||
def list_meters(
|
||||
session: Session,
|
||||
commodity: Optional[str] = None,
|
||||
) -> list[Meter]:
|
||||
"""List all meter epochs, ordered by started_at ascending.
|
||||
|
||||
Active meters (``ended_at IS NULL``) sort naturally to the end of the
|
||||
timeline since they have the latest ``started_at``. Within a single
|
||||
commodity, the ascending ``started_at`` order reflects the historical
|
||||
sequence of installed meters.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
session:
|
||||
Active SQLAlchemy session (read-only usage).
|
||||
commodity:
|
||||
If provided, filter to this commodity only. If ``None``, return all
|
||||
meters across all commodities.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[Meter]
|
||||
Meters ordered by (started_at ASC).
|
||||
"""
|
||||
stmt = select(Meter).order_by(Meter.started_at.asc())
|
||||
if commodity is not None:
|
||||
stmt = stmt.where(Meter.commodity == commodity)
|
||||
|
||||
return list(session.execute(stmt).scalars().all())
|
||||
|
||||
|
||||
def update_meter(
|
||||
session: Session,
|
||||
meter: Meter,
|
||||
*,
|
||||
label: Optional[str] = None,
|
||||
note: Optional[str] = None,
|
||||
started_at: Optional[datetime] = None,
|
||||
) -> Meter:
|
||||
"""Update a meter's mutable fields (label, note, started_at).
|
||||
|
||||
Passing ``None`` for a field leaves it unchanged. At least one keyword
|
||||
argument must be non-``None``; calling with all-``None`` is a no-op but
|
||||
is not an error.
|
||||
|
||||
Updating ``started_at`` (retroactive correction)
|
||||
------------------------------------------------
|
||||
When ``started_at`` is provided the service maintains **timeline
|
||||
continuity** across the adjacent meter boundaries:
|
||||
|
||||
1. **Previous meter's ``ended_at``** — if the meter immediately before
|
||||
this one has ``ended_at == meter.started_at`` (the standard contiguous
|
||||
handoff), its ``ended_at`` is updated to the new ``started_at`` so the
|
||||
boundary between the two epochs stays seamless.
|
||||
|
||||
2. **Validation** — the new ``started_at`` is checked for consistency:
|
||||
a. It must be **strictly after** the previous meter's own ``started_at``
|
||||
(otherwise the previous meter's epoch would collapse to zero or
|
||||
invert).
|
||||
b. It must be **strictly before** this meter's ``ended_at`` (if set),
|
||||
so this meter's epoch remains non-empty.
|
||||
|
||||
Note: triggering a billing recompute (``recompute_range``) after a
|
||||
retroactive ``started_at`` change is **out of scope** for this service
|
||||
layer; that is the API layer's responsibility (M7-T05).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
session:
|
||||
Active SQLAlchemy session. Caller must commit after this returns.
|
||||
meter:
|
||||
The ``Meter`` ORM instance to update (already loaded from the session).
|
||||
label:
|
||||
New human-readable label; ``None`` = keep existing.
|
||||
note:
|
||||
New free-form note; ``None`` = keep existing.
|
||||
started_at:
|
||||
New start timestamp for this meter epoch; ``None`` = keep existing.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Meter
|
||||
The updated ``Meter`` instance (not yet committed).
|
||||
|
||||
Raises
|
||||
------
|
||||
MeterIntervalError
|
||||
If the new ``started_at`` would produce an invalid (empty or inverted)
|
||||
epoch for this meter or the immediately preceding one.
|
||||
"""
|
||||
if label is not None:
|
||||
meter.label = label
|
||||
logger.info("Updated meter id=%d label=%r", meter.id, label)
|
||||
|
||||
if note is not None:
|
||||
meter.note = note
|
||||
logger.info("Updated meter id=%d note=%r", meter.id, note)
|
||||
|
||||
if started_at is not None:
|
||||
old_started_at = meter.started_at
|
||||
|
||||
# --- Validate upper bound: new started_at must be < this meter's ended_at (if set).
|
||||
if meter.ended_at is not None:
|
||||
if _as_utc(started_at) >= _as_utc(meter.ended_at):
|
||||
raise MeterIntervalError(
|
||||
f"New started_at ({started_at.isoformat()}) must be strictly before "
|
||||
f"this meter's ended_at ({meter.ended_at.isoformat()}). "
|
||||
"The meter epoch would become empty or inverted."
|
||||
)
|
||||
|
||||
# --- Find the immediately preceding meter (its ended_at == meter's old started_at).
|
||||
prev = _meter_before(session, meter)
|
||||
|
||||
# --- Validate lower bound: new started_at must be strictly after prev's started_at.
|
||||
if prev is not None:
|
||||
if _as_utc(started_at) <= _as_utc(prev.started_at):
|
||||
raise MeterIntervalError(
|
||||
f"New started_at ({started_at.isoformat()}) must be strictly after "
|
||||
f"the previous meter's started_at ({prev.started_at.isoformat()}). "
|
||||
"Moving the boundary that far back would collapse the previous meter's epoch."
|
||||
)
|
||||
# Maintain continuity: update the previous meter's ended_at to match the new start.
|
||||
prev.ended_at = started_at
|
||||
logger.info(
|
||||
"Updated previous meter id=%d ended_at=%s (boundary shift from %s)",
|
||||
prev.id,
|
||||
started_at.isoformat(),
|
||||
old_started_at.isoformat(),
|
||||
)
|
||||
|
||||
meter.started_at = started_at
|
||||
logger.info(
|
||||
"Updated meter id=%d started_at=%s (was %s)",
|
||||
meter.id,
|
||||
started_at.isoformat(),
|
||||
old_started_at.isoformat(),
|
||||
)
|
||||
|
||||
return meter
|
||||
Reference in New Issue
Block a user