"""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