Parcourir la source

Fix AMS auto-matching to use tray_info_idx from 3MF files

When multiple AMS trays have the same filament type and color, Bambuddy
now uses the tray_info_idx attribute from the 3MF file to identify the
exact spool selected during slicing. This ensures the correct tray is
used rather than just picking the first match.

Matching priority: tray_info_idx > exact color > similar color > type-only

Closes #245
maziggy il y a 3 mois
Parent
commit
fe17a6905f

+ 163 - 0
backend/tests/unit/test_scheduler_ams_mapping.py

@@ -265,3 +265,166 @@ class TestMatchFilamentsToSlots:
 
         result = scheduler._match_filaments_to_slots(required, loaded)
         assert result == [254]
+
+    def test_match_by_tray_info_idx_priority(self, scheduler):
+        """tray_info_idx match should have highest priority over color match."""
+        required = [{"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": "GFA00"}]
+        loaded = [
+            {
+                "type": "PLA",
+                "color": "#000000",
+                "global_tray_id": 0,
+                "tray_info_idx": "GFB00",
+            },  # Same color, different spool
+            {
+                "type": "PLA",
+                "color": "#000000",
+                "global_tray_id": 1,
+                "tray_info_idx": "GFA00",
+            },  # Same color, exact spool
+            {
+                "type": "PLA",
+                "color": "#000000",
+                "global_tray_id": 2,
+                "tray_info_idx": "GFC00",
+            },  # Same color, different spool
+        ]
+
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [1]  # Should pick tray 1 (exact tray_info_idx match)
+
+    def test_match_by_tray_info_idx_with_different_colors(self, scheduler):
+        """tray_info_idx match should work even if colors differ slightly."""
+        required = [{"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": "P4d64437"}]
+        loaded = [
+            {"type": "PLA", "color": "#000000", "global_tray_id": 0, "tray_info_idx": ""},  # No idx
+            {
+                "type": "PLA",
+                "color": "#000010",
+                "global_tray_id": 3,
+                "tray_info_idx": "P4d64437",
+            },  # Exact spool (slightly different color reported)
+        ]
+
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [3]  # Should pick tray 3 (exact tray_info_idx match)
+
+    def test_match_fallback_to_color_when_no_tray_info_idx(self, scheduler):
+        """Should fall back to color matching when tray_info_idx is empty."""
+        required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000", "tray_info_idx": ""}]
+        loaded = [
+            {"type": "PLA", "color": "#00FF00", "global_tray_id": 0, "tray_info_idx": "GFA00"},
+            {"type": "PLA", "color": "#FF0000", "global_tray_id": 1, "tray_info_idx": "GFB00"},  # Color match
+        ]
+
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [1]  # Should pick tray 1 (color match)
+
+    def test_match_fallback_to_color_when_no_matching_tray_info_idx(self, scheduler):
+        """Should fall back to color when tray_info_idx doesn't match any loaded spool."""
+        required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000", "tray_info_idx": "OLD_SPOOL"}]
+        loaded = [
+            {
+                "type": "PLA",
+                "color": "#FF0000",
+                "global_tray_id": 0,
+                "tray_info_idx": "NEW_SPOOL",
+            },  # Different idx but same color
+        ]
+
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [0]  # Should fall back to color match
+
+    def test_match_multiple_same_color_with_tray_info_idx(self, scheduler):
+        """Multiple identical filaments should be matched by tray_info_idx (H2D Pro scenario)."""
+        # This is the exact scenario from issue #245 - 3 black PLA spools
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": "GFA03"},  # Wants tray 3
+        ]
+        loaded = [
+            {"type": "PLA", "color": "#000000", "global_tray_id": 0, "tray_info_idx": "GFA00"},  # Tray 0
+            {"type": "PLA", "color": "#000000", "global_tray_id": 1, "tray_info_idx": "GFA01"},  # Tray 1
+            {"type": "PLA", "color": "#000000", "global_tray_id": 2, "tray_info_idx": "GFA02"},  # Tray 2
+            {
+                "type": "PLA",
+                "color": "#000000",
+                "global_tray_id": 3,
+                "tray_info_idx": "GFA03",
+            },  # Tray 3 - the one we want
+        ]
+
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [3]  # Should pick tray 3, not tray 0
+
+    def test_match_tray_info_idx_not_reused(self, scheduler):
+        """tray_info_idx matched trays should not be reused for other slots."""
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": "GFA00"},
+            {"slot_id": 2, "type": "PLA", "color": "#000000", "tray_info_idx": "GFA01"},
+        ]
+        loaded = [
+            {"type": "PLA", "color": "#000000", "global_tray_id": 0, "tray_info_idx": "GFA00"},
+            {"type": "PLA", "color": "#000000", "global_tray_id": 1, "tray_info_idx": "GFA01"},
+        ]
+
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [0, 1]  # Each slot gets its specific tray
+
+
+class TestBuildLoadedFilamentsTrayInfoIdx:
+    """Test tray_info_idx extraction in _build_loaded_filaments."""
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    def test_build_loaded_filaments_includes_tray_info_idx(self, scheduler):
+        """Should extract tray_info_idx from AMS trays."""
+
+        class MockStatus:
+            raw_data = {
+                "ams": [
+                    {
+                        "id": 0,
+                        "tray": [
+                            {"id": 0, "tray_type": "PLA", "tray_color": "000000", "tray_info_idx": "GFA00"},
+                            {"id": 1, "tray_type": "PLA", "tray_color": "000000", "tray_info_idx": "GFA01"},
+                        ],
+                    }
+                ]
+            }
+
+        result = scheduler._build_loaded_filaments(MockStatus())
+        assert len(result) == 2
+        assert result[0]["tray_info_idx"] == "GFA00"
+        assert result[1]["tray_info_idx"] == "GFA01"
+
+    def test_build_loaded_filaments_empty_tray_info_idx(self, scheduler):
+        """Missing tray_info_idx should default to empty string."""
+
+        class MockStatus:
+            raw_data = {
+                "ams": [
+                    {
+                        "id": 0,
+                        "tray": [
+                            {"id": 0, "tray_type": "PLA", "tray_color": "FF0000"},  # No tray_info_idx
+                        ],
+                    }
+                ]
+            }
+
+        result = scheduler._build_loaded_filaments(MockStatus())
+        assert len(result) == 1
+        assert result[0]["tray_info_idx"] == ""
+
+    def test_build_loaded_filaments_external_spool_tray_info_idx(self, scheduler):
+        """Should extract tray_info_idx from external spool."""
+
+        class MockStatus:
+            raw_data = {"vt_tray": {"tray_type": "TPU", "tray_color": "0000FF", "tray_info_idx": "P4d64437"}}
+
+        result = scheduler._build_loaded_filaments(MockStatus())
+        assert len(result) == 1
+        assert result[0]["tray_info_idx"] == "P4d64437"
+        assert result[0]["is_external"] is True

