M8-R03: hand off channel binding during meter swap
This commit is contained in:
@@ -61,7 +61,12 @@ from app.schemas.meter import (
|
||||
MeterPatchRequest,
|
||||
MeterResponse,
|
||||
)
|
||||
from app.services.meter_sources import ChannelNotFoundError, MeterSourceError, create_binding
|
||||
from app.services.meter_sources import (
|
||||
ChannelNotFoundError,
|
||||
MeterSourceError,
|
||||
create_binding,
|
||||
create_binding_for_meter_swap,
|
||||
)
|
||||
from app.services import timezone as _tz_mod
|
||||
from app.services.auth import AuthenticatedSession
|
||||
from app.services.energy_cost import recompute_range
|
||||
@@ -231,6 +236,9 @@ def declare_energy_meter(
|
||||
started_at_utc = _localize_started_at(body.started_at)
|
||||
|
||||
try:
|
||||
old_meter = db.execute(
|
||||
select(Meter).where(Meter.commodity == body.commodity, Meter.ended_at.is_(None))
|
||||
).scalar_one_or_none()
|
||||
new_meter = declare_meter(
|
||||
db,
|
||||
label=body.label,
|
||||
@@ -246,28 +254,39 @@ def declare_energy_meter(
|
||||
).scalar_one_or_none()
|
||||
if channel is None:
|
||||
raise ChannelNotFoundError("Meter source channel was not found.")
|
||||
create_binding(
|
||||
db,
|
||||
meter_id=new_meter.id,
|
||||
channel_id=channel.id,
|
||||
started_at=started_at_utc,
|
||||
)
|
||||
if body.reason.value == "meter_swap":
|
||||
create_binding_for_meter_swap(
|
||||
db,
|
||||
old_meter_id=old_meter.id if old_meter is not None else None,
|
||||
new_meter_id=new_meter.id,
|
||||
channel_id=channel.id,
|
||||
started_at=started_at_utc,
|
||||
)
|
||||
else:
|
||||
create_binding(
|
||||
db,
|
||||
meter_id=new_meter.id,
|
||||
channel_id=channel.id,
|
||||
started_at=started_at_utc,
|
||||
)
|
||||
|
||||
# Keep recompute in this transaction: a failure must not leave a new
|
||||
# meter, its predecessor, or either binding at a half-applied boundary.
|
||||
now = datetime.now(UTC)
|
||||
if started_at_utc < now:
|
||||
_trigger_recompute(db, started_at_utc, "POST /api/energy/meters")
|
||||
db.commit()
|
||||
except (MeterOverlapError, MeterSourceError) as exc:
|
||||
# declare_meter may already have closed the previous epoch. Rolling back
|
||||
# here makes Meter + binding declaration genuinely atomic.
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=(status.HTTP_404_NOT_FOUND if isinstance(exc, ChannelNotFoundError)
|
||||
else status.HTTP_422_UNPROCESSABLE_ENTITY),
|
||||
detail=str(exc),
|
||||
)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
# Retroactive recompute: re-judge attribution from the new boundary onward.
|
||||
now = datetime.now(UTC)
|
||||
if started_at_utc < now:
|
||||
_trigger_recompute(db, started_at_utc, "POST /api/energy/meters")
|
||||
|
||||
db.commit()
|
||||
db.refresh(new_meter)
|
||||
|
||||
# Trigger HA discovery re-publish so the new active meter's energy-cost
|
||||
|
||||
@@ -315,6 +315,77 @@ def create_binding(
|
||||
return binding
|
||||
|
||||
|
||||
def create_binding_for_meter_swap(
|
||||
session: Session,
|
||||
*,
|
||||
old_meter_id: int | None,
|
||||
new_meter_id: int,
|
||||
channel_id: int,
|
||||
started_at: datetime,
|
||||
) -> MeterSourceBinding:
|
||||
"""Create a binding during a physical meter swap, handing off one channel if safe.
|
||||
|
||||
A channel is transferable only when exactly one of its bindings covered the
|
||||
instant immediately before ``started_at`` and that binding belongs to the
|
||||
meter which this declaration just closed. All other occupied or ambiguous
|
||||
cases retain the normal fail-closed overlap behaviour.
|
||||
|
||||
This function deliberately does not commit. The caller must keep the meter
|
||||
declaration, binding handoff, and any billing recompute in one transaction.
|
||||
"""
|
||||
new_meter = _get_meter(session, new_meter_id)
|
||||
channel = get_channel(session, channel_id)
|
||||
expected_unit = COMMODITY_UNITS.get(new_meter.commodity)
|
||||
if expected_unit is None or channel.unit != expected_unit:
|
||||
raise BindingValidationError(
|
||||
f"Meter commodity {new_meter.commodity!r} requires unit {expected_unit!r}, "
|
||||
f"but channel has {channel.unit!r}."
|
||||
)
|
||||
|
||||
boundary = _as_utc(started_at)
|
||||
if _as_utc(new_meter.started_at) != boundary:
|
||||
raise BindingValidationError(
|
||||
"Meter-swap binding must start at the new meter's started_at boundary."
|
||||
)
|
||||
covering_bindings = [
|
||||
binding
|
||||
for binding in session.execute(
|
||||
select(MeterSourceBinding).where(MeterSourceBinding.channel_id == channel_id)
|
||||
).scalars()
|
||||
if _as_utc(binding.started_at) < boundary
|
||||
and (binding.ended_at is None or _as_utc(binding.ended_at) >= boundary)
|
||||
]
|
||||
|
||||
if not covering_bindings:
|
||||
return create_binding(
|
||||
session,
|
||||
meter_id=new_meter_id,
|
||||
channel_id=channel_id,
|
||||
started_at=started_at,
|
||||
)
|
||||
|
||||
if old_meter_id is None or len(covering_bindings) != 1:
|
||||
raise BindingOverlapError("Channel is occupied or has an ambiguous binding at meter swap.")
|
||||
|
||||
old_meter = _get_meter(session, old_meter_id)
|
||||
old_binding = covering_bindings[0]
|
||||
if (
|
||||
old_meter.commodity != new_meter.commodity
|
||||
or old_meter.ended_at is None
|
||||
or _as_utc(old_meter.ended_at) != boundary
|
||||
or old_binding.meter_id != old_meter.id
|
||||
):
|
||||
raise BindingOverlapError("Channel is occupied by a binding that cannot be handed off.")
|
||||
|
||||
update_binding(session, old_binding.id, ended_at=started_at)
|
||||
return create_binding(
|
||||
session,
|
||||
meter_id=new_meter_id,
|
||||
channel_id=channel_id,
|
||||
started_at=started_at,
|
||||
)
|
||||
|
||||
|
||||
def update_binding(
|
||||
session: Session,
|
||||
binding_id: int,
|
||||
|
||||
Reference in New Issue
Block a user