Sfoglia il codice sorgente

fix(inventory): a cancelled print never costs more than a finished one (issue #3317)

- Charge a cancelled or failed print each filament's slicer weight,
  times the share of that filament the G-code had fed by the stop
  layer (threemf_tools.slicer_grams_at_layer); a finished print is
  charged the slicer's weight, and the G-code feeds more (start
  sequence, AMS flush) and before the layers it serves
- Built-in inventory and Spoolman partial paths both
- Inventory split after an AMS backup switch runs on the G-code's
  grams and is scaled after, so the backup spool keeps its share
- Spoolman partial no longer charges a slot the slicer weighed 0 g
- Reported by @ojimpo, with files from @Andrewdlec.
maziggy 2 giorni fa
parent
commit
0ab42fc9c0

+ 2 - 1
CHANGELOG.md

@@ -203,9 +203,10 @@ All notable changes to Bambuddy will be documented in this file.
 - **A spool replaced during a print is charged only for its part (#3325, reported by @HarryCoff2)** — When a spool ran out and a new one was loaded in the same slot (an external spool, or an AMS slot without a backup) and assigned in Bambuddy while the print ran, the whole print was charged to the new spool. Bambuddy now notes the layer of the new assignment and, when the print ends, splits the filament between the two spools by what the G-code feeds before and after it, for completed and failed prints, with the built-in inventory and with Spoolman, also after a restart mid-print. A slot that had no spool when the print started is still charged to the spool assigned to it, for the whole print.
 - **A write is saved before its response is sent (#3320, reported by @MorganMLGman)** — Many API routes left the final save to the end of the request, and that ran only after the response had gone out. A client reading straight after a successful write could miss it (a new project came back as 404, an edit still showed the old value), and a save that failed at that point was undone behind a success the client already had. Every route now saves before it answers, and a failed save is reported as an error.
 - **A failed or cancelled print's card says its cost is for one full print (#3318, reported by @ojimpo)** — After a print failed or was cancelled, the archive card showed the cost and grams of the whole file with no hint, as if that were what the run cost. The archive keeps one full print's figures on purpose (reprints are estimated and billed from them); the cost and grams now carry a tooltip saying so, pointing to the archive's Print Log for what the run used. With Spoolman, that run's cost is priced from the spools it was charged to.
-- **A print that fails or is cancelled early is charged only for the filament it used (#3317, reported by @ojimpo)** — Two errors added up to a failed print being charged to Spoolman as if it had finished, sometimes for more than the finished print would have used.
+- **A print that fails or is cancelled early is charged only for the filament it used (#3317, reported by @ojimpo)** — Two errors added up to a failed print being charged to Spoolman as if it had finished, sometimes for more than the finished print would have used. Even with both fixed, a print cancelled near the end could still cost more than the same print finished.
   - **The previous print's layer:** right after a print starts, the printer can send one update that still carries the layer the previous print ended on (seen on an H2S: layer 357 of 357 at 0 %). Bambuddy took it as this print's last layer, so a print that failed before its first layer was charged up to layer 357. That value is now ignored until the new print reports a layer of its own. It also no longer counts as a layer change, so it no longer adds a layer-timelapse frame at print start.
   - **Retractions:** partial usage is worked out from the extrusion in the file's G-code, which counted each un-retract but never the retract before it. Every retraction added to the total, putting partial usage up to 1.7x above the slicer's weight. Retractions are now subtracted. The split between spools after an AMS switch to a backup spool mid-print uses the same figures.
+  - **Cancelled near the end:** the G-code feeds more than the slicer counts (the start sequence, and the purge when the AMS changes filament) and feeds it before the layers it serves. A finished print is charged the slicer's weight, so a two-colour X1C print cancelled at layer 33 of 47 cost 2.55 g against 2.33 g for the whole print. A cancelled print is now charged each filament's slicer weight, times how far the G-code had fed that filament when the print stopped: 2.19 g in the same case. The purges still count when they happened, and a cancelled print never costs more than the finished one. After an AMS switch to a backup spool, that weight is shared between both spools by what the G-code fed from each, and Spoolman no longer charges a cancelled print for a filament the slicer weighed at 0 g, as a finished print never did. Both Spoolman and the built-in inventory. Thanks to @Andrewdlec for the files.
 - **Writing a SpoolBuddy tag takes it off the spool that held it before (#3312, reported by @sgiffhorn)** — With Spoolman, writing a tag that another spool already carried was meant to clear it from that spool first, but Spoolman refused the empty value Bambuddy sent. Both spools kept the tag, and the next scan could show the old spool. The tag is now cleared properly, from every other spool that carries it, including when the spool being written already held it as a Spoolman 0.27 tag.
 - **A large 3MF that times out at print start is not downloaded a second time (#3308, reported by @elizabethburg)** — On a slow link, such as an A1 in its start-up routine, a 3MF too large to finish in time was downloaded twice: once by name, and again when the folder listing found the same file. Each attempt took up to 5 minutes, holding the printer's only file connection with the cover images queued behind it. It is now tried once. The archive starts without the 3MF and the recovery retry fills it in, with up to 15 minutes for a large file.
 - **"Print again" on the printer's screen is archived with its 3MF (#3307, reported by @elizabethburg)** — A print that archived in full the first time got an empty archive (no 3MF, thumbnail or filament) when started again with **Print again** on the printer. On a reprint the printer doesn't name the file on the card: it reports its own internal copy under a generic name, plus the model's title from inside the 3MF. When that title differs from the file name, as with a project saved under another name, Bambuddy couldn't find the file.

+ 16 - 2
backend/app/services/spoolman_tracking.py

@@ -1312,7 +1312,11 @@ async def _report_partial_usage(
     data is unavailable.
     """
     from backend.app.services.printer_manager import printer_manager
-    from backend.app.utils.threemf_tools import get_cumulative_usage_at_layer, mm_to_grams
+    from backend.app.utils.threemf_tools import (
+        get_cumulative_usage_at_layer,
+        mm_to_grams,
+        slicer_grams_at_layer,
+    )
 
     async with async_session() as db:
         from backend.app.api.routes.settings import get_setting
@@ -1469,10 +1473,20 @@ async def _report_partial_usage(
         if usage_mm:
             logger.info("[SPOOLMAN] Using G-code parsed data for layer %s", current_layer)
 
-            # Build (slot_id, grams) list using Spoolman densities with 3MF fallback
+            # On the slicer's weights, as a finished print is (#3317); the
+            # rest from Spoolman densities with 3MF fallback.
+            scaled = slicer_grams_at_layer(layer_usage_int, current_layer, filament_usage)
+            # A finished print charges only the slots the slicer weighed, so a
+            # cancelled one charges no others either.
+            weighed = {u.get("slot_id") for u in filament_usage if (u.get("used_g") or 0) > 0}
             usage_items = []
             for filament_id, mm_used in usage_mm.items():
                 slot_id = filament_id + 1  # filament_id is 0-based, slot_id is 1-based
+                if filament_usage and slot_id not in weighed:
+                    continue
+                if slot_id in scaled:
+                    usage_items.append((slot_id, round(scaled[slot_id], 2)))
+                    continue
 
                 # Get density from Spoolman (most accurate), fall back to 3MF, then PLA default
                 global_tray_id = _resolve_global_tray_id(slot_id, slot_to_tray, ams_trays)

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

@@ -1724,6 +1724,8 @@ async def _track_from_3mf(
 
     # Per-layer gcode accuracy for partial prints
     layer_grams: dict[int, float] | None = None
+    # The same in the G-code's own grams, which the tray split weighs segments in.
+    layer_gcode_grams: dict[int, float] = {}
     if status != "completed":
         state = printer_manager.get_status(printer_id)
         current_layer = state.layer_num if state else 0
@@ -1738,19 +1740,22 @@ async def _track_from_3mf(
                     extract_layer_filament_usage_from_3mf,
                     get_cumulative_usage_at_layer,
                     mm_to_grams,
+                    slicer_grams_at_layer,
                 )
 
                 layer_usage = extract_layer_filament_usage_from_3mf(file_path, plate_id)
                 if layer_usage:
                     cumulative_mm = get_cumulative_usage_at_layer(layer_usage, current_layer)
                     filament_props = extract_filament_properties_from_3mf(file_path)
-                    layer_grams = {}
+                    # On the slicer's weights, as a finished print is (#3317).
+                    layer_grams = slicer_grams_at_layer(layer_usage, current_layer, filament_usage)
                     for filament_id, mm_used in cumulative_mm.items():
                         slot_id = filament_id + 1  # 0-based to 1-based
                         props = filament_props.get(slot_id, {})
                         density = props.get("density", 1.24)
                         diameter = props.get("diameter", 1.75)
-                        layer_grams[slot_id] = mm_to_grams(mm_used, diameter, density)
+                        layer_gcode_grams[slot_id] = mm_to_grams(mm_used, diameter, density)
+                        layer_grams.setdefault(slot_id, layer_gcode_grams[slot_id])
             except Exception:
                 pass  # Fall back to linear scaling
 
@@ -1832,9 +1837,14 @@ async def _track_from_3mf(
 
             from backend.app.utils.tray_split import compute_tray_split_grams
 
+            # The split prices segments in the G-code's grams and gives the last
+            # the rest, so it is run on the G-code's total and scaled to the
+            # slicer's after (#3317); a slicer total would leave the backup
+            # spool short, or with nothing.
+            split_total = layer_gcode_grams.get(slot_id) or total_weight
             segments = compute_tray_split_grams(
                 tray_changes=tray_changes,
-                total_weight=total_weight,
+                total_weight=split_total,
                 slot_id=slot_id,
                 layer_usage=split_layer_usage,
                 density=split_props.get("density", 1.24),
@@ -1842,6 +1852,7 @@ async def _track_from_3mf(
                 total_layers=(state.total_layers if state else 0) or 0,
                 last_layer_num=last_layer_num,
             )
+            segments = [(i, tray, grams * total_weight / split_total) for i, tray, grams in segments]
 
             for seg_idx, tray_global, segment_grams in segments:
                 if segment_grams <= 0:

+ 36 - 0
backend/app/utils/threemf_tools.py

@@ -326,6 +326,42 @@ def get_cumulative_usage_at_layer(
     return layer_usage.get(max_layer, {})
 
 
+def slicer_grams_at_layer(
+    layer_usage: dict[int, dict[int, float]],
+    target_layer: int,
+    filament_usage: list[dict],
+) -> dict[int, float]:
+    """Grams per 1-based slot for a print stopped at ``target_layer`` (#3317).
+
+    The slicer's weight for each filament, times the share of that filament's
+    whole-print feed the G-code has done by ``target_layer``. A finished print
+    is charged the slicer's weight, and the G-code feeds more than the slicer
+    counts (the start sequence, the purge at a filament change) and ahead of
+    the layers it serves, so its grams on their own charged a print cancelled
+    near the end more than the same print finished.
+
+    Only slots the slicer weighed and the G-code fed by then; the caller
+    converts the others from millimetres.
+    """
+    if not layer_usage:
+        return {}
+    final = layer_usage[max(layer_usage)]
+    at_layer = get_cumulative_usage_at_layer(layer_usage, target_layer)
+    slicer_grams: dict[int, float] = {}
+    for usage in filament_usage:
+        slot_id, used_g = usage.get("slot_id"), usage.get("used_g") or 0
+        if isinstance(slot_id, int) and used_g > 0:
+            slicer_grams.setdefault(slot_id, used_g)
+    grams: dict[int, float] = {}
+    for filament_id, mm in at_layer.items():
+        slot_id = filament_id + 1
+        total_mm = final.get(filament_id, 0)
+        if slot_id in slicer_grams and total_mm > 0:
+            # The end sequence can retract a little after the last snapshot.
+            grams[slot_id] = slicer_grams[slot_id] * min(mm / total_mm, 1.0)
+    return grams
+
+
 def extract_filament_properties_from_3mf(file_path: Path) -> dict[int, dict]:
     """Extract filament properties (density, diameter, type) from 3MF metadata.
 

+ 239 - 0
backend/tests/unit/test_partial_slicer_scale_3317.py

@@ -0,0 +1,239 @@
+"""A cancelled print is charged on the slicer's weights, like a finished one (#3317).
+
+The G-code feeds more than the slicer counts: the start sequence and the purge
+at a filament change, and both ahead of the layers they serve. Its grams on
+their own charged a print cancelled near the end more than the same print
+finished. The shape below is the reporter's X1C two-colour cylinder: charcoal
+(slot 4) on layers 0-24 after a ~220 mm start sequence, turquoise (slot 1) on
+layers 25-46 after a ~245 mm purge.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services import spoolman_tracking
+from backend.app.utils.threemf_tools import (
+    mm_to_grams,
+    parse_gcode_layer_filament_usage,
+    slicer_grams_at_layer,
+)
+
+SLICER = [{"slot_id": 1, "used_g": 0.77}, {"slot_id": 4, "used_g": 1.55}]
+DENSITY = {1: 1.26, 4: 1.24}
+
+
+def _two_colour_gcode() -> str:
+    lines = ["M620 S3A", "G1 E220"]  # start sequence on the first filament
+    for layer in range(1, 25):
+        lines += [f"M73 L{layer}", "G1 E13.83", "G1 E-0.8", "G1 E0.8"]
+    lines += ["M73 L25", "M620 S0A", "G1 E244.5"]  # change to turquoise, flush
+    for layer in range(25, 48):
+        lines += [f"M73 L{layer}", "G1 E3.28"]
+    return "\n".join(lines)
+
+
+LAYERS = parse_gcode_layer_filament_usage(_two_colour_gcode())
+
+
+def _total(grams: dict[int, float]) -> float:
+    return sum(grams.values())
+
+
+def test_the_gcode_alone_charges_a_late_cancel_more_than_a_finish():
+    # What dev charged before: the premise of the fix, kept so it stays true.
+    from backend.app.utils.threemf_tools import get_cumulative_usage_at_layer
+
+    raw = {
+        fid + 1: mm_to_grams(mm, 1.75, DENSITY[fid + 1])
+        for fid, mm in get_cumulative_usage_at_layer(LAYERS, 33).items()
+    }
+    assert _total(raw) > sum(u["used_g"] for u in SLICER)
+
+
+@pytest.mark.parametrize("layer", [1, 10, 24, 25, 33, 42, 46])
+def test_a_cancel_never_costs_more_than_the_finished_print(layer):
+    grams = slicer_grams_at_layer(LAYERS, layer, SLICER)
+    for usage in SLICER:
+        assert grams.get(usage["slot_id"], 0.0) <= usage["used_g"] + 1e-9
+
+
+def test_each_filament_follows_its_own_feed():
+    at_33 = slicer_grams_at_layer(LAYERS, 33, SLICER)
+    # Charcoal finished at layer 24: all of it.
+    assert at_33[4] == pytest.approx(1.55)
+    # Turquoise's purge came first, so it is well past a third of its weight
+    # eight layers in, and short of the whole.
+    assert 0.6 < at_33[1] < 0.77
+
+
+def test_the_last_layer_is_the_slicer_weight():
+    assert slicer_grams_at_layer(LAYERS, 47, SLICER) == pytest.approx({1: 0.77, 4: 1.55})
+
+
+def test_a_filament_not_fed_yet_is_not_charged():
+    assert set(slicer_grams_at_layer(LAYERS, 10, SLICER)) == {4}
+
+
+def test_a_slot_the_slicer_did_not_weigh_is_left_to_the_caller():
+    assert slicer_grams_at_layer(LAYERS, 33, [{"slot_id": 4, "used_g": 1.55}, {"slot_id": 1, "used_g": 0}]) == {
+        4: pytest.approx(1.55)
+    }
+
+
+def test_a_retraction_at_the_end_does_not_charge_above_the_weight():
+    layers = {1: {0: 100.0}, 2: {0: 98.0}}
+    assert slicer_grams_at_layer(layers, 1, [{"slot_id": 1, "used_g": 3.0}]) == {1: 3.0}
+
+
+def test_a_slot_listed_twice_keeps_its_first_weight():
+    # Every plate listed when the plate is unknown.
+    layers = {1: {0: 50.0}, 2: {0: 100.0}}
+    usage = [{"slot_id": 1, "used_g": 4.0}, {"slot_id": 1, "used_g": 9.0}]
+    assert slicer_grams_at_layer(layers, 1, usage) == {1: 2.0}
+
+
+def test_no_layer_data():
+    assert slicer_grams_at_layer({}, 10, SLICER) == {}
+
+
+@pytest.mark.asyncio
+async def test_spoolman_charges_a_cancel_on_the_slicer_weights():
+    session = MagicMock()
+    session.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
+    session.return_value.__aexit__ = AsyncMock(return_value=False)
+    tracking = SimpleNamespace(
+        # Stored as JSON, so with string keys.
+        layer_usage={str(layer): {str(fid): mm for fid, mm in used.items()} for layer, used in LAYERS.items()},
+        filament_properties={},
+        filament_usage=SLICER,
+        ams_trays={},
+        slot_to_tray=[0, 1, 2, 3],
+        tray_remain_start={},
+        archive_id=1,
+        tray_now_at_start=0,
+    )
+    status = SimpleNamespace(layer_num=33, total_layers=47, raw_data={}, tray_change_log=[])
+    report = AsyncMock(return_value=2)
+
+    with (
+        patch.object(spoolman_tracking, "async_session", session),
+        patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
+        patch("backend.app.services.printer_manager.printer_manager.get_status", MagicMock(return_value=status)),
+        patch.object(spoolman_tracking, "_get_printer_serial", AsyncMock(return_value="SERIAL")),
+        patch.object(spoolman_tracking, "_get_spoolman_client_with_fallback", AsyncMock(return_value=MagicMock())),
+        patch.object(spoolman_tracking, "_report_spool_usage_for_slots", report),
+    ):
+        await spoolman_tracking._report_partial_usage(1, tracking, last_layer_num=33)
+
+    items = dict(report.call_args.args[1])
+    assert items[4] == pytest.approx(1.55)
+    assert 0.6 < items[1] < 0.77
+
+
+def test_a_filament_that_never_fed_net_is_left_to_the_caller():
+    # Only retracted: its snapshots read 0 mm, and there is no share to take.
+    layers = {1: {0: 50.0, 1: 0.0}, 2: {0: 100.0, 1: 0.0}}
+    usage = [{"slot_id": 1, "used_g": 4.0}, {"slot_id": 2, "used_g": 1.0}]
+    assert slicer_grams_at_layer(layers, 1, usage) == {1: 2.0}
+
+
+@pytest.mark.asyncio
+async def test_inventory_backup_switch_shares_the_scaled_weight_between_both_spools():
+    # The split prices segments in G-code grams; on the slicer's (smaller)
+    # total the spool switched to at layer 30 came out below zero and got
+    # nothing, while the first spool got more than the whole cancelled print.
+    from backend.app.services.usage_tracker import _track_from_3mf
+    from backend.tests.unit.test_usage_tracker import (
+        _make_archive,
+        _make_assignment,
+        _make_spool,
+        _mock_db_sequential,
+    )
+
+    spool_a, spool_b = _make_spool(spool_id=10), _make_spool(spool_id=20)
+    db = _mock_db_sequential(
+        [
+            _make_archive(archive_id=100),
+            None,
+            _make_assignment(spool_id=10, ams_id=0, tray_id=0),
+            spool_a,
+            _make_assignment(spool_id=20, ams_id=0, tray_id=1),
+            spool_b,
+        ]
+    )
+    printer_manager = MagicMock()
+    printer_manager.get_status.return_value = SimpleNamespace(
+        progress=31,
+        layer_num=31,
+        tray_now=1,
+        last_loaded_tray=1,
+        total_layers=100,
+        tray_change_log=[(0, 0), (1, 30)],
+    )
+    # 37 mm a layer: 3700 mm (11.0 g) in all, against the slicer's 10 g.
+    layers = {layer: {0: 37.0 * layer} for layer in range(1, 101)}
+
+    with (
+        patch("backend.app.core.config.settings") as mock_settings,
+        patch(
+            "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
+            return_value=[{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": ""}],
+        ),
+        patch("backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf", return_value=layers),
+        patch(
+            "backend.app.utils.threemf_tools.extract_filament_properties_from_3mf",
+            return_value={1: {"density": 1.24, "diameter": 1.75}},
+        ),
+    ):
+        path = MagicMock()
+        path.exists.return_value = True
+        mock_settings.base_dir.__truediv__ = MagicMock(return_value=path)
+        results = await _track_from_3mf(
+            printer_id=1,
+            archive_id=100,
+            status="failed",
+            print_name="Runout",
+            handled_trays=set(),
+            printer_manager=printer_manager,
+            db=db,
+        )
+
+    charged = {r["spool_id"]: r["weight_used"] for r in results}
+    # 10 g x 1147/3700 = 3.1 g, shared as the G-code fed it: 30 layers, then 1.
+    assert sum(charged.values()) == pytest.approx(3.1, abs=0.05)
+    assert charged[10] == pytest.approx(3.0, abs=0.05)
+    assert charged[20] == pytest.approx(0.1, abs=0.05)
+
+
+@pytest.mark.asyncio
+async def test_spoolman_does_not_charge_a_slot_the_slicer_did_not_weigh():
+    session = MagicMock()
+    session.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
+    session.return_value.__aexit__ = AsyncMock(return_value=False)
+    tracking = SimpleNamespace(
+        layer_usage={"10": {"0": 100.0, "1": 200.0}, "20": {"0": 200.0, "1": 200.0}},
+        filament_properties={},
+        # Fed by the G-code, but weighed 0 g: a finished print charges it nothing.
+        filament_usage=[{"slot_id": 1, "used_g": 4.0}, {"slot_id": 2, "used_g": 0}],
+        ams_trays={},
+        slot_to_tray=[0, 1],
+        tray_remain_start={},
+        archive_id=1,
+        tray_now_at_start=0,
+    )
+    status = SimpleNamespace(layer_num=10, total_layers=20, raw_data={}, tray_change_log=[])
+    report = AsyncMock(return_value=1)
+
+    with (
+        patch.object(spoolman_tracking, "async_session", session),
+        patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
+        patch("backend.app.services.printer_manager.printer_manager.get_status", MagicMock(return_value=status)),
+        patch.object(spoolman_tracking, "_get_printer_serial", AsyncMock(return_value="SERIAL")),
+        patch.object(spoolman_tracking, "_get_spoolman_client_with_fallback", AsyncMock(return_value=MagicMock())),
+        patch.object(spoolman_tracking, "_report_spool_usage_for_slots", report),
+    ):
+        await spoolman_tracking._report_partial_usage(1, tracking, last_layer_num=10)
+
+    assert dict(report.call_args.args[1]) == {1: 2.0}

+ 6 - 5
backend/tests/unit/test_usage_tracker.py

@@ -504,8 +504,8 @@ class TestTrackFrom3mf:
         )
 
         filament_usage = [{"slot_id": 1, "used_g": 20.0, "type": "PLA", "color": ""}]
-        # Per-layer data: at layer 25, filament 0 used 5000mm
-        layer_data = {10: {0: 2000.0}, 25: {0: 5000.0}, 50: {0: 10000.0}}
+        # Per-layer data: at layer 25, filament 0 has fed 6000 of its 10000mm
+        layer_data = {10: {0: 2000.0}, 25: {0: 6000.0}, 50: {0: 10000.0}}
         filament_props = {1: {"density": 1.24, "diameter": 1.75}}
         handled_trays: set[tuple[int, int]] = set()
 
@@ -521,7 +521,7 @@ class TestTrackFrom3mf:
             ),
             patch(
                 "backend.app.utils.threemf_tools.get_cumulative_usage_at_layer",
-                return_value={0: 5000.0},
+                return_value={0: 6000.0},
             ),
             patch(
                 "backend.app.utils.threemf_tools.extract_filament_properties_from_3mf",
@@ -529,7 +529,7 @@ class TestTrackFrom3mf:
             ),
             patch(
                 "backend.app.utils.threemf_tools.mm_to_grams",
-                return_value=12.0,  # 5000mm at 1.75mm/1.24g/cm3
+                return_value=99.0,  # the G-code's own grams, not what is charged
             ),
         ):
             mock_settings.base_dir = MagicMock()
@@ -548,7 +548,8 @@ class TestTrackFrom3mf:
             )
 
         assert len(results) == 1
-        # Should use per-layer grams (12.0g), not linear scale (10.0g)
+        # The slicer's 20g times the share of the feed done by layer 25 (#3317):
+        # not the linear progress scale (10.0g), nor the G-code's grams (99.0g).
         assert results[0]["weight_used"] == 12.0
 
     @pytest.mark.asyncio