Files
home-automation/app/services/meters.py
T

555 lines
22 KiB
Python
Raw Normal View History

"""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
from app.models.meter_source import MeterSourceBinding
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).
"""
def close_meter(session: Session, meter: Meter, *, ended_at: datetime) -> Meter:
"""Close an active meter at a valid, non-future exclusive boundary."""
boundary = _as_utc(ended_at)
if meter.ended_at is not None:
raise MeterIntervalError("Only an active meter can be closed.")
if boundary <= _as_utc(meter.started_at):
raise MeterIntervalError("Meter ended_at must be strictly after started_at.")
if boundary > datetime.now(UTC):
raise MeterIntervalError("Meter ended_at must not be in the future.")
_validate_bindings_fit_meter_end(session, meter, boundary)
meter.ended_at = boundary
return meter
# ---------------------------------------------------------------------------
# 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 _validate_bindings_fit_meter_end(session: Session, meter: Meter, boundary: datetime) -> None:
"""Reject an epoch close that would put any retained binding out of bounds.
Closed binding history is immutable here. Open bindings may subsequently
be closed by the caller at the shared meter boundary, but only when that
produces a non-empty interval.
"""
for binding in session.execute(
select(MeterSourceBinding).where(MeterSourceBinding.meter_id == meter.id)
).scalars():
if binding.ended_at is None:
if _as_utc(binding.started_at) >= boundary:
raise MeterIntervalError("Open binding cannot be closed within the proposed meter epoch.")
elif _as_utc(binding.ended_at) > boundary:
raise MeterIntervalError("Closed binding extends beyond the proposed meter epoch.")
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).
"""
if _as_utc(started_at) > datetime.now(UTC):
raise MeterIntervalError("Meter started_at must not be in the future.")
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."
)
# Validate before changing the epoch: retained closed binding history
# must never be silently truncated by a later declaration.
_validate_bindings_fit_meter_end(session, active, _as_utc(started_at))
# 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.
c. Every binding on this meter and its affected predecessor must remain
wholly inside its proposed epoch. The service rejects the correction
rather than rewriting binding history.
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.
"""
# Validate the proposed epoch boundary before touching *any* mutable
# field. PATCH accepts label/note together with started_at, so doing this
# first keeps an invalid future timestamp from leaking a partial in-session
# update before the API's rollback boundary is reached.
proposed_started_at = _as_utc(started_at) if started_at is not None else None
if proposed_started_at is not None and proposed_started_at > datetime.now(UTC):
raise MeterIntervalError("Meter started_at must not be in the future.")
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
assert proposed_started_at is not None
# --- Validate upper bound: new started_at must be < this meter's ended_at (if set).
if meter.ended_at is not None:
if proposed_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 proposed_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."
)
# A boundary correction changes both adjacent meter epochs. Fail closed
# rather than silently rewriting binding history: every existing binding
# must still fit in its proposed epoch before either Meter is mutated.
affected_meters = [
(meter, proposed_started_at, _as_utc(meter.ended_at) if meter.ended_at is not None else None)
]
if prev is not None:
affected_meters.append((prev, _as_utc(prev.started_at), proposed_started_at))
for affected_meter, proposed_start, proposed_end in affected_meters:
bindings = session.execute(
select(MeterSourceBinding).where(MeterSourceBinding.meter_id == affected_meter.id)
).scalars()
for binding in bindings:
if _as_utc(binding.started_at) < proposed_start:
raise MeterIntervalError(
f"Binding {binding.id} starts before meter {affected_meter.id}'s epoch."
)
if proposed_end is not None and (
binding.ended_at is None or _as_utc(binding.ended_at) > proposed_end
):
raise MeterIntervalError(
f"Binding {binding.id} would fall outside meter {affected_meter.id}'s epoch."
)
if prev is not None:
# 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