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