Просмотр исходного кода

fix(spoolman): a failed print that switched to the backup spool charges each spool its share

maziggy 6 дней назад
Родитель
Сommit
1a2560270f

+ 1 - 0
CHANGELOG.md

@@ -168,6 +168,7 @@ All notable changes to Bambuddy will be documented in this file.
   - A failed or stopped print in Spoolman mode is logged in Print History with what its Spoolman spools cost for the filament it used, as with the built-in inventory. It was logged with no cost.
   - The estimated cost of a queued library file is priced from the Spoolman spools in the trays it maps to. Every slot was priced at the default filament cost.
 - **Spoolman usage** —
+  - A failed or stopped single-filament print that switched to the AMS backup spool charges each Spoolman spool its share, as a completed print and the built-in inventory do. All of it went to the spool that ran out.
   - Two copies of the same archive printing at once on two printers are both charged to Spoolman. The second print's start failed on the shared archive and that print was never deducted.
   - A filament slot the print does not feed is no longer charged to the external spool, or to whichever AMS tray sits at that position, as with the built-in inventory.
 - **Spoolman slots** —

+ 41 - 20
backend/app/services/spoolman_tracking.py

@@ -1256,6 +1256,45 @@ async def _report_partial_usage(
             _partial_mapping_source,
         )
 
