pipeline_eligibility.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  1. """Eligibility matcher for Slicer Pipeline runs (#1425 PR B).
  2. Given a pipeline + the user's pinned target printer, this returns a structured
  3. report of issues the operator should resolve before running. The frontend
  4. displays the report; the user can ``Run anyway`` to proceed (lenient policy —
  5. the print may still fail at the printer, but Bambuddy isn't going to refuse
  6. the click).
  7. Issue kinds (pinned for tests + i18n keys):
  8. - printer_not_set — pipeline has no target_printer_id
  9. - printer_not_found — target_printer_id points at a deleted/missing row
  10. - printer_disabled — Printer.is_active is False (#1476)
  11. - printer_offline — MQTT not connected
  12. - filament_type_mismatch — AMS slot loaded with wrong filament type
  13. - filament_color_mismatch — type matches, colour differs
  14. - ams_slot_missing — pipeline expects N filament slots but AMS exposes fewer
  15. - filament_unverified — pipeline filament preset is a non-local tier we
  16. can't statically read (cloud / orca_cloud / standard);
  17. the run will proceed, but the operator should
  18. double-check
  19. The matcher is a pure-ish function over (pipeline, printer row, live AMS state,
  20. local-preset dict) so unit tests can drive it with fixtures without spinning up
  21. MQTT. The route handler is the only place that talks to ``printer_manager``.
  22. """
  23. from __future__ import annotations
  24. import json
  25. from dataclasses import dataclass
  26. from typing import Literal
  27. from sqlalchemy import select
  28. from sqlalchemy.ext.asyncio import AsyncSession
  29. from backend.app.models.local_preset import LocalPreset
  30. from backend.app.models.printer import Printer
  31. from backend.app.models.slicer_pipeline import SlicerPipeline
  32. from backend.app.utils.filament_types import canonical_filament_type
  33. IssueKind = Literal[
  34. "printer_not_set",
  35. "printer_not_found",
  36. "printer_disabled",
  37. "printer_offline",
  38. "filament_type_mismatch",
  39. "filament_color_mismatch",
  40. "ams_slot_missing",
  41. "filament_unverified",
  42. "no_class_matches",
  43. "class_not_set",
  44. ]
  45. @dataclass(frozen=True)
  46. class EligibilityIssue:
  47. kind: IssueKind
  48. slot_index: int | None = None
  49. expected: str | None = None
  50. actual: str | None = None
  51. @dataclass(frozen=True)
  52. class PerPrinterReport:
  53. """One row of the class-targeting eligibility breakdown."""
  54. printer_id: int
  55. printer_name: str
  56. ok: bool
  57. issues: tuple[EligibilityIssue, ...]
  58. @dataclass(frozen=True)
  59. class EligibilityReport:
  60. ok: bool
  61. target_kind: Literal["specific_printer", "printer_class"]
  62. target_printer_id: int | None
  63. target_printer_name: str | None
  64. target_model_class: str | None
  65. issues: tuple[EligibilityIssue, ...]
  66. printer_reports: tuple[PerPrinterReport, ...] = ()
  67. # This module's whole job is to predict what the dispatch matcher will do, so
  68. # it reads type equivalence from the same table the matcher does rather than
  69. # keeping a copy. The copy it used to keep had drifted into disagreeing in both
  70. # directions — it aliased "PLA Basic" to "PLA" where the matcher does not, so a
  71. # job could pass here and then fail on type; and it lacked the PA12-CF/PAHT-CF
  72. # grouping the matcher has, so a job the matcher handles fine was flagged.
  73. _canonical = canonical_filament_type
  74. def _normalise_colour(colour: str | None) -> str:
  75. if not colour:
  76. return ""
  77. return colour.replace("#", "").lower()[:6]
  78. def _ams_slots(raw_data: dict) -> list[tuple[str, str]]:
  79. """Flatten AMS + external spool into ``[(type, colour_hex6), ...]`` in slot
  80. order. Uses the same field shape as print_scheduler._check_required_filaments.
  81. """
  82. out: list[tuple[str, str]] = []
  83. for ams_unit in raw_data.get("ams") or []:
  84. for tray in ams_unit.get("tray") or []:
  85. tray_type = tray.get("tray_type") or ""
  86. tray_colour = tray.get("tray_color") or ""
  87. out.append((_canonical(tray_type), _normalise_colour(tray_colour)))
  88. for vt in raw_data.get("vt_tray") or []:
  89. vt_type = vt.get("tray_type") or ""
  90. vt_colour = vt.get("tray_color") or ""
  91. out.append((_canonical(vt_type), _normalise_colour(vt_colour)))
  92. return out
  93. async def _expected_filament(
  94. db: AsyncSession,
  95. source: str,
  96. preset_id: str,
  97. ) -> tuple[str | None, str | None]:
  98. """Return ``(canonical_type, normalised_colour)`` for a pipeline filament
  99. slot's PresetRef, or ``(None, None)`` when the preset can't be resolved
  100. statically (cloud / orca_cloud / standard — read at slice time, not here).
  101. """
  102. if source != "local":
  103. # Cloud / orca_cloud / standard: surface as ``filament_unverified``
  104. # in the report, the matcher decides.
  105. return (None, None)
  106. try:
  107. local_id = int(preset_id)
  108. except (TypeError, ValueError):
  109. return (None, None)
  110. row = (await db.execute(select(LocalPreset).where(LocalPreset.id == local_id))).scalar_one_or_none()
  111. if row is None:
  112. return (None, None)
  113. return (_canonical(row.filament_type or ""), _normalise_colour(row.default_filament_colour))
  114. async def _check_one_printer(
  115. db: AsyncSession,
  116. pipeline: SlicerPipeline,
  117. printer: Printer,
  118. printer_raw_status: dict | None,
  119. ) -> tuple[bool, tuple[EligibilityIssue, ...]]:
  120. """Run the per-printer eligibility checks. Returns ``(ok, issues)`` so the
  121. caller can flatten them into either a single-printer or class-targeting
  122. report. Pulled out of the original entry function so PR C's class branch
  123. can reuse it for each candidate printer."""
  124. issues: list[EligibilityIssue] = []
  125. if not printer.is_active:
  126. issues.append(EligibilityIssue(kind="printer_disabled"))
  127. if not printer_raw_status or not printer_raw_status.get("connected"):
  128. issues.append(EligibilityIssue(kind="printer_offline"))
  129. return (not issues, tuple(issues))
  130. try:
  131. filament_refs = json.loads(pipeline.filament_presets_json or "[]")
  132. except (json.JSONDecodeError, TypeError):
  133. filament_refs = []
  134. ams_slots = _ams_slots(printer_raw_status.get("raw_data") or {})
  135. for slot_index, ref in enumerate(filament_refs):
  136. if not isinstance(ref, dict):
  137. continue
  138. source = ref.get("source", "")
  139. preset_id = ref.get("id", "")
  140. expected_type, expected_colour = await _expected_filament(db, source, str(preset_id))
  141. if expected_type is None:
  142. issues.append(
  143. EligibilityIssue(
  144. kind="filament_unverified",
  145. slot_index=slot_index,
  146. expected=f"{source}:{preset_id}",
  147. )
  148. )
  149. continue
  150. if slot_index >= len(ams_slots):
  151. issues.append(
  152. EligibilityIssue(
  153. kind="ams_slot_missing",
  154. slot_index=slot_index,
  155. expected=expected_type,
  156. )
  157. )
  158. continue
  159. actual_type, actual_colour = ams_slots[slot_index]
  160. if expected_type and actual_type and expected_type != actual_type:
  161. issues.append(
  162. EligibilityIssue(
  163. kind="filament_type_mismatch",
  164. slot_index=slot_index,
  165. expected=expected_type,
  166. actual=actual_type or "(empty)",
  167. )
  168. )
  169. continue
  170. if expected_colour and actual_colour and expected_colour != actual_colour:
  171. issues.append(
  172. EligibilityIssue(
  173. kind="filament_color_mismatch",
  174. slot_index=slot_index,
  175. expected=expected_colour,
  176. actual=actual_colour,
  177. )
  178. )
  179. # ``filament_unverified`` is informational — doesn't flip ok=False.
  180. blocking_issues = [i for i in issues if i.kind != "filament_unverified"]
  181. return (not blocking_issues, tuple(issues))
  182. async def check_pipeline_eligibility(
  183. db: AsyncSession,
  184. pipeline: SlicerPipeline,
  185. printer_raw_status: dict | None = None,
  186. *,
  187. status_lookup: object = None,
  188. ) -> EligibilityReport:
  189. """Build the eligibility report.
  190. Two calling shapes, chosen by ``pipeline.target_kind``:
  191. - ``specific_printer``: ``printer_raw_status`` carries the live
  192. ``PrinterState`` dict (``connected`` + ``raw_data``) for the pinned
  193. target_printer_id. PR B signature, preserved.
  194. - ``printer_class``: ``status_lookup`` is a callable
  195. ``(printer_id) -> dict | None`` that the matcher calls for each
  196. printer whose model matches ``pipeline.target_model_class``.
  197. """
  198. # PR A pipelines default target_kind to 'printer_class' but PR B and
  199. # earlier UI only let users pin a specific_printer; treat
  200. # ``target_printer_id is not None`` as the source of truth for the
  201. # specific-printer path until the editor exposes target_kind explicitly.
  202. if pipeline.target_printer_id is not None or pipeline.target_kind == "specific_printer":
  203. # Specific-printer branch (PR B parity).
  204. if pipeline.target_printer_id is None:
  205. return EligibilityReport(
  206. ok=False,
  207. target_kind="specific_printer",
  208. target_printer_id=None,
  209. target_printer_name=None,
  210. target_model_class=None,
  211. issues=(EligibilityIssue(kind="printer_not_set"),),
  212. )
  213. printer = (
  214. await db.execute(select(Printer).where(Printer.id == pipeline.target_printer_id))
  215. ).scalar_one_or_none()
  216. if printer is None:
  217. return EligibilityReport(
  218. ok=False,
  219. target_kind="specific_printer",
  220. target_printer_id=pipeline.target_printer_id,
  221. target_printer_name=None,
  222. target_model_class=None,
  223. issues=(EligibilityIssue(kind="printer_not_found"),),
  224. )
  225. ok, issues = await _check_one_printer(db, pipeline, printer, printer_raw_status)
  226. return EligibilityReport(
  227. ok=ok,
  228. target_kind="specific_printer",
  229. target_printer_id=printer.id,
  230. target_printer_name=printer.name,
  231. target_model_class=None,
  232. issues=issues,
  233. )
  234. # Class-targeting branch (PR C).
  235. if not pipeline.target_model_class:
  236. return EligibilityReport(
  237. ok=False,
  238. target_kind="printer_class",
  239. target_printer_id=None,
  240. target_printer_name=None,
  241. target_model_class=None,
  242. issues=(EligibilityIssue(kind="class_not_set"),),
  243. )
  244. candidates = (await db.execute(select(Printer).where(Printer.model == pipeline.target_model_class))).scalars().all()
  245. if not candidates:
  246. return EligibilityReport(
  247. ok=False,
  248. target_kind="printer_class",
  249. target_printer_id=None,
  250. target_printer_name=None,
  251. target_model_class=pipeline.target_model_class,
  252. issues=(
  253. EligibilityIssue(
  254. kind="no_class_matches",
  255. expected=pipeline.target_model_class,
  256. ),
  257. ),
  258. )
  259. reports: list[PerPrinterReport] = []
  260. if status_lookup is None:
  261. # Treat all printers as offline when no lookup was provided — keeps
  262. # the matcher pure-ish for unit tests.
  263. for printer in candidates:
  264. ok, issues = await _check_one_printer(db, pipeline, printer, None)
  265. reports.append(
  266. PerPrinterReport(
  267. printer_id=printer.id,
  268. printer_name=printer.name,
  269. ok=ok,
  270. issues=issues,
  271. )
  272. )
  273. else:
  274. for printer in candidates:
  275. raw = status_lookup(printer.id)
  276. ok, issues = await _check_one_printer(db, pipeline, printer, raw)
  277. reports.append(
  278. PerPrinterReport(
  279. printer_id=printer.id,
  280. printer_name=printer.name,
  281. ok=ok,
  282. issues=issues,
  283. )
  284. )
  285. any_ok = any(r.ok for r in reports)
  286. return EligibilityReport(
  287. ok=any_ok,
  288. target_kind="printer_class",
  289. target_printer_id=None,
  290. target_printer_name=None,
  291. target_model_class=pipeline.target_model_class,
  292. issues=(),
  293. printer_reports=tuple(reports),
  294. )