فهرست منبع

Usage: a print stopped before its first layer charges nothing (#3511)

- the 3MF path worked out 0 g for a slot when the print failed before
  its first layer, but did not claim the tray, so the remain% fallback
  charged it from the AMS reading at the stop
- the AMS can read 0% for a moment as the filament unloads, so a new
  spool at 100% was charged in full: 1000 g for a five-minute print
- a tray the 3MF path resolved from the mapping or the loaded tray now
  counts as handled at 0 g; a tray only guessed by position stays the
  fallback's, and prints without a 3MF still use remain%
maziggy 12 ساعت پیش
والد
کامیت
baaaf0c9e5
2فایلهای تغییر یافته به همراه148 افزوده شده و 0 حذف شده
  1. 14 0
      backend/app/services/usage_tracker.py
  2. 134 0
      backend/tests/unit/test_failed_print_bogus_remain_3511.py

+ 14 - 0
backend/app/services/usage_tracker.py

@@ -2097,6 +2097,20 @@ async def _track_from_3mf(
             continue
 
         if slot_grams <= 0:
+            if not tray_is_guessed:
+                # The slicer weighs this slot (a 0 g one was skipped above) and
+                # its tray is known, but the print stopped before using any of
+                # it. That is the answer for this tray: left open, the remain%
+                # fallback charged it from the AMS reading at the stop, which
+                # can be a transient 0% and so the whole spool (#3511). A tray
+                # only guessed by position stays the fallback's.
+                handled_trays.add(key)
+                logger.info(
+                    "[UsageTracker] 3MF: AMS%d-T%d used nothing before the print stopped (%s), nothing charged",
+                    ams_id,
+                    tray_id,
+                    status,
+                )
             continue
 
         for share_spool_id, weight_grams in shares or [(spool_id, slot_grams)]:

+ 134 - 0
backend/tests/unit/test_failed_print_bogus_remain_3511.py

@@ -0,0 +1,134 @@
+"""A print stopped before its first layer charges nothing, whatever the AMS reads (#3511).
+
+The reporter's X2D failed 290 s in. The 3MF named slot 2 on AMS0-T3 and worked
+out 0 g at layer 0, but did not claim the tray, so the remain% fallback charged
+it from the AMS reading at the stop: 100% at the start, a transient 0% at the
+end, the whole 1000 g spool.
+"""
+
+from datetime import datetime, timezone
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.usage_tracker import PrintSession, _active_sessions, _track_from_3mf, on_print_complete
+from backend.tests.unit.test_usage_tracker import (
+    _make_archive,
+    _make_assignment,
+    _make_spool,
+    _mock_db_sequential,
+)
+
+pytestmark = [pytest.mark.unit, pytest.mark.asyncio]
+
+# The reporter's plate: one PETG filament in slot 2, mapped to AMS0-T3.
+USAGE = [{"slot_id": 2, "used_g": 28.05, "type": "PETG", "color": ""}]
+MAPPING = [65535, 3]
+
+
+def _stopped_state(
+    remain_at_stop: int = 0, mqtt_mapping: list | None = MAPPING, last_loaded_tray: int = 3
+) -> SimpleNamespace:
+    raw = {"ams": [{"id": 0, "tray": [{"id": 3, "remain": remain_at_stop, "tray_type": "PETG"}]}]}
+    if mqtt_mapping is not None:
+        raw["mapping"] = mqtt_mapping
+    return SimpleNamespace(
+        progress=0,
+        layer_num=0,
+        total_layers=120,
+        tray_now=255,
+        last_loaded_tray=last_loaded_tray,
+        tray_change_log=[],
+        spool_change_log=[],
+        raw_data=raw,
+    )
+
+
+def _threemf(usage):
+    path = MagicMock()
+    path.exists.return_value = True
+    settings = patch("backend.app.core.config.settings")
+    return (
+        settings,
+        patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=usage),
+        patch("backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf", return_value=None),
+        path,
+    )
+
+
+async def _track(usage, ams_mapping, mqtt_mapping=MAPPING, last_loaded_tray=3):
+    printer_manager = MagicMock()
+    printer_manager.get_status.return_value = _stopped_state(
+        mqtt_mapping=mqtt_mapping, last_loaded_tray=last_loaded_tray
+    )
+    handled: set = set()
+    db = _mock_db_sequential([_make_archive(archive_id=32), None, _make_assignment(spool_id=38, ams_id=0, tray_id=3)])
+    settings, extract, layers, path = _threemf(usage)
+    with settings as mock_settings, extract, layers:
+        mock_settings.base_dir.__truediv__ = MagicMock(return_value=path)
+        results = await _track_from_3mf(
+            printer_id=1,
+            archive_id=32,
+            status="failed",
+            print_name="Desiccant core",
+            handled_trays=handled,
+            printer_manager=printer_manager,
+            db=db,
+            ams_mapping=ams_mapping,
+        )
+    return results, handled
+
+
+class TestTheThreeMfClaimsTheTray:
+    async def test_a_mapped_slot_stopped_before_using_any_is_handled(self):
+        results, handled = await _track(USAGE, MAPPING)
+
+        assert results == []
+        assert (0, 3) in handled
+
+    async def test_a_tray_guessed_by_position_is_left_to_the_fallback(self):
+        # No mapping from the dispatch nor from the printer, and no tray loaded.
+        _, handled = await _track(USAGE, None, mqtt_mapping=None, last_loaded_tray=-1)
+
+        assert handled == set()
+
+
+class TestTheCompletion:
+    @pytest.fixture(autouse=True)
+    def _clean(self):
+        _active_sessions.clear()
+        with patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None):
+            yield
+        _active_sessions.clear()
+
+    async def test_the_reporters_print_charges_nothing(self):
+        _active_sessions[1] = PrintSession(
+            printer_id=1,
+            print_name="Desiccant core",
+            started_at=datetime.now(timezone.utc),
+            tray_remain_start={(0, 3): 100},
+            tray_now_at_start=3,
+            ams_mapping=MAPPING,
+        )
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = _stopped_state(remain_at_stop=0)
+        spool = _make_spool(spool_id=38)
+        assignment = _make_assignment(spool_id=38, ams_id=0, tray_id=3)
+        # The 3MF path's archive, queue item and assignment; then what the
+        # fallback would read if it ran: the assignment and the spool.
+        db = _mock_db_sequential([_make_archive(archive_id=32), None, assignment, assignment, spool])
+        settings, extract, layers, path = _threemf(USAGE)
+        with settings as mock_settings, extract, layers:
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=path)
+            results = await on_print_complete(
+                printer_id=1,
+                data={"status": "failed"},
+                printer_manager=printer_manager,
+                db=db,
+                archive_id=32,
+                ams_mapping=MAPPING,
+            )
+
+        assert results == []
+        db.add.assert_not_called()