| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239 |
- """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}
|