test_plug_energy_history.py 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. """Today / Yesterday derived from a plug's lifetime energy counter (#2539).
  2. The reporter's Shelly Plug S Gen3 reports one number, ``aenergy.total``, and it
  3. only ever climbs. Bambuddy filed that under "today", so Today never reset at
  4. midnight and Yesterday and Total stayed at zero forever. These tests pin the
  5. arithmetic that replaces it, and the two ways it can legitimately have no answer.
  6. """
  7. from __future__ import annotations
  8. from datetime import datetime, timedelta, timezone
  9. import pytest
  10. from backend.app.models.smart_plug import SmartPlug
  11. from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot
  12. from backend.app.services.plug_energy_history import derive_today_yesterday, fill_derived_energy
  13. from backend.app.utils.local_time import local_day_start, to_naive_utc
  14. pytestmark = pytest.mark.asyncio
  15. @pytest.fixture(autouse=True)
  16. def berlin(monkeypatch):
  17. """The reporter's timezone. A UTC day boundary would roll his Today over at
  18. 02:00 wall-clock, which is the whole reason local_day_start exists.
  19. """
  20. monkeypatch.setenv("TZ", "Europe/Berlin")
  21. async def _plug(db) -> SmartPlug:
  22. plug = SmartPlug(
  23. name="Shelly",
  24. plug_type="rest",
  25. rest_energy_total_path="aenergy.total",
  26. rest_energy_total_multiplier=0.001,
  27. )
  28. db.add(plug)
  29. await db.commit()
  30. await db.refresh(plug)
  31. return plug
  32. async def _snapshot(db, plug_id: int, when: datetime, kwh: float) -> None:
  33. db.add(
  34. SmartPlugEnergySnapshot(
  35. plug_id=plug_id,
  36. recorded_at=to_naive_utc(when),
  37. lifetime_kwh=kwh,
  38. )
  39. )
  40. await db.commit()
  41. async def test_derives_today_and_yesterday_from_the_counter(db_session):
  42. plug = await _plug(db_session)
  43. now = datetime.now(timezone.utc)
  44. await _snapshot(db_session, plug.id, local_day_start(now, days_ago=1), 100.0)
  45. await _snapshot(db_session, plug.id, local_day_start(now, days_ago=0), 102.0)
  46. today, yesterday = await derive_today_yesterday(db_session, plug.id, live_total_kwh=103.5)
  47. assert today == pytest.approx(1.5) # counter now, minus this midnight
  48. assert yesterday == pytest.approx(2.0) # this midnight, minus the one before
  49. async def test_yesterday_is_none_until_two_midnights_have_passed(db_session):
  50. """A day-old install can say what today used, but has nothing to compare
  51. yesterday against. Better an empty field than a fabricated one.
  52. """
  53. plug = await _plug(db_session)
  54. now = datetime.now(timezone.utc)
  55. await _snapshot(db_session, plug.id, local_day_start(now, days_ago=0), 102.0)
  56. today, yesterday = await derive_today_yesterday(db_session, plug.id, live_total_kwh=103.5)
  57. assert today == pytest.approx(1.5)
  58. assert yesterday is None
  59. async def test_nothing_derivable_before_the_first_midnight(db_session):
  60. plug = await _plug(db_session)
  61. now = datetime.now(timezone.utc)
  62. # Snapshot taken this morning, after midnight — no baseline for the day.
  63. # Anchored to the boundary, not to the wall clock: `now - 30 minutes` is
  64. # before local midnight for the first half hour of every local day, which
  65. # made this the only test in the file that could drift (#2938).
  66. await _snapshot(db_session, plug.id, local_day_start(now) + timedelta(minutes=30), 102.0)
  67. today, yesterday = await derive_today_yesterday(db_session, plug.id, live_total_kwh=103.5)
  68. assert today is None
  69. assert yesterday is None
  70. async def test_counter_reset_reports_nothing_rather_than_a_negative(db_session):
  71. """A factory reset zeroes a Shelly's aenergy.total. The delta goes negative,
  72. and "-101.6 kWh used today" is worse than a blank.
  73. """
  74. plug = await _plug(db_session)
  75. now = datetime.now(timezone.utc)
  76. await _snapshot(db_session, plug.id, local_day_start(now, days_ago=1), 100.0)
  77. await _snapshot(db_session, plug.id, local_day_start(now, days_ago=0), 102.0)
  78. today, _ = await derive_today_yesterday(db_session, plug.id, live_total_kwh=0.4)
  79. assert today is None
  80. async def test_snapshots_from_other_plugs_are_not_borrowed(db_session):
  81. plug = await _plug(db_session)
  82. other = SmartPlug(name="Other", plug_type="rest")
  83. db_session.add(other)
  84. await db_session.commit()
  85. await db_session.refresh(other)
  86. now = datetime.now(timezone.utc)
  87. await _snapshot(db_session, other.id, local_day_start(now, days_ago=0), 50.0)
  88. today, yesterday = await derive_today_yesterday(db_session, plug.id, live_total_kwh=103.5)
  89. assert today is None
  90. assert yesterday is None
  91. class TestFillDerivedEnergy:
  92. async def test_fills_today_and_yesterday_for_a_lifetime_only_plug(self, db_session):
  93. plug = await _plug(db_session)
  94. now = datetime.now(timezone.utc)
  95. await _snapshot(db_session, plug.id, local_day_start(now, days_ago=1), 100.0)
  96. await _snapshot(db_session, plug.id, local_day_start(now, days_ago=0), 102.0)
  97. energy = await fill_derived_energy(db_session, plug.id, {"power": 84.0, "total": 103.5})
  98. assert energy["today"] == pytest.approx(1.5)
  99. assert energy["yesterday"] == pytest.approx(2.0)
  100. assert energy["total"] == 103.5
  101. async def test_never_overwrites_what_the_device_reported(self, db_session):
  102. """Tasmota knows its own daily figures. A device that measured the day
  103. itself beats our hourly-snapshot arithmetic, so it wins.
  104. """
  105. plug = await _plug(db_session)
  106. now = datetime.now(timezone.utc)
  107. await _snapshot(db_session, plug.id, local_day_start(now, days_ago=1), 100.0)
  108. await _snapshot(db_session, plug.id, local_day_start(now, days_ago=0), 102.0)
  109. energy = await fill_derived_energy(db_session, plug.id, {"today": 9.9, "yesterday": 8.8, "total": 103.5})
  110. assert energy["today"] == 9.9
  111. assert energy["yesterday"] == 8.8
  112. async def test_no_lifetime_counter_is_left_alone(self, db_session):
  113. """A REST plug configured with only a today-path, or an MQTT plug: there
  114. is nothing to derive from, and its Today already came from the device.
  115. """
  116. plug = await _plug(db_session)
  117. energy = await fill_derived_energy(db_session, plug.id, {"power": 84.0, "today": 1.2})
  118. assert energy == {"power": 84.0, "today": 1.2}