test_energy_snapshots.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. """Tests for #941 — date-range energy in total consumption mode + restart-resilient per-print tracking.
  2. Covers:
  3. - `_sum_snapshot_deltas()`: correct (endpoint - baseline) arithmetic
  4. - Counter-reset clamp, warming-up flag, missing-endpoint handling
  5. - Restart resilience: per-print `energy_start_kwh` persists across a
  6. "simulated restart" (new session/process), so the print-end handler can
  7. still compute the delta.
  8. """
  9. from datetime import datetime, timedelta, timezone
  10. import pytest
  11. from sqlalchemy import select
  12. from backend.app.api.routes.archives import _sum_snapshot_deltas
  13. from backend.app.models.archive import PrintArchive
  14. from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot
  15. def _snap(plug_id: int, recorded_at: datetime, kwh: float) -> SmartPlugEnergySnapshot:
  16. return SmartPlugEnergySnapshot(plug_id=plug_id, recorded_at=recorded_at, lifetime_kwh=kwh)
  17. class TestSumSnapshotDeltas:
  18. @pytest.mark.asyncio
  19. async def test_returns_zero_when_no_plugs(self, db_session):
  20. total, warming = await _sum_snapshot_deltas(db_session, dt_from=None, dt_to=None)
  21. assert total == 0.0
  22. assert warming is False
  23. @pytest.mark.asyncio
  24. async def test_simple_delta_with_baseline_and_endpoint(self, db_session, smart_plug_factory):
  25. plug = await smart_plug_factory(name="A")
  26. # Baseline sits before the range, endpoint inside the range.
  27. t0 = datetime(2026, 4, 1, 0, 0, tzinfo=timezone.utc)
  28. db_session.add(_snap(plug.id, t0, 100.0)) # baseline
  29. db_session.add(_snap(plug.id, t0 + timedelta(days=2), 115.0)) # endpoint
  30. await db_session.commit()
  31. range_start = t0 + timedelta(days=1)
  32. range_end = t0 + timedelta(days=3)
  33. total, warming = await _sum_snapshot_deltas(db_session, dt_from=range_start, dt_to=range_end)
  34. assert total == pytest.approx(15.0)
  35. assert warming is False
  36. @pytest.mark.asyncio
  37. async def test_warming_up_when_no_baseline_before_range(self, db_session, smart_plug_factory):
  38. plug = await smart_plug_factory(name="A")
  39. # All snapshots happen AFTER range_start — simulates fresh upgrade.
  40. t0 = datetime(2026, 4, 10, 12, 0, tzinfo=timezone.utc)
  41. db_session.add(_snap(plug.id, t0, 500.0)) # first snapshot ever (fallback baseline)
  42. db_session.add(_snap(plug.id, t0 + timedelta(hours=6), 502.0)) # endpoint
  43. await db_session.commit()
  44. range_start = datetime(2026, 4, 10, 0, 0, tzinfo=timezone.utc) # before any snapshot
  45. range_end = datetime(2026, 4, 10, 23, 59, tzinfo=timezone.utc)
  46. total, warming = await _sum_snapshot_deltas(db_session, dt_from=range_start, dt_to=range_end)
  47. assert total == pytest.approx(2.0) # 502 - 500
  48. assert warming is True
  49. @pytest.mark.asyncio
  50. async def test_counter_reset_is_clamped_to_zero(self, db_session, smart_plug_factory):
  51. plug = await smart_plug_factory(name="A")
  52. t0 = datetime(2026, 4, 1, 0, 0, tzinfo=timezone.utc)
  53. db_session.add(_snap(plug.id, t0, 1000.0)) # baseline
  54. # Counter reset — endpoint is lower than baseline (plug replaced, firmware reset, ...)
  55. db_session.add(_snap(plug.id, t0 + timedelta(days=2), 5.0))
  56. await db_session.commit()
  57. total, warming = await _sum_snapshot_deltas(
  58. db_session,
  59. dt_from=t0 + timedelta(days=1),
  60. dt_to=t0 + timedelta(days=3),
  61. )
  62. assert total == 0.0
  63. assert warming is False
  64. @pytest.mark.asyncio
  65. async def test_multiple_plugs_are_summed(self, db_session, smart_plug_factory):
  66. plug1 = await smart_plug_factory(name="A", ip_address="10.0.0.1")
  67. plug2 = await smart_plug_factory(name="B", ip_address="10.0.0.2")
  68. t0 = datetime(2026, 4, 1, 0, 0, tzinfo=timezone.utc)
  69. # plug1: 100 -> 110 (delta 10)
  70. db_session.add(_snap(plug1.id, t0, 100.0))
  71. db_session.add(_snap(plug1.id, t0 + timedelta(days=2), 110.0))
  72. # plug2: 50 -> 55 (delta 5)
  73. db_session.add(_snap(plug2.id, t0, 50.0))
  74. db_session.add(_snap(plug2.id, t0 + timedelta(days=2), 55.0))
  75. await db_session.commit()
  76. total, warming = await _sum_snapshot_deltas(
  77. db_session,
  78. dt_from=t0 + timedelta(days=1),
  79. dt_to=t0 + timedelta(days=3),
  80. )
  81. assert total == pytest.approx(15.0)
  82. assert warming is False
  83. @pytest.mark.asyncio
  84. async def test_plug_with_no_snapshots_signals_warming(self, db_session, smart_plug_factory):
  85. # Plug exists but never snapshotted (yet).
  86. await smart_plug_factory(name="A")
  87. t0 = datetime(2026, 4, 1, 0, 0, tzinfo=timezone.utc)
  88. total, warming = await _sum_snapshot_deltas(
  89. db_session,
  90. dt_from=t0,
  91. dt_to=t0 + timedelta(days=1),
  92. )
  93. assert total == 0.0
  94. assert warming is True
  95. @pytest.mark.asyncio
  96. async def test_endpoint_picks_last_snapshot_at_or_before_range_end(self, db_session, smart_plug_factory):
  97. plug = await smart_plug_factory(name="A")
  98. t0 = datetime(2026, 4, 1, 0, 0, tzinfo=timezone.utc)
  99. db_session.add(_snap(plug.id, t0, 100.0)) # baseline
  100. db_session.add(_snap(plug.id, t0 + timedelta(days=1), 105.0)) # inside range
  101. db_session.add(_snap(plug.id, t0 + timedelta(days=5), 130.0)) # AFTER range_end — must be ignored
  102. await db_session.commit()
  103. total, _warming = await _sum_snapshot_deltas(
  104. db_session,
  105. dt_from=t0 + timedelta(hours=12),
  106. dt_to=t0 + timedelta(days=2),
  107. )
  108. # Baseline is last snapshot <= range_start → the t0 one at 100
  109. # Endpoint is last snapshot <= range_end → the day-1 one at 105
  110. assert total == pytest.approx(5.0)
  111. class TestPerPrintRestartResilience:
  112. """#941: per-print energy tracking survives a mid-print backend restart.
  113. The critical change: `energy_start_kwh` is stored on the archive row, not
  114. in an in-memory dict. A new DB session should still be able to read it.
  115. """
  116. @pytest.mark.asyncio
  117. async def test_energy_start_kwh_persists_to_db(self, db_session, printer_factory):
  118. printer = await printer_factory()
  119. archive = PrintArchive(
  120. printer_id=printer.id,
  121. filename="resilience.gcode.3mf",
  122. print_name="Resilience",
  123. file_path="archives/test/resilience.gcode.3mf",
  124. file_size=1000,
  125. status="printing",
  126. energy_start_kwh=123.456,
  127. )
  128. db_session.add(archive)
  129. await db_session.commit()
  130. archive_id = archive.id
  131. # Drop the ORM reference and re-fetch, simulating a fresh session
  132. # (the situation we'd be in after a backend restart).
  133. db_session.expunge_all()
  134. result = await db_session.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
  135. reloaded = result.scalar_one()
  136. assert reloaded.energy_start_kwh == pytest.approx(123.456)
  137. @pytest.mark.asyncio
  138. async def test_energy_kwh_delta_computes_from_persisted_start(self, db_session, printer_factory):
  139. """Simulates the background energy calc reading from DB instead of a dict."""
  140. printer = await printer_factory()
  141. archive = PrintArchive(
  142. printer_id=printer.id,
  143. filename="delta.gcode.3mf",
  144. print_name="Delta",
  145. file_path="archives/test/delta.gcode.3mf",
  146. file_size=1000,
  147. status="completed",
  148. energy_start_kwh=200.0,
  149. )
  150. db_session.add(archive)
  151. await db_session.commit()
  152. # Emulate the end-of-print calculation: plug currently reads 203.4 kWh
  153. ending_kwh = 203.4
  154. assert archive.energy_start_kwh is not None
  155. archive.energy_kwh = round(ending_kwh - archive.energy_start_kwh, 4)
  156. archive.energy_cost = round(archive.energy_kwh * 0.30, 3)
  157. await db_session.commit()
  158. # Re-read and verify
  159. db_session.expunge_all()
  160. result = await db_session.execute(select(PrintArchive).where(PrintArchive.id == archive.id))
  161. reloaded = result.scalar_one()
  162. assert reloaded.energy_kwh == pytest.approx(3.4)
  163. assert reloaded.energy_cost == pytest.approx(1.02)