test_scheduler_wear_order_3480.py 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. """An "Any model" job goes to the eligible printer with the fewest print hours (#3480).
  2. The matcher used to take the first eligible printer in database order, so on a
  3. farm the lowest id got most of the work. It now ranks the printers of the model
  4. by the maintenance hour counter (runtime plus the baseline offset), fewest
  5. first, and the smart-plug wake step powers on the least worn one the same way.
  6. Everything that decides whether a printer is eligible is unchanged.
  7. """
  8. from contextlib import ExitStack
  9. from types import SimpleNamespace
  10. from unittest.mock import AsyncMock, MagicMock, patch
  11. import pytest
  12. from sqlalchemy import select
  13. from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
  14. import backend.app.models # noqa: F401 - populate Base.metadata
  15. from backend.app.core.database import Base
  16. from backend.app.models.library import LibraryFile
  17. from backend.app.models.print_queue import PrintQueueItem
  18. from backend.app.models.printer import Printer
  19. from backend.app.services.print_scheduler import PrintScheduler, _ModelCandidate
  20. HOUR = 3600
  21. async def _db(printers: dict[int, tuple[int, float]]):
  22. """X1Cs by id: (runtime_seconds, print_hours_offset)."""
  23. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  24. async with engine.begin() as conn:
  25. await conn.run_sync(Base.metadata.create_all)
  26. session_maker = async_sessionmaker(engine, expire_on_commit=False)
  27. async with session_maker() as db:
  28. db.add_all(
  29. [
  30. Printer(
  31. id=pid,
  32. name=f"X1C-{pid}",
  33. serial_number=f"X1C000{pid}",
  34. ip_address=f"192.0.2.{pid}",
  35. access_code="x",
  36. model="X1C",
  37. is_active=True,
  38. runtime_seconds=runtime,
  39. print_hours_offset=offset,
  40. )
  41. for pid, (runtime, offset) in printers.items()
  42. ]
  43. )
  44. await db.commit()
  45. return engine, SimpleNamespace(session_maker=session_maker)
  46. @pytest.fixture
  47. async def farm():
  48. """Printer 1 is the most worn, printer 3 the least; 2 sits between them."""
  49. engine, ctx = await _db({1: (900 * HOUR, 0.0), 2: (100 * HOUR, 0.0), 3: (20 * HOUR, 0.0)})
  50. try:
  51. yield ctx
  52. finally:
  53. await engine.dispose()
  54. async def _find(ctx, scheduler, *, exclude=frozenset(), short=None, **kwargs):
  55. async def is_short(pid):
  56. return pid in short
  57. with (
  58. patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
  59. patch.object(scheduler, "_is_printer_idle", MagicMock(return_value=True)),
  60. ):
  61. async with ctx.session_maker() as db:
  62. return await scheduler._find_idle_printer_for_model(
  63. db, "X1C", set(exclude), is_short=is_short if short is not None else None, **kwargs
  64. )
  65. class TestMatcher:
  66. @pytest.mark.asyncio
  67. async def test_takes_the_printer_with_the_fewest_hours(self, farm):
  68. assert await _find(farm, PrintScheduler()) == (3, None)
  69. @pytest.mark.asyncio
  70. async def test_a_busy_least_worn_printer_passes_the_job_to_the_next(self, farm):
  71. assert await _find(farm, PrintScheduler(), exclude={3}) == (2, None)
  72. @pytest.mark.asyncio
  73. async def test_counts_the_baseline_offset_like_maintenance(self):
  74. """A second-hand machine entered with its real hours is not "new"."""
  75. engine, ctx = await _db({1: (10 * HOUR, 0.0), 2: (0, 500.0)})
  76. try:
  77. assert await _find(ctx, PrintScheduler()) == (1, None)
  78. finally:
  79. await engine.dispose()
  80. @pytest.mark.asyncio
  81. async def test_printers_still_at_zero_go_by_id(self):
  82. engine, ctx = await _db({3: (0, 0.0), 1: (0, 0.0), 2: (0, 0.0)})
  83. try:
  84. assert await _find(ctx, PrintScheduler()) == (1, None)
  85. finally:
  86. await engine.dispose()
  87. @pytest.mark.asyncio
  88. async def test_a_short_printer_is_passed_over_for_the_next_least_worn(self, farm):
  89. assert await _find(farm, PrintScheduler(), short={3}) == (2, None)
  90. @pytest.mark.asyncio
  91. async def test_every_printer_short_holds_the_job_on_the_least_worn(self, farm):
  92. assert await _find(farm, PrintScheduler(), short={1, 2, 3}) == (3, None)
  93. @pytest.mark.asyncio
  94. async def test_more_preferred_colours_still_beat_fewer_hours(self, farm):
  95. scheduler = PrintScheduler()
  96. matches = {1: 2, 2: 1, 3: 1}
  97. with patch.object(scheduler, "_count_override_color_matches", side_effect=lambda pid, _o: matches[pid]):
  98. result = await _find(farm, scheduler, filament_overrides=[{"type": "PLA", "color": "#000000"}])
  99. assert result == (1, None)
  100. @pytest.mark.asyncio
  101. async def test_equal_preferred_colours_go_to_the_fewest_hours(self, farm):
  102. scheduler = PrintScheduler()
  103. matches = {1: 1, 2: 2, 3: 2}
  104. with patch.object(scheduler, "_count_override_color_matches", side_effect=lambda pid, _o: matches[pid]):
  105. result = await _find(farm, scheduler, filament_overrides=[{"type": "PLA", "color": "#000000"}])
  106. assert result == (3, None)
  107. class TestQueuePass:
  108. @pytest.mark.asyncio
  109. async def test_jobs_spread_over_the_least_worn_printers(self, farm):
  110. """Two "any X1C" jobs, three idle X1Cs: the two least worn take them."""
  111. ids = []
  112. async with farm.session_maker() as db:
  113. for pos in (1, 2):
  114. lib = LibraryFile(
  115. filename=f"job{pos}.gcode.3mf",
  116. file_path=f"/library/job{pos}.gcode.3mf",
  117. file_size=10,
  118. file_type="gcode.3mf",
  119. file_metadata={"sliced_for_model": "X1C"},
  120. )
  121. db.add(lib)
  122. await db.flush()
  123. item = PrintQueueItem(status="pending", position=pos, target_model="X1C", library_file_id=lib.id)
  124. db.add(item)
  125. await db.flush()
  126. ids.append(item.id)
  127. await db.commit()
  128. scheduler = PrintScheduler()
  129. patches = [
  130. patch("backend.app.services.print_scheduler.async_session", farm.session_maker),
  131. patch("backend.app.core.database.async_session", farm.session_maker),
  132. patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
  133. patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
  134. patch(
  135. "backend.app.services.print_scheduler.ha_sensor_manager.blocked_printers",
  136. AsyncMock(return_value={}),
  137. ),
  138. patch(
  139. "backend.app.services.notification_service.notification_service.on_queue_job_waiting",
  140. AsyncMock(),
  141. ),
  142. patch(
  143. "backend.app.services.notification_service.notification_service.on_queue_job_assigned",
  144. AsyncMock(),
  145. ),
  146. patch("backend.app.services.print_scheduler.compute_deficit_for_queue_item", AsyncMock(return_value=[])),
  147. patch.object(scheduler, "_filament_short_on", AsyncMock(return_value=False)),
  148. patch.object(scheduler, "_is_printer_idle", MagicMock(return_value=True)),
  149. patch.object(scheduler, "_check_auto_drying", AsyncMock()),
  150. patch.object(scheduler, "_ensure_ams_mapping", AsyncMock(return_value=None)),
  151. patch.object(scheduler, "_block_on_unmatched_filament", AsyncMock(return_value=False)),
  152. patch.object(scheduler, "_launch_uploads", MagicMock()),
  153. ]
  154. with ExitStack() as stack:
  155. for p in patches:
  156. stack.enter_context(p)
  157. await scheduler.check_queue()
  158. async with farm.session_maker() as db:
  159. rows = (await db.execute(select(PrintQueueItem).where(PrintQueueItem.id.in_(ids)))).scalars().all()
  160. assert sorted(row.printer_id for row in rows) == [2, 3]
  161. class TestWake:
  162. @pytest.mark.asyncio
  163. async def test_powers_on_the_least_worn_offline_printer(self, farm):
  164. scheduler = PrintScheduler()
  165. plug = SimpleNamespace(id=1, auto_on=True, enabled=True, controls_printer_power=True)
  166. power_on = AsyncMock(return_value=True)
  167. with (
  168. patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=False)),
  169. patch(
  170. "backend.app.services.print_scheduler.printer_manager.is_awaiting_plate_clear",
  171. MagicMock(return_value=False),
  172. ),
  173. patch.object(scheduler, "_cached_filament_shortfall", MagicMock(return_value=[])),
  174. patch.object(scheduler, "_get_smart_plugs", AsyncMock(return_value=[plug])),
  175. patch.object(scheduler, "_power_on_and_wait", power_on),
  176. ):
  177. async with farm.session_maker() as db:
  178. woken = await scheduler._wake_printer_for_model(
  179. db,
  180. [
  181. _ModelCandidate(
  182. target_model="X1C", sliced_for="X1C", required_filament_types=None, filament_overrides=None
  183. )
  184. ],
  185. None,
  186. set(),
  187. {1, 2, 3},
  188. require_plate_clear=True,
  189. )
  190. assert woken == (3, 3)
  191. assert power_on.call_args[0][1] == 3