test_tray_split.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. """Pure-logic tests for the mid-print tray-split math (#1793).
  2. The helper lives in ``backend/app/utils/tray_split.py`` and is exercised
  3. by both inventory backends (``usage_tracker`` and ``spoolman_tracking``).
  4. These tests pin the algorithm so a change in one caller can't silently
  5. break the other — cross-inventory parity is a HARD RULE for this project.
  6. """
  7. from __future__ import annotations
  8. from backend.app.utils.tray_split import compute_tray_split_grams
  9. class TestComputeTraySplitGrams:
  10. """Segment-attribution algorithm — gcode preferred, linear fallback, equal split."""
  11. def test_empty_tray_changes_returns_empty(self):
  12. assert (
  13. compute_tray_split_grams(
  14. tray_changes=[],
  15. total_weight=100.0,
  16. slot_id=1,
  17. layer_usage=None,
  18. density=1.24,
  19. diameter=1.75,
  20. total_layers=200,
  21. last_layer_num=200,
  22. )
  23. == []
  24. )
  25. def test_single_segment_charges_everything_to_that_tray(self):
  26. segments = compute_tray_split_grams(
  27. tray_changes=[(0, 0)],
  28. total_weight=72.56,
  29. slot_id=1,
  30. layer_usage=None,
  31. density=1.24,
  32. diameter=1.75,
  33. total_layers=100,
  34. last_layer_num=100,
  35. )
  36. assert segments == [(0, 0, 72.56)]
  37. def test_two_segments_linear_split_by_layer_ratio(self):
  38. # Runout at layer 37 of 100 total; no gcode available → linear.
  39. # Segment 0 (tray 0, layers 0-37) = 100 * 37/100 = 37g
  40. # Segment 1 (tray 1, layers 37-end) = 100 - 37 = 63g (remainder)
  41. segments = compute_tray_split_grams(
  42. tray_changes=[(0, 0), (1, 37)],
  43. total_weight=100.0,
  44. slot_id=1,
  45. layer_usage=None,
  46. density=1.24,
  47. diameter=1.75,
  48. total_layers=100,
  49. last_layer_num=100,
  50. )
  51. assert segments == [(0, 0, 37.0), (1, 1, 63.0)]
  52. def test_two_segments_gcode_preferred_over_linear(self):
  53. # layer_usage stores mm of filament extruded per (layer, filament_id).
  54. # Values are cumulative-per-key inside get_cumulative_usage_at_layer.
  55. # 20 layers, filament_id=0 (slot_id=1 → filament_id 0):
  56. # layer 10 → 100mm cumulative
  57. # layer 20 → 300mm cumulative
  58. # tray change at layer 10 → seg 0 spans layers 0-10 (mm 0 → 100),
  59. # seg 1 spans layers 10-end.
  60. # mm_to_grams(100, 1.75, 1.24) ≈ 0.298g; last segment absorbs the rest.
  61. layer_usage = {
  62. 5: {0: 50.0},
  63. 10: {0: 100.0},
  64. 15: {0: 200.0},
  65. 20: {0: 300.0},
  66. }
  67. segments = compute_tray_split_grams(
  68. tray_changes=[(0, 0), (1, 10)],
  69. total_weight=1.0, # sentinel — we assert the seg1 remainder
  70. slot_id=1,
  71. layer_usage=layer_usage,
  72. density=1.24,
  73. diameter=1.75,
  74. total_layers=20,
  75. last_layer_num=20,
  76. )
  77. # Seg 0 charged from gcode delta (mm 0 → 100).
  78. # Seg 1 gets total_weight - seg0 as remainder.
  79. assert segments[0][0] == 0
  80. assert segments[0][1] == 0 # tray 0
  81. assert segments[0][2] > 0 # non-zero gcode contribution
  82. assert segments[1][0] == 1
  83. assert segments[1][1] == 1 # tray 1
  84. # Sum equals the input total by construction (last segment absorbs).
  85. assert round(segments[0][2] + segments[1][2], 6) == 1.0
  86. def test_three_segments_last_absorbs_rounding_drift(self):
  87. # 100g over three segments at layers 30 and 60 of 90; linear fallback.
  88. # Seg 0: 100 * 30/90 = 33.3333...
  89. # Seg 1: 100 * 30/90 = 33.3333...
  90. # Seg 2: remainder = 100 - 66.6666... = 33.3333... — exact by construction
  91. segments = compute_tray_split_grams(
  92. tray_changes=[(0, 0), (1, 30), (2, 60)],
  93. total_weight=100.0,
  94. slot_id=1,
  95. layer_usage=None,
  96. density=1.24,
  97. diameter=1.75,
  98. total_layers=90,
  99. last_layer_num=90,
  100. )
  101. assert len(segments) == 3
  102. assert round(sum(g for _, _, g in segments), 6) == 100.0
  103. assert segments[0][1] == 0
  104. assert segments[1][1] == 1
  105. assert segments[2][1] == 2
  106. def test_no_layer_info_at_all_falls_to_equal_split(self):
  107. # Denominator 0 → last-resort equal-split; last segment absorbs remainder.
  108. segments = compute_tray_split_grams(
  109. tray_changes=[(0, 0), (1, 50)],
  110. total_weight=90.0,
  111. slot_id=1,
  112. layer_usage=None,
  113. density=1.24,
  114. diameter=1.75,
  115. total_layers=0,
  116. last_layer_num=0,
  117. )
  118. # 90g / 2 = 45g each; sum still 90 by remainder mechanic.
  119. assert segments == [(0, 0, 45.0), (1, 1, 45.0)]
  120. def test_last_layer_num_used_when_total_layers_zero(self):
  121. # P1S firmware-reset scenario: total_layers=0 at completion, but the
  122. # captured last_layer_num survives. Should give the same linear split
  123. # as if total_layers had held its value (#1771 cascade).
  124. segments_captured = compute_tray_split_grams(
  125. tray_changes=[(0, 0), (1, 30)],
  126. total_weight=100.0,
  127. slot_id=1,
  128. layer_usage=None,
  129. density=1.24,
  130. diameter=1.75,
  131. total_layers=0,
  132. last_layer_num=100,
  133. )
  134. segments_normal = compute_tray_split_grams(
  135. tray_changes=[(0, 0), (1, 30)],
  136. total_weight=100.0,
  137. slot_id=1,
  138. layer_usage=None,
  139. density=1.24,
  140. diameter=1.75,
  141. total_layers=100,
  142. last_layer_num=100,
  143. )
  144. assert segments_captured == segments_normal
  145. def test_slot_id_maps_to_zero_based_filament_id_in_gcode(self):
  146. # slot_id 2 → filament_id 1 in layer_usage. If we mistakenly used
  147. # slot_id as-is, we'd read filament_id 2 which is absent → 0mm delta
  148. # → seg 0 gets 0, seg 1 (remainder) gets the whole total. Guard
  149. # against that regression.
  150. layer_usage = {
  151. 5: {0: 0.0, 1: 40.0},
  152. 10: {0: 0.0, 1: 80.0},
  153. 20: {0: 0.0, 1: 160.0},
  154. }
  155. segments = compute_tray_split_grams(
  156. tray_changes=[(0, 0), (1, 10)],
  157. total_weight=1.0,
  158. slot_id=2,
  159. layer_usage=layer_usage,
  160. density=1.24,
  161. diameter=1.75,
  162. total_layers=20,
  163. last_layer_num=20,
  164. )
  165. # Seg 0 gcode delta on filament_id=1 is non-zero → not 0g.
  166. assert segments[0][2] > 0