Browse Source

"Any model" jobs go to the printer with the fewest print hours (#3480)

- _printers_for_model orders by maintenance hours (runtime plus the
  baseline offset), fewest first, ties by id
- The matcher, its short-of-filament fallback and the smart-plug wake
  step all follow that order; preferred colours still decide first
- No change to which printers are eligible
maziggy 4 days ago
parent
commit
a9f752ea63

+ 1 - 0
CHANGELOG.md

@@ -8,6 +8,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Cam Wall tiles show temperatures and finish time, and can pause, stop and switch the light (#3479, sponsor vote Q4 2026)** — With the **Full** status overlay, every connected tile shows its nozzle (both on a dual-nozzle printer), bed and chamber temperatures, and a printing tile adds its finish time beside the time left. Hovering over a tile shows **Pause/Resume** and **Stop**, with the same confirmations as the printer card, and a **chamber light** switch; **Tile controls: Always** in the wall settings keeps them on screen, and a touch screen always shows them. The controls need the `printers:control` permission; a user without it, an offline printer and a Cam Wall token (kiosk) wall get none.
 - **Cam Wall tiles show temperatures and finish time, and can pause, stop and switch the light (#3479, sponsor vote Q4 2026)** — With the **Full** status overlay, every connected tile shows its nozzle (both on a dual-nozzle printer), bed and chamber temperatures, and a printing tile adds its finish time beside the time left. Hovering over a tile shows **Pause/Resume** and **Stop**, with the same confirmations as the printer card, and a **chamber light** switch; **Tile controls: Always** in the wall settings keeps them on screen, and a touch screen always shows them. The controls need the `printers:control` permission; a user without it, an offline printer and a Cam Wall token (kiosk) wall get none.
 
 
 ### Changed
 ### Changed
+- **"Any model" jobs go to the printer with the fewest print hours (#3480, sponsor vote Q4 2026)** — When several printers of a model can take a job, the queue used to pick the first one in its list, so on a farm the same printer got most of the work. It now picks the one with the fewest print hours (the Maintenance counter, including baseline hours entered there), so jobs and wear spread across the farm. Preferred colours still come first, and the queue powers on the least used printer too when it has to switch one on with an Auto Power On plug.
 
 
 ### Fixed
 ### Fixed
 - **The log says which auto-archive switch skipped a print (#3477, reported by @Fuzzylog2x)** — Since 1.3.0 the global **Auto-archive prints** switch (Settings > General > Archive Settings) takes effect for prints started outside Bambuddy. Before, it was saved and never read, so an install upgraded with it off stopped archiving, and the statistics stopped updating. The log only said "auto_archive: False", the same as for a printer's own switch. It now names the global switch or the printer, whichever turned archiving off.
 - **The log says which auto-archive switch skipped a print (#3477, reported by @Fuzzylog2x)** — Since 1.3.0 the global **Auto-archive prints** switch (Settings > General > Archive Settings) takes effect for prints started outside Bambuddy. Before, it was saved and never read, so an install upgraded with it off stopped archiving, and the statistics stopped updating. The log only said "auto_archive: False", the same as for a printer's own switch. It now names the global switch or the printer, whichever turned archiving off.

+ 26 - 5
backend/app/services/print_scheduler.py

@@ -1229,6 +1229,17 @@ def _effective_plate_id(explicit_plate_id: int | None, file_path: Path) -> int:
     return resolved if resolved is not None else 1
     return resolved if resolved is not None else 1
 
 
 
 
+def _wear_order(printer: Printer) -> tuple[float, int]:
+    """Sort key for printers a model job could run on: fewest print hours, then id (#3480).
+
+    The same hours as maintenance's ``get_printer_total_hours``: the runtime
+    counter plus the baseline offset a user set for a second-hand or reset
+    machine.
+    """
+    hours = (printer.runtime_seconds or 0) / 3600.0 + (printer.print_hours_offset or 0.0)
+    return hours, printer.id
+
+
 class PrintScheduler:
 class PrintScheduler:
     """Background scheduler that processes the print queue."""
     """Background scheduler that processes the print queue."""
 
 
@@ -3247,6 +3258,13 @@ class PrintScheduler:
         answer "which printers can this job run on" from one query — a job can
         answer "which printers can this job run on" from one query — a job can
         only be woken onto a printer the matcher would also have considered.
         only be woken onto a printer the matcher would also have considered.
         ``printer_scope`` is the job creator's (#1727).
         ``printer_scope`` is the job creator's (#1727).
+
+        Fewest print hours first (#3480): both take the first printer that
+        fits, so an "Any" job goes to the least worn machine and a farm's jobs
+        spread by wear instead of all landing on the lowest id. The hours are
+        the maintenance counter's (runtime plus the baseline offset), which
+        counts every print, wherever it was started. Ties (printers still at
+        zero) go by id, so the order is stable.
         """
         """
         normalized_model = normalize_printer_model(model) or model
         normalized_model = normalize_printer_model(model) or model
         query = (
         query = (
@@ -3259,7 +3277,7 @@ class PrintScheduler:
         if (clause := printer_scope.where_strict(Printer.id)) is not None:
         if (clause := printer_scope.where_strict(Printer.id)) is not None:
             query = query.where(clause)
             query = query.where(clause)
         result = await db.execute(query)
         result = await db.execute(query)
-        return list(result.scalars().all())
+        return sorted(result.scalars().all(), key=_wear_order)
 
 
     async def _wakeable_printer_ids(self, db: AsyncSession) -> set[int]:
     async def _wakeable_printer_ids(self, db: AsyncSession) -> set[int]:
         """Printer IDs that at least one enabled ``auto_on`` plug can power on.
         """Printer IDs that at least one enabled ``auto_on`` plug can power on.
@@ -3333,7 +3351,8 @@ class PrintScheduler:
                 continue
                 continue
             required_types, filament_overrides = _filament_constraints(candidate)
             required_types, filament_overrides = _filament_constraints(candidate)
             printers = await self._printers_for_model(db, candidate.target_model, target_location, printer_scope)
             printers = await self._printers_for_model(db, candidate.target_model, target_location, printer_scope)
-            for printer in sorted(printers, key=lambda p: p.id):
+            # Fewest print hours first, as the matcher picks (#3480).
+            for printer in printers:
                 if printer.id in exclude_ids or printer.id not in wakeable_ids:
                 if printer.id in exclude_ids or printer.id not in wakeable_ids:
                     continue
                     continue
                 if printer_manager.is_connected(printer.id):
                 if printer_manager.is_connected(printer.id):
@@ -3595,8 +3614,9 @@ class PrintScheduler:
                     logger.debug("Skipping printer %s (%s) - no matching override colors", printer.id, printer.name)
                     logger.debug("Skipping printer %s (%s) - no matching override colors", printer.id, printer.name)
                     continue
                     continue
             else:
             else:
-                # No preference ordering needed: the first eligible printer
-                # wins, unless its spools are too light for the job (#3137).
+                # The first eligible printer wins, which is the one with the
+                # fewest print hours (#3480, see _printers_for_model), unless
+                # its spools are too light for the job (#3137).
                 if is_short is not None and await is_short(printer.id):
                 if is_short is not None and await is_short(printer.id):
                     if short_fallback is None:
                     if short_fallback is None:
                         short_fallback = printer.id
                         short_fallback = printer.id
@@ -3608,7 +3628,8 @@ class PrintScheduler:
                     continue
                     continue
                 return printer.id, None
                 return printer.id, None
 
 
-        # If we have candidates from preference override matching, pick the one with most color matches
+        # If we have candidates from preference override matching, pick the one with most color matches.
+        # The sort is stable: among equal matches the fewest print hours still go first (#3480).
         if candidates:
         if candidates:
             candidates.sort(key=lambda c: c[1], reverse=True)
             candidates.sort(key=lambda c: c[1], reverse=True)
             if is_short is not None:
             if is_short is not None:

+ 218 - 0
backend/tests/unit/test_scheduler_wear_order_3480.py

@@ -0,0 +1,218 @@
+"""An "Any model" job goes to the eligible printer with the fewest print hours (#3480).
+
+The matcher used to take the first eligible printer in database order, so on a
+farm the lowest id got most of the work. It now ranks the printers of the model
+by the maintenance hour counter (runtime plus the baseline offset), fewest
+first, and the smart-plug wake step powers on the least worn one the same way.
+Everything that decides whether a printer is eligible is unchanged.
+"""
+
+from contextlib import ExitStack
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+import backend.app.models  # noqa: F401 - populate Base.metadata
+from backend.app.core.database import Base
+from backend.app.models.library import LibraryFile
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.printer import Printer
+from backend.app.services.print_scheduler import PrintScheduler, _ModelCandidate
+
+HOUR = 3600
+
+
+async def _db(printers: dict[int, tuple[int, float]]):
+    """X1Cs by id: (runtime_seconds, print_hours_offset)."""
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    session_maker = async_sessionmaker(engine, expire_on_commit=False)
+    async with session_maker() as db:
+        db.add_all(
+            [
+                Printer(
+                    id=pid,
+                    name=f"X1C-{pid}",
+                    serial_number=f"X1C000{pid}",
+                    ip_address=f"192.0.2.{pid}",
+                    access_code="x",
+                    model="X1C",
+                    is_active=True,
+                    runtime_seconds=runtime,
+                    print_hours_offset=offset,
+                )
+                for pid, (runtime, offset) in printers.items()
+            ]
+        )
+        await db.commit()
+    return engine, SimpleNamespace(session_maker=session_maker)
+
+
+@pytest.fixture
+async def farm():
+    """Printer 1 is the most worn, printer 3 the least; 2 sits between them."""
+    engine, ctx = await _db({1: (900 * HOUR, 0.0), 2: (100 * HOUR, 0.0), 3: (20 * HOUR, 0.0)})
+    try:
+        yield ctx
+    finally:
+        await engine.dispose()
+
+
+async def _find(ctx, scheduler, *, exclude=frozenset(), short=None, **kwargs):
+    async def is_short(pid):
+        return pid in short
+
+    with (
+        patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
+        patch.object(scheduler, "_is_printer_idle", MagicMock(return_value=True)),
+    ):
+        async with ctx.session_maker() as db:
+            return await scheduler._find_idle_printer_for_model(
+                db, "X1C", set(exclude), is_short=is_short if short is not None else None, **kwargs
+            )
+
+
+class TestMatcher:
+    @pytest.mark.asyncio
+    async def test_takes_the_printer_with_the_fewest_hours(self, farm):
+        assert await _find(farm, PrintScheduler()) == (3, None)
+
+    @pytest.mark.asyncio
+    async def test_a_busy_least_worn_printer_passes_the_job_to_the_next(self, farm):
+        assert await _find(farm, PrintScheduler(), exclude={3}) == (2, None)
+
+    @pytest.mark.asyncio
+    async def test_counts_the_baseline_offset_like_maintenance(self):
+        """A second-hand machine entered with its real hours is not "new"."""
+        engine, ctx = await _db({1: (10 * HOUR, 0.0), 2: (0, 500.0)})
+        try:
+            assert await _find(ctx, PrintScheduler()) == (1, None)
+        finally:
+            await engine.dispose()
+
+    @pytest.mark.asyncio
+    async def test_printers_still_at_zero_go_by_id(self):
+        engine, ctx = await _db({3: (0, 0.0), 1: (0, 0.0), 2: (0, 0.0)})
+        try:
+            assert await _find(ctx, PrintScheduler()) == (1, None)
+        finally:
+            await engine.dispose()
+
+    @pytest.mark.asyncio
+    async def test_a_short_printer_is_passed_over_for_the_next_least_worn(self, farm):
+        assert await _find(farm, PrintScheduler(), short={3}) == (2, None)
+
+    @pytest.mark.asyncio
+    async def test_every_printer_short_holds_the_job_on_the_least_worn(self, farm):
+        assert await _find(farm, PrintScheduler(), short={1, 2, 3}) == (3, None)
+
+    @pytest.mark.asyncio
+    async def test_more_preferred_colours_still_beat_fewer_hours(self, farm):
+        scheduler = PrintScheduler()
+        matches = {1: 2, 2: 1, 3: 1}
+        with patch.object(scheduler, "_count_override_color_matches", side_effect=lambda pid, _o: matches[pid]):
+            result = await _find(farm, scheduler, filament_overrides=[{"type": "PLA", "color": "#000000"}])
+        assert result == (1, None)
+
+    @pytest.mark.asyncio
+    async def test_equal_preferred_colours_go_to_the_fewest_hours(self, farm):
+        scheduler = PrintScheduler()
+        matches = {1: 1, 2: 2, 3: 2}
+        with patch.object(scheduler, "_count_override_color_matches", side_effect=lambda pid, _o: matches[pid]):
+            result = await _find(farm, scheduler, filament_overrides=[{"type": "PLA", "color": "#000000"}])
+        assert result == (3, None)
+
+
+class TestQueuePass:
+    @pytest.mark.asyncio
+    async def test_jobs_spread_over_the_least_worn_printers(self, farm):
+        """Two "any X1C" jobs, three idle X1Cs: the two least worn take them."""
+        ids = []
+        async with farm.session_maker() as db:
+            for pos in (1, 2):
+                lib = LibraryFile(
+                    filename=f"job{pos}.gcode.3mf",
+                    file_path=f"/library/job{pos}.gcode.3mf",
+                    file_size=10,
+                    file_type="gcode.3mf",
+                    file_metadata={"sliced_for_model": "X1C"},
+                )
+                db.add(lib)
+                await db.flush()
+                item = PrintQueueItem(status="pending", position=pos, target_model="X1C", library_file_id=lib.id)
+                db.add(item)
+                await db.flush()
+                ids.append(item.id)
+            await db.commit()
+
+        scheduler = PrintScheduler()
+        patches = [
+            patch("backend.app.services.print_scheduler.async_session", farm.session_maker),
+            patch("backend.app.core.database.async_session", farm.session_maker),
+            patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
+            patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
+            patch(
+                "backend.app.services.print_scheduler.ha_sensor_manager.blocked_printers",
+                AsyncMock(return_value={}),
+            ),
+            patch(
+                "backend.app.services.notification_service.notification_service.on_queue_job_waiting",
+                AsyncMock(),
+            ),
+            patch(
+                "backend.app.services.notification_service.notification_service.on_queue_job_assigned",
+                AsyncMock(),
+            ),
+            patch("backend.app.services.print_scheduler.compute_deficit_for_queue_item", AsyncMock(return_value=[])),
+            patch.object(scheduler, "_filament_short_on", AsyncMock(return_value=False)),
+            patch.object(scheduler, "_is_printer_idle", MagicMock(return_value=True)),
+            patch.object(scheduler, "_check_auto_drying", AsyncMock()),
+            patch.object(scheduler, "_ensure_ams_mapping", AsyncMock(return_value=None)),
+            patch.object(scheduler, "_block_on_unmatched_filament", AsyncMock(return_value=False)),
+            patch.object(scheduler, "_launch_uploads", MagicMock()),
+        ]
+        with ExitStack() as stack:
+            for p in patches:
+                stack.enter_context(p)
+            await scheduler.check_queue()
+
+        async with farm.session_maker() as db:
+            rows = (await db.execute(select(PrintQueueItem).where(PrintQueueItem.id.in_(ids)))).scalars().all()
+        assert sorted(row.printer_id for row in rows) == [2, 3]
+
+
+class TestWake:
+    @pytest.mark.asyncio
+    async def test_powers_on_the_least_worn_offline_printer(self, farm):
+        scheduler = PrintScheduler()
+        plug = SimpleNamespace(id=1, auto_on=True, enabled=True, controls_printer_power=True)
+        power_on = AsyncMock(return_value=True)
+        with (
+            patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=False)),
+            patch(
+                "backend.app.services.print_scheduler.printer_manager.is_awaiting_plate_clear",
+                MagicMock(return_value=False),
+            ),
+            patch.object(scheduler, "_cached_filament_shortfall", MagicMock(return_value=[])),
+            patch.object(scheduler, "_get_smart_plugs", AsyncMock(return_value=[plug])),
+            patch.object(scheduler, "_power_on_and_wait", power_on),
+        ):
+            async with farm.session_maker() as db:
+                woken = await scheduler._wake_printer_for_model(
+                    db,
+                    [
+                        _ModelCandidate(
+                            target_model="X1C", sliced_for="X1C", required_filament_types=None, filament_overrides=None
+                        )
+                    ],
+                    None,
+                    set(),
+                    {1, 2, 3},
+                    require_plate_clear=True,
+                )
+        assert woken == (3, 3)
+        assert power_on.call_args[0][1] == 3