+    # A single-filament print that switched trays (AMS backup on a runout)
+    # charges each tray its share of the partial grams, by layer up to where
+    # the print stopped, as the built-in inventory and the completion path do
+    # (#1793). The G-code split is not used here: its last segment runs to the
+    # end of the whole print. A multi-material print keeps the mapping.
+    tray_changes = list(getattr(state, "tray_change_log", None) or []) if state else []
+
+    async def _charge(usage_items: list[tuple[int, float]], method_label: str) -> int:
+        used = [(slot_id, grams) for slot_id, grams in usage_items if grams > 0]
+        if len(used) == 1 and len(tray_changes) > 1:
+            # Never short of a logged switch: a stop layer estimated from
+            # progress could otherwise give a segment more than the total.
+            split_layers = max([current_layer] + [layer for _, layer in tray_changes])
+            updated, _ = await _report_spool_usage_split_by_tray_changes(
+                client,
+                [{"slot_id": used[0][0], "used_g": used[0][1]}],
+                tray_changes,
+                ams_trays,
+                None,
+                filament_properties,
+                split_layers,
+                split_layers,
+                f"{method_label} split",
+                printer_serial,
+                printer_id=printer_id,
+                cost_out=cost_out,
+            )
+            return updated
+        return await _report_spool_usage_for_slots(
+            client,
+            usage_items,
+            ams_trays,
+            slot_to_tray,
+            method_label,
+            printer_serial,
+            printer_id=printer_id,
+            cost_out=cost_out,
+        )
+
     # Try to use accurate G-code parsed data
     if layer_usage:
         layer_usage_int = {
@@ -1294,16 +1333,7 @@ async def _report_partial_usage(
                 grams_used = round(mm_to_grams(mm_used, diameter, density), 2)
                 usage_items.append((slot_id, grams_used))
 
-            spools_updated = await _report_spool_usage_for_slots(
-                client,
-                usage_items,
-                ams_trays,
-                slot_to_tray,
-                "Partial (G-code)",
-                printer_serial,
-                printer_id=printer_id,
-                cost_out=cost_out,
-            )
+            spools_updated = await _charge(usage_items, "Partial (G-code)")
             if spools_updated > 0:
                 logger.info("[SPOOLMAN] Reported partial usage to %s spool(s) using G-code data", spools_updated)
             return
@@ -1333,16 +1363,7 @@ async def _report_partial_usage(
             partial_used_g = round(total_used_g * progress_ratio, 2)
             usage_items.append((slot_id, partial_used_g))
 
-    spools_updated = await _report_spool_usage_for_slots(
-        client,
-        usage_items,
-        ams_trays,
-        slot_to_tray,
-        "Partial (linear)",
-        printer_serial,
-        printer_id=printer_id,
-        cost_out=cost_out,
-    )
+    spools_updated = await _charge(usage_items, "Partial (linear)")
     if spools_updated > 0:
         logger.info("[SPOOLMAN] Reported partial usage to %s spool(s) using linear interpolation", spools_updated)
 

+ 84 - 0
backend/tests/unit/test_spoolman_no3mf_remain_fallback.py

@@ -640,6 +640,90 @@ class TestPartialUsageCost:
             assert await cleanup_tracking(printer.id, archive.id, db_session) is None
 
 
+class TestPartialUsageTraySplit:
+    """A single-filament print that ran out and switched to the AMS backup
+    spool before failing charges each spool its share, as a completed print
+    and the built-in inventory do. All of it went to the tray that ran out."""
+
+    @pytest.mark.asyncio
+    async def test_the_partial_grams_are_split_at_the_tray_change(self):
+        from backend.app.services.spoolman_tracking import _report_partial_usage
+
+        tracking = SimpleNamespace(
+            archive_id=99,
+            filament_usage=[{"slot_id": 1, "used_g": 200.0}],
+            layer_usage=None,
+            filament_properties=None,
+            ams_trays={"0": {"tray_uuid": "A" * 32}, "1": {"tray_uuid": "B" * 32}},
+            slot_to_tray=[0],
+            tray_remain_start=None,
+        )
+        spools = {"A" * 32: {"id": 7}, "B" * 32: {"id": 8}}
+        client = AsyncMock()
+        client.find_spool_by_tag = AsyncMock(side_effect=lambda tag, *a, **k: spools.get(tag))
+        client.use_spool = AsyncMock()
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={},
+            layer_num=100,
+            total_layers=200,
+            tray_change_log=[(0, 1), (1, 51)],
+        )
+
+        with (
+            patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
+            patch(
+                "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback",
+                AsyncMock(return_value=client),
+            ),
+            patch("backend.app.services.spoolman_tracking._get_printer_serial", AsyncMock(return_value="serial")),
+            patch("backend.app.services.printer_manager.printer_manager", printer_manager),
+        ):
+            await _report_partial_usage(printer_id=1, tracking=tracking)
+
+        charged = {call.args[0]: call.args[1] for call in client.use_spool.await_args_list}
+        # 100 g by layer 100 of 200, the switch at layer 51: half each.
+        assert charged == {7: 50.0, 8: 50.0}
+
+    @pytest.mark.asyncio
+    async def test_a_stop_layer_below_a_logged_switch_never_charges_more_than_was_used(self):
+        """The stop layer estimated from progress (60) can trail the logged
+        switch (80); the split must not exceed the partial grams."""
+        from backend.app.services.spoolman_tracking import _report_partial_usage
+
+        tracking = SimpleNamespace(
+            archive_id=99,
+            filament_usage=[{"slot_id": 1, "used_g": 200.0}],
+            layer_usage=None,
+            filament_properties=None,
+            ams_trays={"0": {"tray_uuid": "A" * 32}, "1": {"tray_uuid": "B" * 32}},
+            slot_to_tray=[0],
+            tray_remain_start=None,
+        )
+        spools = {"A" * 32: {"id": 7}, "B" * 32: {"id": 8}}
+        client = AsyncMock()
+        client.find_spool_by_tag = AsyncMock(side_effect=lambda tag, *a, **k: spools.get(tag))
+        client.use_spool = AsyncMock()
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={}, layer_num=0, total_layers=200, tray_change_log=[(0, 0), (1, 80)]
+        )
+
+        with (
+            patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
+            patch(
+                "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback",
+                AsyncMock(return_value=client),
+            ),
+            patch("backend.app.services.spoolman_tracking._get_printer_serial", AsyncMock(return_value="serial")),
+            patch("backend.app.services.printer_manager.printer_manager", printer_manager),
+        ):
+            await _report_partial_usage(printer_id=1, tracking=tracking, last_progress=30)
+
+        # Layer 60 of 200 from 30 %: 60 g used, never more.
+        assert round(sum(call.args[1] for call in client.use_spool.await_args_list), 2) <= 60.0
+
+
 class _AsyncCtx:
     """Tiny async-context shim returning a pre-built db mock; mirrors
     async_session()'s ``async with`` interface."""