slot_kprofile.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  1. """Find the stored K-profile for an AMS slot on a particular nozzle.
  2. K-profiles are per-nozzle: Bambuddy already keeps one row per
  3. ``(spool, printer, extruder)`` in ``spool_k_profile`` (and the Spoolman mirror
  4. in ``spoolman_k_profile``), each with its own ``cali_idx`` and K value. On the
  5. maintainer's H2C, one black PLA reads 0.018 on the left hotend and 0.020 on the
  6. right, stored as calibration indices 16 and 15.
  7. A tray, by contrast, holds exactly **one** ``cali_idx``. So whenever a slot's
  8. nozzle changes — which on a Filament Track Switch machine happens every time an
  9. AMS is moved between the switch's two inlets — the stored counterpart for the
  10. new nozzle has to be looked up and re-selected. This module is that lookup.
  11. """
  12. from dataclasses import dataclass
  13. from sqlalchemy import select
  14. from sqlalchemy.ext.asyncio import AsyncSession
  15. from backend.app.models.spool import Spool
  16. from backend.app.models.spool_assignment import SpoolAssignment
  17. from backend.app.models.spool_k_profile import SpoolKProfile
  18. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  19. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  20. from backend.app.services.inventory_mode import spoolman_owns_assignments
  21. from backend.app.services.spool_filament_preset import resolve_spool_preset, resolve_spoolman_preset
  22. @dataclass(frozen=True)
  23. class SlotKProfile:
  24. """A stored calibration profile, flattened across the two inventory backends."""
  25. cali_idx: int | None
  26. k_value: float | None
  27. name: str | None
  28. extruder: int
  29. # The preset the profile was calibrated under. ``extrusion_cali_sel`` must
  30. # carry this rather than the tray's RFID value, or the firmware mislinks it.
  31. filament_id: str | None
  32. def _flow_applies(stored_flow: str | None, fitted_flow: str | None) -> bool:
  33. """``SlotNozzle.flow_matches`` for callers that hold only the two strings."""
  34. from backend.app.services.slot_nozzle import SlotNozzle
  35. return SlotNozzle(extruder=None, diameter="", flow=fitted_flow).flow_matches(stored_flow)
  36. async def find_slot_kprofile_for_extruder(
  37. db: AsyncSession,
  38. printer_id: int,
  39. ams_id: int,
  40. tray_id: int,
  41. extruder: int,
  42. nozzle_diameter: str,
  43. printer_model: str | None = None,
  44. flow: str | None = None,
  45. ) -> SlotKProfile | None:
  46. """Stored profile for whatever is in this slot, calibrated for ``extruder``.
  47. Returns None when the slot holds no known spool, or when that spool has no
  48. profile for this nozzle — an operator who calibrated only one side should
  49. keep the binding they set by hand rather than have it swapped for a guess.
  50. Only the table the current inventory mode uses is consulted. Before #2812
  51. the inactive one was emptied on every mode toggle, so reading the built-in
  52. table first and stopping on a hit was safe -- there could be nothing in it
  53. to stop on. Nothing is emptied now, and a leftover built-in row would
  54. otherwise shadow the Spoolman assignment for the slot, returning that
  55. spool's profile or, on the deliberate stop below, no profile at all. That
  56. is the symptom #1556 reported from the other direction.
  57. """
  58. spoolman_mode = await spoolman_owns_assignments(db)
  59. assignment = (
  60. None
  61. if spoolman_mode
  62. else (
  63. await db.execute(
  64. select(SpoolAssignment).where(
  65. SpoolAssignment.printer_id == printer_id,
  66. SpoolAssignment.ams_id == ams_id,
  67. SpoolAssignment.tray_id == tray_id,
  68. )
  69. )
  70. ).scalar_one_or_none()
  71. )
  72. if assignment is not None:
  73. profile = (
  74. (
  75. await db.execute(
  76. select(SpoolKProfile).where(
  77. SpoolKProfile.spool_id == assignment.spool_id,
  78. SpoolKProfile.printer_id == printer_id,
  79. SpoolKProfile.extruder == extruder,
  80. SpoolKProfile.nozzle_diameter == nozzle_diameter,
  81. )
  82. )
  83. )
  84. .scalars()
  85. .all()
  86. )
  87. # Flow is filtered here rather than in SQL: a stored NULL matches any
  88. # fitted nozzle (see SlotNozzle.flow_matches), which is not an equality
  89. # test and would need an OR IS NULL that reads worse than this.
  90. profile = next((p for p in profile if _flow_applies(p.nozzle_type, flow)), None)
  91. if profile is not None:
  92. spool = (await db.execute(select(Spool).where(Spool.id == assignment.spool_id))).scalar_one_or_none()
  93. # The preset this profile was calibrated under, through the
  94. # per-printer-model cascade: a spool can carry a different preset
  95. # per model, and extrusion_cali_sel has to name the one the printer
  96. # will actually see in the slot. Falls back to the spool's own
  97. # value when the caller cannot say which model this is.
  98. filament_id = spool.slicer_filament if spool else None
  99. if spool is not None and printer_model:
  100. filament_id, _ = await resolve_spool_preset(
  101. db,
  102. spool_id=spool.id,
  103. printer_model=printer_model,
  104. nozzle_diameter=nozzle_diameter,
  105. fallback_filament=spool.slicer_filament,
  106. fallback_name=spool.slicer_filament_name,
  107. )
  108. return SlotKProfile(
  109. cali_idx=profile.cali_idx,
  110. k_value=profile.k_value,
  111. name=profile.name,
  112. extruder=profile.extruder,
  113. filament_id=filament_id,
  114. )
  115. # A known spool with no profile for this nozzle is a deliberate stop:
  116. # falling through to Spoolman would answer for a different spool.
  117. return None
  118. if not spoolman_mode:
  119. return None
  120. sm_assignment = (
  121. await db.execute(
  122. select(SpoolmanSlotAssignment).where(
  123. SpoolmanSlotAssignment.printer_id == printer_id,
  124. SpoolmanSlotAssignment.ams_id == ams_id,
  125. SpoolmanSlotAssignment.tray_id == tray_id,
  126. )
  127. )
  128. ).scalar_one_or_none()
  129. if sm_assignment is None:
  130. return None
  131. sm_profile = (
  132. (
  133. await db.execute(
  134. select(SpoolmanKProfile).where(
  135. SpoolmanKProfile.spoolman_spool_id == sm_assignment.spoolman_spool_id,
  136. SpoolmanKProfile.printer_id == printer_id,
  137. SpoolmanKProfile.extruder == extruder,
  138. SpoolmanKProfile.nozzle_diameter == nozzle_diameter,
  139. )
  140. )
  141. )
  142. .scalars()
  143. .all()
  144. )
  145. sm_profile = next((p for p in sm_profile if _flow_applies(p.nozzle_type, flow)), None)
  146. if sm_profile is None:
  147. return None
  148. # A Spoolman K row carries no preset of its own, but the spool can still
  149. # have a per-model override stored locally -- that is the same table the
  150. # Spoolman assign path writes. Without a model to key on there is nothing
  151. # to resolve and the caller falls back to the tray's own tray_info_idx.
  152. sm_filament_id = None
  153. if printer_model:
  154. sm_filament_id, _ = await resolve_spoolman_preset(
  155. db,
  156. spoolman_spool_id=sm_assignment.spoolman_spool_id,
  157. printer_model=printer_model,
  158. nozzle_diameter=nozzle_diameter,
  159. fallback_filament=None,
  160. fallback_name=None,
  161. )
  162. return SlotKProfile(
  163. cali_idx=sm_profile.cali_idx,
  164. k_value=sm_profile.k_value,
  165. name=sm_profile.name,
  166. extruder=sm_profile.extruder,
  167. filament_id=sm_filament_id,
  168. )