test_plate_estimates_3375.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. """Each print against the estimate of the plate it printed (#3375).
  2. A multi-plate 3MF's archive adds its plates up (#1593): one plate printed
  3. read as +200 % on the card and skewed the statistics. The archive keeps each
  4. plate's estimate, read from its 3MF, and a print is measured against its
  5. plate's.
  6. """
  7. from __future__ import annotations
  8. import zipfile
  9. from datetime import datetime, timedelta
  10. from types import SimpleNamespace
  11. import pytest
  12. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  13. from backend.app.core.config import settings as app_settings
  14. from backend.app.models.archive import PrintArchive
  15. from backend.app.models.print_log import PrintLogEntry
  16. from backend.app.services.print_cost_estimate import (
  17. fill_plate_estimates,
  18. refresh_plate_estimates,
  19. run_estimate,
  20. )
  21. from backend.app.utils.threemf_tools import extract_plate_estimates_from_3mf
  22. T1 = datetime(2026, 10, 30, 9)
  23. DIR = "archives/plates"
  24. SLICE_INFO = """<?xml version="1.0" encoding="UTF-8"?>
  25. <config>
  26. <plate>
  27. <metadata key="index" value="1"/>
  28. <metadata key="prediction" value="3600"/>
  29. <filament id="1" type="PLA" color="#FFFFFF" used_m="10" used_g="30.5"/>
  30. <filament id="2" type="PLA" color="#000000" used_m="3" used_g="9.5"/>
  31. </plate>
  32. <plate>
  33. <metadata key="index" value="2"/>
  34. <metadata key="prediction" value="1800"/>
  35. <filament id="1" type="PLA" color="#FFFFFF" used_m="6" used_g="20"/>
  36. </plate>
  37. </config>
  38. """
  39. def _three_mf(path, slice_info: str | None = SLICE_INFO):
  40. path.parent.mkdir(parents=True, exist_ok=True)
  41. with zipfile.ZipFile(path, "w") as zf:
  42. zf.writestr("3D/3dmodel.model", "<model/>")
  43. if slice_info is not None:
  44. zf.writestr("Metadata/slice_info.config", slice_info)
  45. return path
  46. @pytest.fixture
  47. def files(tmp_path, monkeypatch):
  48. monkeypatch.setattr(app_settings, "base_dir", tmp_path)
  49. monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
  50. _three_mf(tmp_path / DIR / "two.gcode.3mf")
  51. return tmp_path
  52. def _file(**overrides):
  53. values = {"plate_estimates": None, "print_time_seconds": 5400, "filament_used_grams": 60.0}
  54. return SimpleNamespace(**{**values, **overrides})
  55. PLATES = {"1": {"seconds": 3600, "grams": 40.0}, "2": {"seconds": 1800, "grams": 20.0}}
  56. class TestReadingPlates:
  57. def test_each_plate_has_its_time_and_grams(self, tmp_path):
  58. assert extract_plate_estimates_from_3mf(_three_mf(tmp_path / "f.3mf")) == PLATES
  59. def test_a_file_not_sliced_has_none(self, tmp_path):
  60. assert extract_plate_estimates_from_3mf(_three_mf(tmp_path / "f.3mf", slice_info=None)) == {}
  61. def test_an_unreadable_file_is_left_to_read_again(self, tmp_path):
  62. bad = tmp_path / "f.3mf"
  63. bad.write_bytes(b"not a zip")
  64. assert extract_plate_estimates_from_3mf(bad) is None
  65. class TestRunEstimate:
  66. def test_a_print_of_a_plate_is_that_plates(self):
  67. estimate = run_estimate(_file(plate_estimates=PLATES), 2)
  68. assert (estimate.seconds, estimate.grams, estimate.per_plate) == (1800, 20.0, True)
  69. def test_a_print_of_a_single_plate_file_is_its_plate_whatever_its_number(self):
  70. estimate = run_estimate(_file(plate_estimates={"2": PLATES["2"]}), 1)
  71. assert (estimate.seconds, estimate.per_plate) == (1800, True)
  72. def test_a_print_of_an_unknown_plate_of_several_has_none(self):
  73. assert run_estimate(_file(plate_estimates=PLATES), None) is None
  74. def test_plates_not_known_give_the_files_figures(self):
  75. for plates in (None, {}):
  76. estimate = run_estimate(_file(plate_estimates=plates), 2)
  77. assert (estimate.seconds, estimate.grams, estimate.per_plate) == (5400, 60.0, False)
  78. class TestKeepingPlates:
  79. def test_an_archive_reads_its_files_plates(self, files):
  80. archive = PrintArchive(filename="two.gcode.3mf", file_path=f"{DIR}/two.gcode.3mf", file_size=1)
  81. refresh_plate_estimates(archive)
  82. assert archive.plate_estimates == PLATES
  83. def test_a_gcode_file_has_no_plates_to_read(self, files):
  84. (files / DIR / "cube.gcode").write_text("; gcode\n")
  85. archive = PrintArchive(filename="cube.gcode", file_path=f"{DIR}/cube.gcode", file_size=1)
  86. refresh_plate_estimates(archive)
  87. assert archive.plate_estimates == {}
  88. def test_an_archive_without_its_file_stays_unread(self, files):
  89. archive = PrintArchive(filename="gone.3mf", file_path=f"{DIR}/gone.3mf", file_size=1)
  90. refresh_plate_estimates(archive)
  91. assert archive.plate_estimates is None
  92. async def test_an_archived_print_keeps_its_files_plates(self, files, db_session, printer_factory):
  93. from backend.app.services.archive import ArchiveService
  94. printer = await printer_factory()
  95. source = _three_mf(files / "upload" / "two.gcode.3mf")
  96. archive = await ArchiveService(db_session).archive_print(printer.id, source)
  97. assert archive.plate_estimates == PLATES
  98. async def test_a_print_given_its_3mf_later_reads_its_plates(self, files, db_session, printer_factory):
  99. from backend.app.services.archive import ArchiveService
  100. printer = await printer_factory()
  101. fallback = PrintArchive(printer_id=printer.id, filename="two.3mf", file_path="", file_size=0, status="printing")
  102. db_session.add(fallback)
  103. await db_session.commit()
  104. source = _three_mf(files / "upload" / "two.gcode.3mf")
  105. archive = await ArchiveService(db_session).archive_print(printer.id, source, update_archive_id=fallback.id)
  106. assert (archive.id, archive.plate_estimates) == (fallback.id, PLATES)
  107. async def test_archives_made_before_get_theirs_once(self, files, db_session, test_engine):
  108. older = PrintArchive(filename="two.gcode.3mf", file_path=f"{DIR}/two.gcode.3mf", file_size=1)
  109. gone = PrintArchive(filename="gone.3mf", file_path=f"{DIR}/gone.3mf", file_size=1)
  110. read = PrintArchive(filename="r.3mf", file_path=f"{DIR}/two.gcode.3mf", file_size=1, plate_estimates={})
  111. db_session.add_all([older, gone, read])
  112. await db_session.commit()
  113. sessions = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  114. assert await fill_plate_estimates(sessions, batch=1) == 2
  115. assert await fill_plate_estimates(sessions) == 0
  116. for row in (older, gone, read):
  117. await db_session.refresh(row)
  118. assert (older.plate_estimates, gone.plate_estimates, read.plate_estimates) == (PLATES, {}, {})
  119. async def test_an_archive_read_meanwhile_keeps_what_was_read(self, files, db_session, test_engine, monkeypatch):
  120. from backend.app.services import print_cost_estimate
  121. racing = PrintArchive(filename="two.gcode.3mf", file_path="", file_size=0)
  122. deleted = PrintArchive(filename="gone.3mf", file_path=f"{DIR}/gone.3mf", file_size=1)
  123. db_session.add_all([racing, deleted])
  124. await db_session.commit()
  125. sessions = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  126. async def meanwhile(fn, row):
  127. # Its 3MF recovered and read, the other one deleted, while the
  128. # fill reads the first.
  129. if row.id == racing.id:
  130. async with sessions() as other:
  131. (await other.get(PrintArchive, racing.id)).plate_estimates = PLATES
  132. await other.delete(await other.get(PrintArchive, deleted.id))
  133. await other.commit()
  134. return fn(row)
  135. monkeypatch.setattr(print_cost_estimate, "asyncio", SimpleNamespace(to_thread=meanwhile))
  136. assert await fill_plate_estimates(sessions) == 0
  137. await db_session.refresh(racing)
  138. assert racing.plate_estimates == PLATES
  139. async def test_an_archive_set_back_to_unread_is_read_again(self, files, db_session, test_engine):
  140. archive = PrintArchive(filename="two.gcode.3mf", file_path=f"{DIR}/two.gcode.3mf", file_size=1)
  141. archive.plate_estimates = {}
  142. db_session.add(archive)
  143. await db_session.commit()
  144. # As the print start's rollback of an adoption restores its columns.
  145. archive.plate_estimates = None
  146. await db_session.commit()
  147. sessions = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  148. assert await fill_plate_estimates(sessions) == 1
  149. @pytest.fixture
  150. async def printed(db_session, printer_factory, files):
  151. """Plate 1 of the file printed in its estimate, then plate 2 in 2000 s
  152. of its 1800."""
  153. printer = await printer_factory()
  154. archive = PrintArchive(
  155. printer_id=printer.id,
  156. filename="two.gcode.3mf",
  157. file_path=f"{DIR}/two.gcode.3mf",
  158. file_size=1,
  159. print_name="two",
  160. print_time_seconds=5400,
  161. filament_used_grams=60.0,
  162. status="completed",
  163. plate_estimates=PLATES,
  164. )
  165. db_session.add(archive)
  166. await db_session.flush()
  167. first = PrintLogEntry(
  168. archive_id=archive.id,
  169. printer_id=printer.id,
  170. plate_id=1,
  171. status="completed",
  172. started_at=T1,
  173. completed_at=T1 + timedelta(seconds=3600),
  174. duration_seconds=3600,
  175. filament_used_grams=40.0,
  176. )
  177. second = PrintLogEntry(
  178. archive_id=archive.id,
  179. printer_id=printer.id,
  180. plate_id=2,
  181. status="completed",
  182. started_at=T1 + timedelta(hours=2),
  183. completed_at=T1 + timedelta(hours=2, seconds=2000),
  184. duration_seconds=2000,
  185. filament_used_grams=21.0,
  186. )
  187. db_session.add_all([first, second])
  188. await db_session.commit()
  189. return SimpleNamespace(archive=archive, printer=printer, first=first.id, second=second.id)
  190. async def _card(async_client, archive_id):
  191. return next(a for a in (await async_client.get("/api/v1/archives/")).json() if a["id"] == archive_id)
  192. @pytest.mark.asyncio
  193. @pytest.mark.integration
  194. class TestReading:
  195. async def test_the_card_measures_the_latest_print_against_its_plate(self, async_client, printed):
  196. card = await _card(async_client, printed.archive.id)
  197. assert (card["time_estimate_seconds"], card["actual_time_seconds"], card["time_accuracy"]) == (1800, 2000, 90.0)
  198. async def test_a_file_whose_plates_are_unknown_shows_no_badge_once_printed_twice(
  199. self, async_client, db_session, printed
  200. ):
  201. printed.archive.plate_estimates = None
  202. await db_session.commit()
  203. card = await _card(async_client, printed.archive.id)
  204. assert (card["time_estimate_seconds"], card["time_accuracy"]) == (None, None)
  205. async def test_a_print_not_completed_shows_its_estimate_and_no_badge(self, async_client, db_session, printed):
  206. second = await db_session.get(PrintLogEntry, printed.second)
  207. second.status = "failed"
  208. await db_session.commit()
  209. card = await _card(async_client, printed.archive.id)
  210. assert (card["time_estimate_seconds"], card["actual_time_seconds"], card["time_accuracy"]) == (1800, None, None)
  211. async def test_a_print_of_an_unknown_plate_shows_its_time_and_no_badge(self, async_client, db_session, printed):
  212. second = await db_session.get(PrintLogEntry, printed.second)
  213. second.plate_id = None
  214. await db_session.commit()
  215. card = await _card(async_client, printed.archive.id)
  216. assert (card["time_estimate_seconds"], card["actual_time_seconds"], card["time_accuracy"]) == (None, 2000, None)
  217. async def test_an_archive_shown_as_another_print_has_no_badge_of_the_latest(self, async_client, printed):
  218. shown = (await async_client.get(f"/api/v1/archives/{printed.archive.id}?run_id={printed.first}")).json()
  219. assert (shown["plate_id"], shown["time_estimate_seconds"], shown["time_accuracy"]) == (1, None, None)
  220. async def test_each_print_event_has_its_plates_estimate(self, async_client, printed):
  221. events = (await async_client.get("/api/v1/archives/slim")).json()
  222. assert sorted(e["print_time_seconds"] for e in events) == [1800, 3600]
  223. async def test_each_print_carries_its_plates_estimate(self, async_client, db_session, printed):
  224. third = PrintLogEntry(archive_id=printed.archive.id, printer_id=printed.printer.id, status="failed")
  225. db_session.add(third)
  226. await db_session.commit()
  227. runs = (await async_client.get(f"/api/v1/archives/{printed.archive.id}/runs")).json()["items"]
  228. by_id = {r["id"]: (r["estimate_seconds"], r["estimate_grams"]) for r in runs}
  229. assert by_id == {printed.first: (3600, 40.0), printed.second: (1800, 20.0), third.id: (None, None)}
  230. async def test_prints_of_a_file_whose_plates_are_unknown_get_no_estimate(self, async_client, db_session, printed):
  231. printed.archive.plate_estimates = None
  232. await db_session.commit()
  233. runs = (await async_client.get(f"/api/v1/archives/{printed.archive.id}/runs")).json()["items"]
  234. # The file's 5400 s describes neither plate.
  235. assert [r["estimate_seconds"] for r in runs] == [None, None]
  236. async def test_the_statistics_measure_each_print_against_its_plate(self, async_client, printed):
  237. body = (await async_client.get("/api/v1/archives/stats")).json()
  238. # 100 % and 90 %: against the file's 5400 s they were 150 % and 270 %.
  239. assert body["average_time_accuracy"] == pytest.approx(95.0)
  240. @pytest.mark.asyncio
  241. @pytest.mark.integration
  242. async def test_a_queued_job_moved_to_another_plate_takes_its_time(async_client, db_session, printed):
  243. from backend.app.models.print_queue import PrintQueueItem
  244. item = PrintQueueItem(
  245. printer_id=printed.printer.id, archive_id=printed.archive.id, position=1, plate_id=1, print_time_seconds=3600
  246. )
  247. db_session.add(item)
  248. await db_session.commit()
  249. response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"plate_id": 2})
  250. assert response.status_code == 200, response.text
  251. await db_session.refresh(item)
  252. assert item.print_time_seconds == 1800
  253. def test_the_start_notification_gives_the_plates_time(monkeypatch):
  254. from backend.app import main
  255. archive = SimpleNamespace(id=7, plate_id=1, plate_estimates=PLATES, print_time_seconds=5400, filament_used_grams=60)
  256. monkeypatch.setattr(main, "_print_plate_ids", {(3, 7): 2})
  257. assert main._run_print_time_seconds(archive, 3) == 1800
  258. # Another printer's dispatch of it: the archive's plate.
  259. assert main._run_print_time_seconds(archive, 4) == 3600
  260. # Plates not known: the file's.
  261. assert main._run_print_time_seconds(SimpleNamespace(**{**vars(archive), "plate_estimates": None}), 3) == 5400
  262. # A plate not known of several: none, the printer's remaining time is used.
  263. assert main._run_print_time_seconds(SimpleNamespace(**{**vars(archive), "plate_id": None}), 4) is None