|
|
@@ -0,0 +1,159 @@
|
|
|
+"""Today / Yesterday derived from a plug's lifetime energy counter (#2539).
|
|
|
+
|
|
|
+The reporter's Shelly Plug S Gen3 reports one number, ``aenergy.total``, and it
|
|
|
+only ever climbs. Bambuddy filed that under "today", so Today never reset at
|
|
|
+midnight and Yesterday and Total stayed at zero forever. These tests pin the
|
|
|
+arithmetic that replaces it, and the two ways it can legitimately have no answer.
|
|
|
+"""
|
|
|
+
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+from datetime import datetime, timedelta, timezone
|
|
|
+
|
|
|
+import pytest
|
|
|
+
|
|
|
+from backend.app.models.smart_plug import SmartPlug
|
|
|
+from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot
|
|
|
+from backend.app.services.plug_energy_history import derive_today_yesterday, fill_derived_energy
|
|
|
+from backend.app.utils.local_time import local_day_start, to_naive_utc
|
|
|
+
|
|
|
+pytestmark = pytest.mark.asyncio
|
|
|
+
|
|
|
+
|
|
|
+@pytest.fixture(autouse=True)
|
|
|
+def berlin(monkeypatch):
|
|
|
+ """The reporter's timezone. A UTC day boundary would roll his Today over at
|
|
|
+ 02:00 wall-clock, which is the whole reason local_day_start exists.
|
|
|
+ """
|
|
|
+ monkeypatch.setenv("TZ", "Europe/Berlin")
|
|
|
+
|
|
|
+
|
|
|
+async def _plug(db) -> SmartPlug:
|
|
|
+ plug = SmartPlug(
|
|
|
+ name="Shelly",
|
|
|
+ plug_type="rest",
|
|
|
+ rest_energy_total_path="aenergy.total",
|
|
|
+ rest_energy_total_multiplier=0.001,
|
|
|
+ )
|
|
|
+ db.add(plug)
|
|
|
+ await db.commit()
|
|
|
+ await db.refresh(plug)
|
|
|
+ return plug
|
|
|
+
|
|
|
+
|
|
|
+async def _snapshot(db, plug_id: int, when: datetime, kwh: float) -> None:
|
|
|
+ db.add(
|
|
|
+ SmartPlugEnergySnapshot(
|
|
|
+ plug_id=plug_id,
|
|
|
+ recorded_at=to_naive_utc(when),
|
|
|
+ lifetime_kwh=kwh,
|
|
|
+ )
|
|
|
+ )
|
|
|
+ await db.commit()
|
|
|
+
|
|
|
+
|
|
|
+async def test_derives_today_and_yesterday_from_the_counter(db_session):
|
|
|
+ plug = await _plug(db_session)
|
|
|
+ now = datetime.now(timezone.utc)
|
|
|
+
|
|
|
+ await _snapshot(db_session, plug.id, local_day_start(now, days_ago=1), 100.0)
|
|
|
+ await _snapshot(db_session, plug.id, local_day_start(now, days_ago=0), 102.0)
|
|
|
+
|
|
|
+ today, yesterday = await derive_today_yesterday(db_session, plug.id, live_total_kwh=103.5)
|
|
|
+
|
|
|
+ assert today == pytest.approx(1.5) # counter now, minus this midnight
|
|
|
+ assert yesterday == pytest.approx(2.0) # this midnight, minus the one before
|
|
|
+
|
|
|
+
|
|
|
+async def test_yesterday_is_none_until_two_midnights_have_passed(db_session):
|
|
|
+ """A day-old install can say what today used, but has nothing to compare
|
|
|
+ yesterday against. Better an empty field than a fabricated one.
|
|
|
+ """
|
|
|
+ plug = await _plug(db_session)
|
|
|
+ now = datetime.now(timezone.utc)
|
|
|
+ await _snapshot(db_session, plug.id, local_day_start(now, days_ago=0), 102.0)
|
|
|
+
|
|
|
+ today, yesterday = await derive_today_yesterday(db_session, plug.id, live_total_kwh=103.5)
|
|
|
+
|
|
|
+ assert today == pytest.approx(1.5)
|
|
|
+ assert yesterday is None
|
|
|
+
|
|
|
+
|
|
|
+async def test_nothing_derivable_before_the_first_midnight(db_session):
|
|
|
+ plug = await _plug(db_session)
|
|
|
+ now = datetime.now(timezone.utc)
|
|
|
+ # Snapshot taken this morning, after midnight — no baseline for the day.
|
|
|
+ await _snapshot(db_session, plug.id, now - timedelta(minutes=30), 102.0)
|
|
|
+
|
|
|
+ today, yesterday = await derive_today_yesterday(db_session, plug.id, live_total_kwh=103.5)
|
|
|
+
|
|
|
+ assert today is None
|
|
|
+ assert yesterday is None
|
|
|
+
|
|
|
+
|
|
|
+async def test_counter_reset_reports_nothing_rather_than_a_negative(db_session):
|
|
|
+ """A factory reset zeroes a Shelly's aenergy.total. The delta goes negative,
|
|
|
+ and "-101.6 kWh used today" is worse than a blank.
|
|
|
+ """
|
|
|
+ plug = await _plug(db_session)
|
|
|
+ now = datetime.now(timezone.utc)
|
|
|
+ await _snapshot(db_session, plug.id, local_day_start(now, days_ago=1), 100.0)
|
|
|
+ await _snapshot(db_session, plug.id, local_day_start(now, days_ago=0), 102.0)
|
|
|
+
|
|
|
+ today, _ = await derive_today_yesterday(db_session, plug.id, live_total_kwh=0.4)
|
|
|
+
|
|
|
+ assert today is None
|
|
|
+
|
|
|
+
|
|
|
+async def test_snapshots_from_other_plugs_are_not_borrowed(db_session):
|
|
|
+ plug = await _plug(db_session)
|
|
|
+ other = SmartPlug(name="Other", plug_type="rest")
|
|
|
+ db_session.add(other)
|
|
|
+ await db_session.commit()
|
|
|
+ await db_session.refresh(other)
|
|
|
+
|
|
|
+ now = datetime.now(timezone.utc)
|
|
|
+ await _snapshot(db_session, other.id, local_day_start(now, days_ago=0), 50.0)
|
|
|
+
|
|
|
+ today, yesterday = await derive_today_yesterday(db_session, plug.id, live_total_kwh=103.5)
|
|
|
+
|
|
|
+ assert today is None
|
|
|
+ assert yesterday is None
|
|
|
+
|
|
|
+
|
|
|
+class TestFillDerivedEnergy:
|
|
|
+ async def test_fills_today_and_yesterday_for_a_lifetime_only_plug(self, db_session):
|
|
|
+ plug = await _plug(db_session)
|
|
|
+ now = datetime.now(timezone.utc)
|
|
|
+ await _snapshot(db_session, plug.id, local_day_start(now, days_ago=1), 100.0)
|
|
|
+ await _snapshot(db_session, plug.id, local_day_start(now, days_ago=0), 102.0)
|
|
|
+
|
|
|
+ energy = await fill_derived_energy(db_session, plug.id, {"power": 84.0, "total": 103.5})
|
|
|
+
|
|
|
+ assert energy["today"] == pytest.approx(1.5)
|
|
|
+ assert energy["yesterday"] == pytest.approx(2.0)
|
|
|
+ assert energy["total"] == 103.5
|
|
|
+
|
|
|
+ async def test_never_overwrites_what_the_device_reported(self, db_session):
|
|
|
+ """Tasmota knows its own daily figures. A device that measured the day
|
|
|
+ itself beats our hourly-snapshot arithmetic, so it wins.
|
|
|
+ """
|
|
|
+ plug = await _plug(db_session)
|
|
|
+ now = datetime.now(timezone.utc)
|
|
|
+ await _snapshot(db_session, plug.id, local_day_start(now, days_ago=1), 100.0)
|
|
|
+ await _snapshot(db_session, plug.id, local_day_start(now, days_ago=0), 102.0)
|
|
|
+
|
|
|
+ energy = await fill_derived_energy(db_session, plug.id, {"today": 9.9, "yesterday": 8.8, "total": 103.5})
|
|
|
+
|
|
|
+ assert energy["today"] == 9.9
|
|
|
+ assert energy["yesterday"] == 8.8
|
|
|
+
|
|
|
+ async def test_no_lifetime_counter_is_left_alone(self, db_session):
|
|
|
+ """A REST plug configured with only a today-path, or an MQTT plug: there
|
|
|
+ is nothing to derive from, and its Today already came from the device.
|
|
|
+ """
|
|
|
+ plug = await _plug(db_session)
|
|
|
+
|
|
|
+ energy = await fill_derived_energy(db_session, plug.id, {"power": 84.0, "today": 1.2})
|
|
|
+
|
|
|
+ assert energy == {"power": 84.0, "today": 1.2}
|