+ 349 - 0
frontend/src/__tests__/hooks/useFilamentMapping.test.ts

@@ -0,0 +1,349 @@
+/**
+ * Tests for the useFilamentMapping hook and helper functions.
+ *
+ * Tests the tray_info_idx matching logic that ensures the exact spool
+ * selected during slicing is used when multiple trays have identical filament.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+  buildLoadedFilaments,
+  computeAmsMapping,
+} from '../../hooks/useFilamentMapping';
+import type { PrinterStatus } from '../../api/client';
+
+// Helper to create a minimal printer status with AMS data
+function createPrinterStatus(ams: PrinterStatus['ams'], vt_tray?: PrinterStatus['vt_tray']): PrinterStatus {
+  return {
+    ams,
+    vt_tray,
+  } as PrinterStatus;
+}
+
+describe('buildLoadedFilaments', () => {
+  it('returns empty array for undefined status', () => {
+    const result = buildLoadedFilaments(undefined);
+    expect(result).toEqual([]);
+  });
+
+  it('extracts filaments from AMS units', () => {
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00' },
+          { id: 1, tray_type: 'PETG', tray_color: '00FF00', tray_info_idx: 'GFA01' },
+        ],
+      },
+    ]);
+
+    const result = buildLoadedFilaments(status);
+
+    expect(result).toHaveLength(2);
+    expect(result[0]).toMatchObject({
+      type: 'PLA',
+      color: '#FF0000',
+      amsId: 0,
+      trayId: 0,
+      globalTrayId: 0,
+      trayInfoIdx: 'GFA00',
+    });
+    expect(result[1]).toMatchObject({
+      type: 'PETG',
+      color: '#00FF00',
+      globalTrayId: 1,
+      trayInfoIdx: 'GFA01',
+    });
+  });
+
+  it('includes tray_info_idx from AMS trays', () => {
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'P4d64437' },
+        ],
+      },
+    ]);
+
+    const result = buildLoadedFilaments(status);
+
+    expect(result[0].trayInfoIdx).toBe('P4d64437');
+  });
+
+  it('handles missing tray_info_idx', () => {
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: 'FF0000' },  // No tray_info_idx
+        ],
+      },
+    ]);
+
+    const result = buildLoadedFilaments(status);
+
+    expect(result[0].trayInfoIdx).toBe('');
+  });
+
+  it('extracts external spool with tray_info_idx', () => {
+    const status = createPrinterStatus(
+      [],
+      { tray_type: 'TPU', tray_color: '0000FF', tray_info_idx: 'EXT001' }
+    );
+
+    const result = buildLoadedFilaments(status);
+
+    expect(result).toHaveLength(1);
+    expect(result[0]).toMatchObject({
+      type: 'TPU',
+      isExternal: true,
+      globalTrayId: 254,
+      trayInfoIdx: 'EXT001',
+    });
+  });
+
+  it('skips empty trays', () => {
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00' },
+          { id: 1, tray_type: '', tray_color: '' },  // Empty tray
+          { id: 2 },  // No tray_type
+        ],
+      },
+    ]);
+
+    const result = buildLoadedFilaments(status);
+
+    expect(result).toHaveLength(1);
+    expect(result[0].type).toBe('PLA');
+  });
+
+  it('marks AMS-HT units correctly', () => {
+    const status = createPrinterStatus([
+      {
+        id: 128,  // AMS-HT typically has high ID
+        tray: [
+          { id: 0, tray_type: 'PLA-CF', tray_color: '000000', tray_info_idx: 'HT001' },
+        ],  // Single tray = AMS-HT
+      },
+    ]);
+
+    const result = buildLoadedFilaments(status);
+
+    expect(result[0].isHt).toBe(true);
+    expect(result[0].globalTrayId).toBe(512);  // 128 * 4 + 0
+  });
+});
+
+describe('computeAmsMapping', () => {
+  it('returns undefined for empty filament requirements', () => {
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
+      },
+    ]);
+
+    expect(computeAmsMapping(undefined, status)).toBeUndefined();
+    expect(computeAmsMapping({ filaments: [] }, status)).toBeUndefined();
+  });
+
+  it('returns undefined when no filaments loaded', () => {
+    const reqs = {
+      filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],
+    };
+
+    expect(computeAmsMapping(reqs, undefined)).toBeUndefined();
+    expect(computeAmsMapping(reqs, createPrinterStatus([]))).toBeUndefined();
+  });
+
+  it('matches by tray_info_idx with highest priority', () => {
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 10, tray_info_idx: 'GFA01' },
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA00' },  // Same color, wrong idx
+          { id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA01' },  // Exact idx match
+          { id: 2, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA02' },  // Same color, wrong idx
+        ],
+      },
+    ]);
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([1]);  // Should pick tray 1, not tray 0
+  });
+
+  it('matches multiple identical filaments by tray_info_idx (H2D Pro scenario)', () => {
+    // This is the exact scenario from issue #245 - multiple black PLA spools
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 50, tray_info_idx: 'GFA03' },
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA00' },
+          { id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA01' },
+          { id: 2, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA02' },
+          { id: 3, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA03' },  // This one
+        ],
+      },
+    ]);
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([3]);  // Should pick tray 3, not tray 0
+  });
+
+  it('falls back to color match when tray_info_idx is empty', () => {
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, tray_info_idx: '' },
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: '00FF00', tray_info_idx: 'GFA00' },  // Wrong color
+          { id: 1, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA01' },  // Color match
+        ],
+      },
+    ]);
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([1]);
+  });
+
+  it('falls back to color match when tray_info_idx does not match', () => {
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, tray_info_idx: 'OLD_SPOOL' },
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'NEW_SPOOL' },  // Different idx, same color
+        ],
+      },
+    ]);
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([0]);  // Falls back to color match
+  });
+
+  it('matches by type only when color differs', () => {
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 },
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: '0000FF' },  // Same type, different color
+        ],
+      },
+    ]);
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([0]);  // Type-only match
+  });
+
+  it('returns -1 for unmatched slots', () => {
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'TPU', color: '#FF0000', used_grams: 10 },  // No TPU loaded
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: 'FF0000' },
+        ],
+      },
+    ]);
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([-1]);
+  });
+
+  it('avoids duplicate tray assignment', () => {
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 },
+        { slot_id: 2, type: 'PLA', color: '#FF0000', used_grams: 10 },  // Same requirements
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: 'FF0000' },  // Only one PLA
+        ],
+      },
+    ]);
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([0, -1]);  // First slot gets the match, second is unmatched
+  });
+
+  it('handles multi-slot mapping with tray_info_idx', () => {
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 10, tray_info_idx: 'GFA00' },
+        { slot_id: 2, type: 'PLA', color: '#000000', used_grams: 10, tray_info_idx: 'GFA02' },
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA00' },
+          { id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA01' },
+          { id: 2, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA02' },
+        ],
+      },
+    ]);
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([0, 2]);  // Each slot gets its specific tray
+  });
+
+  it('handles external spool matching', () => {
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'TPU', color: '#0000FF', used_grams: 10, tray_info_idx: 'EXT001' },
+      ],
+    };
+    const status = createPrinterStatus(
+      [],
+      { tray_type: 'TPU', tray_color: '0000FF', tray_info_idx: 'EXT001' }
+    );
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([254]);  // External spool global ID
+  });
+});