test_partial_slicer_scale_3317.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  1. """A cancelled print is charged on the slicer's weights, like a finished one (#3317).
  2. The G-code feeds more than the slicer counts: the start sequence and the purge
  3. at a filament change, and both ahead of the layers they serve. Its grams on
  4. their own charged a print cancelled near the end more than the same print
  5. finished. The shape below is the reporter's X1C two-colour cylinder: charcoal
  6. (slot 4) on layers 0-24 after a ~220 mm start sequence, turquoise (slot 1) on
  7. layers 25-46 after a ~245 mm purge.
  8. """
  9. from types import SimpleNamespace
  10. from unittest.mock import AsyncMock, MagicMock, patch
  11. import pytest
  12. from backend.app.services import spoolman_tracking
  13. from backend.app.utils.threemf_tools import (
  14. mm_to_grams,
  15. parse_gcode_layer_filament_usage,
  16. slicer_grams_at_layer,
  17. )
  18. SLICER = [{"slot_id": 1, "used_g": 0.77}, {"slot_id": 4, "used_g": 1.55}]
  19. DENSITY = {1: 1.26, 4: 1.24}
  20. def _two_colour_gcode() -> str:
  21. lines = ["M620 S3A", "G1 E220"] # start sequence on the first filament
  22. for layer in range(1, 25):
  23. lines += [f"M73 L{layer}", "G1 E13.83", "G1 E-0.8", "G1 E0.8"]
  24. lines += ["M73 L25", "M620 S0A", "G1 E244.5"] # change to turquoise, flush
  25. for layer in range(25, 48):
  26. lines += [f"M73 L{layer}", "G1 E3.28"]
  27. return "\n".join(lines)
  28. LAYERS = parse_gcode_layer_filament_usage(_two_colour_gcode())
  29. def _total(grams: dict[int, float]) -> float:
  30. return sum(grams.values())
  31. def test_the_gcode_alone_charges_a_late_cancel_more_than_a_finish():
  32. # What dev charged before: the premise of the fix, kept so it stays true.
  33. from backend.app.utils.threemf_tools import get_cumulative_usage_at_layer
  34. raw = {
  35. fid + 1: mm_to_grams(mm, 1.75, DENSITY[fid + 1])
  36. for fid, mm in get_cumulative_usage_at_layer(LAYERS, 33).items()
  37. }
  38. assert _total(raw) > sum(u["used_g"] for u in SLICER)
  39. @pytest.mark.parametrize("layer", [1, 10, 24, 25, 33, 42, 46])
  40. def test_a_cancel_never_costs_more_than_the_finished_print(layer):
  41. grams = slicer_grams_at_layer(LAYERS, layer, SLICER)
  42. for usage in SLICER:
  43. assert grams.get(usage["slot_id"], 0.0) <= usage["used_g"] + 1e-9
  44. def test_each_filament_follows_its_own_feed():
  45. at_33 = slicer_grams_at_layer(LAYERS, 33, SLICER)
  46. # Charcoal finished at layer 24: all of it.
  47. assert at_33[4] == pytest.approx(1.55)
  48. # Turquoise's purge came first, so it is well past a third of its weight
  49. # eight layers in, and short of the whole.
  50. assert 0.6 < at_33[1] < 0.77
  51. def test_the_last_layer_is_the_slicer_weight():
  52. assert slicer_grams_at_layer(LAYERS, 47, SLICER) == pytest.approx({1: 0.77, 4: 1.55})
  53. def test_a_filament_not_fed_yet_is_not_charged():
  54. assert set(slicer_grams_at_layer(LAYERS, 10, SLICER)) == {4}
  55. def test_a_slot_the_slicer_did_not_weigh_is_left_to_the_caller():
  56. assert slicer_grams_at_layer(LAYERS, 33, [{"slot_id": 4, "used_g": 1.55}, {"slot_id": 1, "used_g": 0}]) == {
  57. 4: pytest.approx(1.55)
  58. }
  59. def test_a_retraction_at_the_end_does_not_charge_above_the_weight():
  60. layers = {1: {0: 100.0}, 2: {0: 98.0}}
  61. assert slicer_grams_at_layer(layers, 1, [{"slot_id": 1, "used_g": 3.0}]) == {1: 3.0}
  62. def test_a_slot_listed_twice_keeps_its_first_weight():
  63. # Every plate listed when the plate is unknown.
  64. layers = {1: {0: 50.0}, 2: {0: 100.0}}
  65. usage = [{"slot_id": 1, "used_g": 4.0}, {"slot_id": 1, "used_g": 9.0}]
  66. assert slicer_grams_at_layer(layers, 1, usage) == {1: 2.0}
  67. def test_no_layer_data():
  68. assert slicer_grams_at_layer({}, 10, SLICER) == {}
  69. @pytest.mark.asyncio
  70. async def test_spoolman_charges_a_cancel_on_the_slicer_weights():
  71. session = MagicMock()
  72. session.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
  73. session.return_value.__aexit__ = AsyncMock(return_value=False)
  74. tracking = SimpleNamespace(
  75. # Stored as JSON, so with string keys.
  76. layer_usage={str(layer): {str(fid): mm for fid, mm in used.items()} for layer, used in LAYERS.items()},
  77. filament_properties={},
  78. filament_usage=SLICER,
  79. ams_trays={},
  80. slot_to_tray=[0, 1, 2, 3],
  81. tray_remain_start={},
  82. archive_id=1,
  83. tray_now_at_start=0,
  84. )
  85. status = SimpleNamespace(layer_num=33, total_layers=47, raw_data={}, tray_change_log=[])
  86. report = AsyncMock(return_value=2)
  87. with (
  88. patch.object(spoolman_tracking, "async_session", session),
  89. patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
  90. patch("backend.app.services.printer_manager.printer_manager.get_status", MagicMock(return_value=status)),
  91. patch.object(spoolman_tracking, "_get_printer_serial", AsyncMock(return_value="SERIAL")),
  92. patch.object(spoolman_tracking, "_get_spoolman_client_with_fallback", AsyncMock(return_value=MagicMock())),
  93. patch.object(spoolman_tracking, "_report_spool_usage_for_slots", report),
  94. ):
  95. await spoolman_tracking._report_partial_usage(1, tracking, last_layer_num=33)
  96. items = dict(report.call_args.args[1])
  97. assert items[4] == pytest.approx(1.55)
  98. assert 0.6 < items[1] < 0.77
  99. def test_a_filament_that_never_fed_net_is_left_to_the_caller():
  100. # Only retracted: its snapshots read 0 mm, and there is no share to take.
  101. layers = {1: {0: 50.0, 1: 0.0}, 2: {0: 100.0, 1: 0.0}}
  102. usage = [{"slot_id": 1, "used_g": 4.0}, {"slot_id": 2, "used_g": 1.0}]
  103. assert slicer_grams_at_layer(layers, 1, usage) == {1: 2.0}
  104. @pytest.mark.asyncio
  105. async def test_inventory_backup_switch_shares_the_scaled_weight_between_both_spools():
  106. # The split prices segments in G-code grams; on the slicer's (smaller)
  107. # total the spool switched to at layer 30 came out below zero and got
  108. # nothing, while the first spool got more than the whole cancelled print.
  109. from backend.app.services.usage_tracker import _track_from_3mf
  110. from backend.tests.unit.test_usage_tracker import (
  111. _make_archive,
  112. _make_assignment,
  113. _make_spool,
  114. _mock_db_sequential,
  115. )
  116. spool_a, spool_b = _make_spool(spool_id=10), _make_spool(spool_id=20)
  117. db = _mock_db_sequential(
  118. [
  119. _make_archive(archive_id=100),
  120. None,
  121. _make_assignment(spool_id=10, ams_id=0, tray_id=0),
  122. spool_a,
  123. _make_assignment(spool_id=20, ams_id=0, tray_id=1),
  124. spool_b,
  125. ]
  126. )
  127. printer_manager = MagicMock()
  128. printer_manager.get_status.return_value = SimpleNamespace(
  129. progress=31,
  130. layer_num=31,
  131. tray_now=1,
  132. last_loaded_tray=1,
  133. total_layers=100,
  134. tray_change_log=[(0, 0), (1, 30)],
  135. )
  136. # 37 mm a layer: 3700 mm (11.0 g) in all, against the slicer's 10 g.
  137. layers = {layer: {0: 37.0 * layer} for layer in range(1, 101)}
  138. with (
  139. patch("backend.app.core.config.settings") as mock_settings,
  140. patch(
  141. "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
  142. return_value=[{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": ""}],
  143. ),
  144. patch("backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf", return_value=layers),
  145. patch(
  146. "backend.app.utils.threemf_tools.extract_filament_properties_from_3mf",
  147. return_value={1: {"density": 1.24, "diameter": 1.75}},
  148. ),
  149. ):
  150. path = MagicMock()
  151. path.exists.return_value = True
  152. mock_settings.base_dir.__truediv__ = MagicMock(return_value=path)
  153. results = await _track_from_3mf(
  154. printer_id=1,
  155. archive_id=100,
  156. status="failed",
  157. print_name="Runout",
  158. handled_trays=set(),
  159. printer_manager=printer_manager,
  160. db=db,
  161. )
  162. charged = {r["spool_id"]: r["weight_used"] for r in results}
  163. # 10 g x 1147/3700 = 3.1 g, shared as the G-code fed it: 30 layers, then 1.
  164. assert sum(charged.values()) == pytest.approx(3.1, abs=0.05)
  165. assert charged[10] == pytest.approx(3.0, abs=0.05)
  166. assert charged[20] == pytest.approx(0.1, abs=0.05)
  167. @pytest.mark.asyncio
  168. async def test_spoolman_does_not_charge_a_slot_the_slicer_did_not_weigh():
  169. session = MagicMock()
  170. session.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
  171. session.return_value.__aexit__ = AsyncMock(return_value=False)
  172. tracking = SimpleNamespace(
  173. layer_usage={"10": {"0": 100.0, "1": 200.0}, "20": {"0": 200.0, "1": 200.0}},
  174. filament_properties={},
  175. # Fed by the G-code, but weighed 0 g: a finished print charges it nothing.
  176. filament_usage=[{"slot_id": 1, "used_g": 4.0}, {"slot_id": 2, "used_g": 0}],
  177. ams_trays={},
  178. slot_to_tray=[0, 1],
  179. tray_remain_start={},
  180. archive_id=1,
  181. tray_now_at_start=0,
  182. )
  183. status = SimpleNamespace(layer_num=10, total_layers=20, raw_data={}, tray_change_log=[])
  184. report = AsyncMock(return_value=1)
  185. with (
  186. patch.object(spoolman_tracking, "async_session", session),
  187. patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
  188. patch("backend.app.services.printer_manager.printer_manager.get_status", MagicMock(return_value=status)),
  189. patch.object(spoolman_tracking, "_get_printer_serial", AsyncMock(return_value="SERIAL")),
  190. patch.object(spoolman_tracking, "_get_spoolman_client_with_fallback", AsyncMock(return_value=MagicMock())),
  191. patch.object(spoolman_tracking, "_report_spool_usage_for_slots", report),
  192. ):
  193. await spoolman_tracking._report_partial_usage(1, tracking, last_layer_num=10)
  194. assert dict(report.call_args.args[1]) == {1: 2.0}