test_obico_print_lifecycle_3459.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  1. """Obico detection keeps a print's state through a pause (#3459).
  2. Every poll that didn't see RUNNING used to wipe the print's smoothing and its
  3. "action fired", PAUSE included: a print Obico had paused was re-armed on
  4. resume, ran its warmup again, and could be paused again by the same false
  5. positive. The state now ends with the print (FINISH / FAILED / IDLE) or with
  6. a new job, and the printer's long-term baseline outlives its prints.
  7. """
  8. from unittest.mock import AsyncMock, MagicMock, patch
  9. import pytest
  10. from backend.app.services.obico_detection import ObicoDetectionService
  11. from backend.app.services.obico_smoothing import WARMUP_FRAMES
  12. FAKE_JPEG = b"\xff\xd8\xff\xd9"
  13. def _settings(action: str = "pause") -> dict:
  14. return {
  15. "enabled": True,
  16. "ml_url": "http://obico:3333",
  17. "sensitivity": "medium",
  18. "action": action,
  19. "poll_interval": 10,
  20. "enabled_printers": None,
  21. "snapshot_base_url": "http://bambuddy:8000",
  22. }
  23. def _status(state: str = "RUNNING", task: str = "job", subtask_id: str | None = "111"):
  24. return MagicMock(state=state, task_name=task, subtask_name="", subtask_id=subtask_id)
  25. class _Rig:
  26. """Drives _poll_once with a printer status and an ML API answer."""
  27. def __init__(self, svc: ObicoDetectionService):
  28. self.svc = svc
  29. self.p = 0.0
  30. self.action = AsyncMock()
  31. async def poll(self, status, settings: dict, times: int = 1) -> None:
  32. response = MagicMock()
  33. response.status_code = 200
  34. response.raise_for_status = MagicMock()
  35. response.json.side_effect = lambda: {"detections": [["failure", self.p, [0, 0, 1, 1]]] if self.p else []}
  36. client = MagicMock()
  37. client.get = AsyncMock(return_value=response)
  38. client.__aenter__ = AsyncMock(return_value=client)
  39. client.__aexit__ = AsyncMock(return_value=False)
  40. manager = MagicMock()
  41. manager.get_all_statuses.return_value = {1: status}
  42. manager.is_connected.return_value = True
  43. with (
  44. patch.dict("sys.modules", {"backend.app.services.printer_manager": MagicMock(printer_manager=manager)}),
  45. patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=client),
  46. patch("backend.app.services.obico_actions.execute_action", new=self.action),
  47. patch.object(self.svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
  48. ):
  49. for _ in range(times):
  50. await self.svc._poll_once(settings)
  51. @pytest.fixture
  52. def rig():
  53. return _Rig(ObicoDetectionService())
  54. async def _pause_by_obico(rig: _Rig) -> None:
  55. rig.p = 0.0
  56. await rig.poll(_status(), _settings(), times=WARMUP_FRAMES + 200)
  57. rig.p = 3.0
  58. await rig.poll(_status(), _settings(), times=40)
  59. assert rig.action.await_count == 1
  60. @pytest.mark.asyncio
  61. async def test_a_resumed_print_keeps_its_state_and_is_not_paused_again(rig):
  62. await _pause_by_obico(rig)
  63. frames = rig.svc._states[1].frame_count
  64. await rig.poll(_status("PAUSE"), _settings(), times=5)
  65. assert rig.svc._states[1].frame_count == frames # kept, not checked while paused
  66. await rig.poll(_status(), _settings(), times=WARMUP_FRAMES + 10)
  67. assert rig.svc._states[1].frame_count == frames + WARMUP_FRAMES + 10
  68. assert rig.action.await_count == 1
  69. @pytest.mark.parametrize("ended", ["FINISH", "FAILED", "IDLE", "PREPARE", "SLICING"])
  70. @pytest.mark.asyncio
  71. async def test_the_state_ends_with_the_print(rig, ended):
  72. await _pause_by_obico(rig)
  73. await rig.poll(_status(ended), _settings())
  74. assert 1 not in rig.svc._states
  75. assert 1 not in rig.svc._action_fired
  76. @pytest.mark.asyncio
  77. async def test_another_job_of_the_same_file_starts_fresh(rig):
  78. # Its FINISH and PREPARE both fell between two polls; same task name.
  79. await _pause_by_obico(rig)
  80. rig.p = 0.0
  81. await rig.poll(_status(subtask_id="222"), _settings())
  82. assert rig.svc._states[1].frame_count == 1
  83. assert rig.svc._action_fired[1] is False
  84. @pytest.mark.asyncio
  85. async def test_a_reprint_without_a_job_id_starts_fresh_after_prepare(rig):
  86. # A print from the SD card or the touchscreen may report no job id.
  87. await _pause_by_obico(rig)
  88. await rig.poll(_status("PREPARE", subtask_id=None), _settings())
  89. rig.p = 0.0
  90. await rig.poll(_status(subtask_id=None), _settings())
  91. assert rig.svc._states[1].frame_count == 1
  92. assert rig.svc._action_fired[1] is False
  93. @pytest.mark.asyncio
  94. async def test_a_job_id_arriving_late_is_the_same_print(rig):
  95. await rig.poll(_status(subtask_id=None), _settings(), times=3)
  96. await rig.poll(_status(subtask_id="111"), _settings(), times=2)
  97. assert rig.svc._states[1].frame_count == 5
  98. @pytest.mark.asyncio
  99. async def test_the_baseline_carries_into_the_next_print(rig):
  100. rig.p = 0.82 # the plate artwork, in every frame
  101. await rig.poll(_status(), _settings(), times=3000)
  102. await rig.poll(_status("FINISH"), _settings())
  103. # The next print's camera only shows it once the plate is uncovered: on a
  104. # printer that had never seen it, that is a sudden rise.
  105. rig.p = 0.0
  106. await rig.poll(_status(task="next", subtask_id="222"), _settings("notify"), times=200)
  107. rig.p = 0.82
  108. await rig.poll(_status(task="next", subtask_id="222"), _settings("notify"), times=40)
  109. assert rig.svc._last_class[1] == "safe"
  110. assert rig.action.await_count == 0
  111. @pytest.mark.asyncio
  112. async def test_the_status_shows_the_score_minus_the_baseline(rig):
  113. rig.p = 0.82
  114. await rig.poll(_status(), _settings(), times=WARMUP_FRAMES + 200)
  115. assert rig.svc.get_per_printer()[1]["score"] == pytest.approx(0.0, abs=1e-3)
  116. # Once a part covers what the baseline learned, the score drops below 0:
  117. # shown as no detection, not as a negative number.
  118. rig.p = 0.3 # still a detection, so it is logged
  119. await rig.poll(_status(), _settings(), times=20)
  120. assert rig.svc._states[1].last.score < 0
  121. assert rig.svc.get_status()["history"]
  122. assert rig.svc.get_per_printer()[1]["score"] == 0.0
  123. assert all(entry["score"] >= 0 for entry in rig.svc.get_status()["history"])
  124. def test_the_status_shows_the_pause_level():
  125. thresholds = ObicoDetectionService().get_status("medium")["thresholds"]
  126. assert thresholds["pause"] == pytest.approx(0.78 * 1.75)
  127. @pytest.mark.asyncio
  128. async def test_a_new_print_forgets_the_old_job_id(rig):
  129. # Task name changed while the new job id is still unknown: when it
  130. # arrives it belongs to this print, not to a third one.
  131. await rig.poll(_status(subtask_id="111"), _settings(), times=3)
  132. await rig.poll(_status(task="next", subtask_id=None), _settings(), times=3)
  133. await rig.poll(_status(task="next", subtask_id="222"), _settings(), times=2)
  134. assert rig.svc._states[1].frame_count == 5
  135. @pytest.mark.asyncio
  136. async def test_forgetting_a_printer_drops_its_baseline(rig):
  137. await rig.poll(_status(), _settings(), times=3)
  138. rig.svc.forget_printer(1)
  139. assert 1 not in rig.svc._baselines
  140. @pytest.mark.parametrize(("action", "fired"), [("notify", 1), ("pause", 0), ("pause_and_off", 0)])
  141. @pytest.mark.asyncio
  142. async def test_notify_fires_at_the_alert_level_and_pausing_waits(rig, action, fired):
  143. # A slow build-up to 0.8 over ~25 minutes: Obico alerts, never pauses.
  144. await rig.poll(_status(), _settings(action), times=1550)
  145. for i in range(1, 300):
  146. rig.p = 0.8 * min(1.0, i / 150)
  147. await rig.poll(_status(), _settings(action))
  148. assert rig.action.await_count == fired