| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541 |
- """A spool swapped by hand on one tray mid-print shares the print (#3325).
- The reporter's P2S has no AMS: the external spool ran out, they loaded a new
- roll and assigned it in Bambuddy while the print was paused, and the whole
- print was charged to the new spool. The tray never changed (254 before and
- after), so the tray-change split had nothing to work with; the assignment was
- the only trace of the swap.
- """
- from datetime import datetime, timezone
- from types import SimpleNamespace
- from unittest.mock import AsyncMock, MagicMock, patch
- import pytest
- from backend.app.models.active_print_session import ActivePrintSession
- from backend.app.models.archive import PrintArchive
- from backend.app.models.spool import Spool
- from backend.app.services import spool_swaps
- from backend.app.services.usage_tracker import _track_from_3mf
- from backend.app.utils.threemf_tools import mm_to_grams
- pytestmark = pytest.mark.unit
- INV = spool_swaps.INVENTORY
- EXT = (255, 0) # the external spool, as assignments key it
- def _log(*swaps, start=4, backend=INV, key=EXT):
- """A print that started with spool ``start`` on ``key`` and the given (spool, layer) swaps."""
- log = [spool_swaps.entry(backend, *key, start, 0, start=True)]
- log += [spool_swaps.entry(backend, *key, spool, layer) for spool, layer in swaps]
- return log
- # 100 layers of filament 0, evenly: the whole print is 50 g of PLA, as the
- # slicer says, so the G-code and the slicer agree.
- LAYER_MM = 50.0 / mm_to_grams(1.0, 1.75, 1.24) / 100
- LAYER_USAGE = {layer: {0: layer * LAYER_MM} for layer in range(1, 101)}
- class TestSegments:
- def test_a_swap_after_the_start(self):
- assert spool_swaps.segments(_log((9, 60)), INV, *EXT) == [(4, 0), (9, 60)]
- def test_no_swap_is_no_split(self):
- assert spool_swaps.segments(_log(), INV, *EXT) == []
- def test_reassigning_the_same_spool_is_no_swap(self):
- assert spool_swaps.segments(_log((4, 30)), INV, *EXT) == []
- def test_a_tray_empty_at_the_start_is_not_split(self):
- # Assigning a spool mid-print to a tray that had none is catching up on
- # a forgotten assignment: the whole print goes to it, as before.
- log = [spool_swaps.entry(INV, *EXT, 9, 40)]
- assert spool_swaps.segments(log, INV, *EXT) == []
- def test_a_caught_up_assignment_covers_the_start_of_a_later_swap(self):
- # Spool 9 assigned late at layer 40, then swapped for 12 at 70: 9 fed
- # the print from its start, not from layer 40.
- log = [spool_swaps.entry(INV, *EXT, 9, 40), spool_swaps.entry(INV, *EXT, 12, 70)]
- assert spool_swaps.segments(log, INV, *EXT) == [(9, 0), (12, 70)]
- def test_other_trays_and_backends_are_ignored(self):
- log = _log((9, 60), key=(0, 1)) + _log((7, 20), backend=spool_swaps.SPOOLMAN)
- assert spool_swaps.segments(log, INV, *EXT) == []
- def test_swapping_back_is_a_segment_of_its_own(self):
- assert spool_swaps.segments(_log((9, 30), (4, 70)), INV, *EXT) == [(4, 0), (9, 30), (4, 70)]
- class TestSplit:
- def test_by_gcode_extrusion(self):
- shares = spool_swaps.split([(4, 0), (9, 60)], 50.0, 0, LAYER_USAGE, 100)
- assert [(s, round(g, 6)) for s, g in shares] == [(4, 30.0), (9, 20.0)]
- def test_by_layer_count_without_gcode(self):
- shares = spool_swaps.split([(4, 0), (9, 25)], 40.0, 0, None, 100)
- assert [(s, round(g, 6)) for s, g in shares] == [(4, 10.0), (9, 30.0)]
- def test_shares_always_add_up_and_never_starve_the_last_spool(self):
- # Front-loaded G-code: nearly all filament before the swap. The new
- # spool still gets its proportional share, and the total holds.
- usage = {1: {0: 9000.0}, 50: {0: 9900.0}, 100: {0: 10000.0}}
- shares = dict(spool_swaps.split([(4, 0), (9, 50)], 12.0, 0, usage, 100))
- assert shares[9] > 0
- assert sum(shares.values()) == pytest.approx(12.0)
- def test_a_swap_back_adds_up_per_spool(self):
- shares = dict(spool_swaps.split([(4, 0), (9, 25), (4, 75)], 100.0, 0, None, 100))
- assert shares == pytest.approx({4: 50.0, 9: 50.0})
- def test_within_a_range(self):
- # An AMS-backup segment from layer 40 to 80, the spool swapped at 60.
- shares = spool_swaps.split([(4, 0), (9, 60)], 8.0, 0, LAYER_USAGE, 80, from_layer=40)
- assert [(s, round(g, 6)) for s, g in shares] == [(4, 4.0), (9, 4.0)]
- def test_a_swap_after_the_print_stopped_goes_nowhere(self):
- # A failed print stopped at layer 30; the spool was swapped at 60.
- assert spool_swaps.split([(4, 0), (9, 60)], 5.0, 0, LAYER_USAGE, 30) == [(4, 5.0)]
- def test_nothing_to_weigh_by_goes_to_the_last_spool(self):
- assert spool_swaps.split([(4, 0), (9, 0)], 5.0, 0, None, 0) == [(9, 5.0)]
- class TestRecordAssignment:
- async def _record(self, db_session, printer_id, state, spool_id=9):
- with patch("backend.app.services.printer_manager.printer_manager.get_status", return_value=state):
- await spool_swaps.record_assignment(db_session, printer_id, *EXT, spool_id, INV)
- await db_session.commit()
- @pytest.mark.asyncio
- async def test_a_swap_while_printing_is_logged_at_the_current_layer(self, db_session, printer_factory):
- printer = await printer_factory()
- db_session.add(ActivePrintSession(printer_id=printer.id, started_at=datetime.now(), spool_change_log=_log()))
- await db_session.commit()
- state = SimpleNamespace(layer_num=57, spool_change_log=_log())
- await self._record(db_session, printer.id, state)
- assert spool_swaps.segments(state.spool_change_log, INV, *EXT) == [(4, 0), (9, 57)]
- row = await db_session.get(ActivePrintSession, printer.id)
- await db_session.refresh(row)
- assert row.spool_change_log == state.spool_change_log
- @pytest.mark.asyncio
- async def test_nothing_is_logged_without_a_print(self, db_session, printer_factory):
- printer = await printer_factory()
- state = SimpleNamespace(layer_num=0, spool_change_log=[])
- await self._record(db_session, printer.id, state)
- assert state.spool_change_log == []
- @pytest.mark.asyncio
- async def test_an_assignment_that_rolls_back_is_not_logged(self, db_session, printer_factory):
- printer = await printer_factory()
- db_session.add(ActivePrintSession(printer_id=printer.id, started_at=datetime.now(), spool_change_log=_log()))
- await db_session.commit()
- state = SimpleNamespace(layer_num=57, spool_change_log=_log())
- with patch("backend.app.services.printer_manager.printer_manager.get_status", return_value=state):
- await spool_swaps.record_assignment(db_session, printer.id, *EXT, 9, INV)
- await db_session.rollback()
- assert state.spool_change_log == _log()
- @pytest.mark.asyncio
- async def test_after_a_restart_the_start_spool_is_kept(self, db_session, printer_factory):
- # Bambuddy restarted during the runout pause: the state's log is empty
- # until the printer reports RUNNING, the row still has the start spool.
- printer = await printer_factory()
- db_session.add(ActivePrintSession(printer_id=printer.id, started_at=datetime.now(), spool_change_log=_log()))
- await db_session.commit()
- state = SimpleNamespace(layer_num=57, spool_change_log=[])
- await self._record(db_session, printer.id, state)
- assert spool_swaps.segments(state.spool_change_log, INV, *EXT) == [(4, 0), (9, 57)]
- row = await db_session.get(ActivePrintSession, printer.id)
- await db_session.refresh(row)
- assert row.spool_change_log == state.spool_change_log
- @pytest.mark.asyncio
- async def test_the_same_spool_again_is_not_logged(self, db_session, printer_factory):
- printer = await printer_factory()
- db_session.add(ActivePrintSession(printer_id=printer.id, started_at=datetime.now()))
- await db_session.commit()
- state = SimpleNamespace(layer_num=20, spool_change_log=_log())
- await self._record(db_session, printer.id, state, spool_id=4)
- assert state.spool_change_log == _log()
- # --- The built-in inventory -------------------------------------------------
- def _spool(spool_id: int):
- spool = MagicMock()
- spool.id = spool_id
- spool.label_weight = 1000
- spool.weight_used = 0
- spool.cost_per_kg = None
- spool.material = "PLA"
- spool.rgba = None
- return spool
- def _db(spools: dict[int, MagicMock]):
- archive = MagicMock()
- archive.id = 7
- archive.file_path = "archives/7/test.3mf"
- archive.extra_data = None
- archive.plate_id = None
- async def execute(stmt, *args, **kwargs):
- entity = stmt.column_descriptions[0].get("entity")
- result = MagicMock()
- value = None
- if entity is PrintArchive:
- value = archive
- elif entity is Spool:
- value = spools.get(stmt.whereclause.right.value)
- result.scalar_one_or_none.return_value = value
- result.scalars.return_value.first.return_value = None
- result.scalar.return_value = None
- return result
- db = AsyncMock()
- db.execute = execute
- db.add = MagicMock()
- return db
- async def _track(
- spool_change_log, status="completed", layer_num=100, tray_change_log=None, last_layer_num=0, total_layers=100
- ):
- printer_manager = MagicMock()
- printer_manager.get_status.return_value = SimpleNamespace(
- progress=100 if status == "completed" else 40,
- layer_num=layer_num,
- total_layers=total_layers,
- tray_now=254,
- last_loaded_tray=254,
- tray_change_log=tray_change_log or [],
- spool_change_log=spool_change_log,
- raw_data={"ams": [], "vt_tray": [{"id": "254", "tray_type": "PLA"}]},
- )
- async def resolve(printer_id, ams_id, tray_id, **kwargs):
- # AMS slot 0 holds spool 20; the external spool's live assignment is
- # spool 9, the one loaded at the end.
- return 20 if (ams_id, tray_id) == (0, 0) else 9
- charged: list[tuple[int, float]] = []
- async def charge(db, spool, grams):
- charged.append((spool.id, round(grams, 2)))
- return True
- 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": 50.0, "type": "PLA", "color": "#000000"}],
- ),
- patch("backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf", return_value=LAYER_USAGE),
- patch("backend.app.utils.threemf_tools.extract_filament_properties_from_3mf", return_value={}),
- patch("backend.app.services.usage_tracker._resolve_spool_id_for_tray", side_effect=resolve),
- patch("backend.app.services.usage_tracker._charge_spool", side_effect=charge),
- ):
- mock_path = MagicMock()
- mock_path.exists.return_value = True
- mock_settings.base_dir = MagicMock()
- mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
- results = await _track_from_3mf(
- printer_id=1,
- archive_id=7,
- status=status,
- print_name="swap",
- handled_trays=set(),
- printer_manager=printer_manager,
- db=_db({4: _spool(4), 9: _spool(9), 20: _spool(20)}),
- ams_mapping=[254],
- last_layer_num=last_layer_num,
- print_started_at=datetime.now(timezone.utc),
- )
- return results, charged
- class TestBuiltInInventory:
- @pytest.mark.asyncio
- async def test_the_print_is_split_between_the_two_spools(self):
- results, charged = await _track(_log((9, 60)))
- assert charged == [(4, 30.0), (9, 20.0)]
- assert [r["spool_id"] for r in results] == [4, 9]
- @pytest.mark.asyncio
- async def test_a_printer_that_forgot_its_layer_count_still_splits(self):
- # P1S firmware zeroes total_layer_num at print end; the G-code's own
- # last layer bounds the split instead.
- _, charged = await _track(_log((9, 60)), layer_num=0, total_layers=0)
- assert charged == [(4, 30.0), (9, 20.0)]
- @pytest.mark.asyncio
- async def test_without_a_swap_the_live_spool_takes_it_all(self):
- _, charged = await _track(_log())
- assert charged == [(9, 50.0)]
- @pytest.mark.asyncio
- async def test_a_failed_print_splits_up_to_where_it_stopped(self):
- # Stopped at layer 80 of 100: 40 g of the slot's 50 g by the G-code,
- # the swap at 60 splits it 30 / 10.
- _, charged = await _track(_log((9, 60)), status="failed", layer_num=0, last_layer_num=80)
- assert charged == [(4, 30.0), (9, 10.0)]
- @pytest.mark.asyncio
- async def test_an_ams_backup_segment_is_split_too(self):
- # AMS backup moved the print from AMS slot 0 to the external spool at
- # layer 40, and that spool was swapped by hand at 60.
- _, charged = await _track(_log((9, 60)), tray_change_log=[(0, 0), (254, 40)])
- assert charged == [(20, 20.0), (4, 10.0), (9, 20.0)]
- # --- Spoolman ---------------------------------------------------------------
- def _client():
- client = MagicMock()
- client.find_spool_by_tag = AsyncMock(return_value=None)
- client.use_spool = AsyncMock()
- client.get_spool = AsyncMock(side_effect=lambda spool_id: {"id": spool_id, "price": 20.0, "initial_weight": 1000})
- return client
- def _splitter(log, end_layer=100, completed=True):
- return spool_swaps.Splitter(log, spool_swaps.SPOOLMAN, LAYER_USAGE, end_layer, completed=completed)
- class TestSpoolman:
- @pytest.mark.asyncio
- async def test_the_print_is_split_between_the_two_spools(self):
- from backend.app.services.spoolman_tracking import _PrintCost, _report_spool_usage_for_slots
- client = _client()
- cost = _PrintCost()
- with patch(
- "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment", AsyncMock(return_value=9)
- ):
- updated = await _report_spool_usage_for_slots(
- client,
- [(1, 50.0)],
- {254: {"tray_type": "PLA"}},
- [254],
- "Archive 7",
- "SERIAL",
- printer_id=1,
- cost_out=cost,
- swaps=_splitter(_log((9, 60), backend=spool_swaps.SPOOLMAN)),
- )
- assert updated == 2
- assert [c.args for c in client.use_spool.await_args_list] == [(4, 30.0), (9, 20.0)]
- assert cost.priced_grams == pytest.approx(50.0)
- @pytest.mark.asyncio
- async def test_without_a_swap_nothing_changes(self):
- from backend.app.services.spoolman_tracking import _report_spool_usage_for_slots
- client = _client()
- with patch(
- "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment", AsyncMock(return_value=9)
- ):
- await _report_spool_usage_for_slots(
- client,
- [(1, 50.0)],
- {254: {"tray_type": "PLA"}},
- [254],
- "Archive 7",
- "SERIAL",
- printer_id=1,
- swaps=_splitter(_log(backend=spool_swaps.SPOOLMAN)),
- )
- assert [c.args for c in client.use_spool.await_args_list] == [(9, 50.0)]
- def test_a_multi_colour_print_without_gcode_layers_splits_by_layer_count(self):
- # report_usage reads the printer's layers for single-slot prints only.
- from backend.app.services.spoolman_tracking import _swap_splitter
- log = _log((9, 25), backend=spool_swaps.SPOOLMAN)
- state = SimpleNamespace(total_layers=100, layer_num=100, spool_change_log=log)
- with patch("backend.app.services.printer_manager.printer_manager.get_status", return_value=state):
- swaps = _swap_splitter(1, None, None, 0, completed=True)
- assert swaps.shares(*EXT, 1, 40.0) == pytest.approx([(4, 10.0), (9, 30.0)])
- @pytest.mark.asyncio
- async def test_a_tray_change_segment_is_split_too(self):
- from backend.app.services.spoolman_tracking import _report_spool_usage_split_by_tray_changes
- client = _client()
- async def slot(printer_id, ams_id, tray_id):
- return 9 if ams_id == 255 else 20
- with patch("backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment", side_effect=slot):
- await _report_spool_usage_split_by_tray_changes(
- client,
- [{"slot_id": 1, "used_g": 50.0}],
- [(0, 0), (254, 40)],
- {0: {"tray_type": "PLA"}, 254: {"tray_type": "PLA"}},
- LAYER_USAGE,
- {},
- 100,
- 100,
- "Archive 7",
- "SERIAL",
- printer_id=1,
- swaps=_splitter(_log((9, 60), backend=spool_swaps.SPOOLMAN)),
- )
- charged = [c.args for c in client.use_spool.await_args_list]
- assert charged[0][0] == 20
- assert charged[1:] == [(4, 10.0), (9, 20.0)]
- # --- Print start and restart --------------------------------------------------
- class TestPrintStartAndRestart:
- def _state(self):
- state = MagicMock()
- state.raw_data = {"ams": [], "vt_tray": [{"id": "254", "tray_type": "PLA"}]}
- state.tray_now = 254
- state.tray_change_log = [(254, 0)]
- state.spool_change_log = []
- return state
- @pytest.mark.asyncio
- @pytest.mark.parametrize("spoolman", [False, True])
- async def test_print_start_logs_the_spool_each_tray_starts_with(self, db_session, printer_factory, spoolman):
- from backend.app.models.spool_assignment import SpoolAssignment
- from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
- from backend.app.services.usage_tracker import _active_sessions, on_print_start
- printer = await printer_factory()
- spool = Spool(material="PLA", label_weight=1000, weight_used=0)
- db_session.add(spool)
- await db_session.commit()
- db_session.add(SpoolAssignment(spool_id=spool.id, printer_id=printer.id, ams_id=255, tray_id=0))
- db_session.add(SpoolmanSlotAssignment(printer_id=printer.id, ams_id=255, tray_id=0, spoolman_spool_id=77))
- await db_session.commit()
- state = self._state()
- printer_manager = MagicMock()
- printer_manager.get_status.return_value = state
- await on_print_start(
- printer.id, {"subtask_name": "swap"}, printer_manager, db=db_session, spoolman_owns_usage=spoolman
- )
- _active_sessions.pop(printer.id, None)
- backend, spool_id = (spool_swaps.SPOOLMAN, 77) if spoolman else (INV, spool.id)
- assert state.spool_change_log == [spool_swaps.entry(backend, 255, 0, spool_id, 0, start=True)]
- row = await db_session.get(ActivePrintSession, printer.id)
- assert row.spool_change_log == state.spool_change_log
- @pytest.mark.asyncio
- async def test_a_restart_puts_the_log_back(self, db_session, printer_factory):
- from backend.app.main import _restore_usage_tracking_session
- printer = await printer_factory()
- before_restart = _log((9, 40))
- db_session.add(
- ActivePrintSession(
- printer_id=printer.id,
- print_name="swap",
- started_at=datetime.now(),
- spool_change_log=before_restart,
- )
- )
- await db_session.commit()
- since = spool_swaps.entry(INV, *EXT, 12, 70)
- state = SimpleNamespace(
- subtask_name="swap",
- tray_change_log=[],
- tray_now=254,
- layer_num=75,
- last_loaded_tray=254,
- print_ams_mapping=None,
- spool_change_log=[since],
- )
- await _restore_usage_tracking_session(printer.id, state, db_session, MagicMock())
- assert state.spool_change_log == before_restart + [since]
- assert spool_swaps.segments(state.spool_change_log, INV, *EXT) == [(4, 0), (9, 40), (12, 70)]
- class TestBuiltInRemainDelta:
- """Without a 3MF the built-in tracker charges a tray's remain% drop. Across a
- swap the start and end readings belong to two different spools."""
- @pytest.fixture(autouse=True)
- def _clean(self):
- from backend.app.services.usage_tracker import _active_sessions
- _active_sessions.clear()
- with patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None):
- yield
- _active_sessions.clear()
- async def _complete(self, spool_change_log):
- from backend.app.services.usage_tracker import PrintSession, _active_sessions, on_print_complete
- _active_sessions[1] = PrintSession(
- printer_id=1,
- print_name="Test",
- started_at=datetime.now(timezone.utc),
- tray_remain_start={(0, 0): 80},
- )
- printer_manager = MagicMock()
- printer_manager.get_status.return_value = SimpleNamespace(
- raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
- tray_now=0,
- last_loaded_tray=-1,
- spool_change_log=spool_change_log,
- )
- assignment = MagicMock(spool_id=2, printer_id=1, ams_id=0, tray_id=0)
- spool = _spool(2)
- responses = iter([None, None, assignment, spool])
- async def execute(*args, **kwargs):
- result = MagicMock()
- value = next(responses, None)
- result.scalar_one_or_none.return_value = value
- result.scalars.return_value.first.return_value = value
- result.scalar.return_value = None
- return result
- db = AsyncMock()
- db.execute = execute
- db.add = MagicMock()
- return await on_print_complete(
- printer_id=1, data={"status": "completed"}, printer_manager=printer_manager, db=db, archive_id=None
- )
- @pytest.mark.asyncio
- async def test_a_swapped_tray_is_not_charged_its_remain_drop(self):
- assert await self._complete(_log((2, 40), start=5, key=(0, 0))) == []
- @pytest.mark.asyncio
- async def test_an_assignment_corrected_before_the_first_layer_is_still_charged(self):
- results = await self._complete(_log((2, 0), start=5, key=(0, 0)))
- assert [(r["spool_id"], r["weight_used"]) for r in results] == [(2, 100.0)]
- @pytest.mark.asyncio
- async def test_a_tray_that_kept_its_spool_still_is(self):
- results = await self._complete(_log(start=2, key=(0, 0)))
- assert [(r["spool_id"], r["weight_used"]) for r in results] == [(2, 100.0)]
|