Browse Source

Read the bed temperature from the plate the project is sliced for (issue #2989)

    BambuStudio writes no bed_temperature key. It stores a per-filament array per
    plate type and names the fitted plate in curr_bed_type; bed_temperature is the
    Orca/Prusa spelling, so the lookup matched nothing and every archive from a
    Bambu slice stored NULL - 0 of 455 real 3MFs resolved. Preheat then fell back
    to the keep-warm bed temperature on jobs that had one all along.

    Plate names and the plate-to-key mapping are BambuStudio's own get_bed_temp_key
    and get_bed_temp_1st_layer_key. First-layer value preferred, highest entry in
    the per-filament array taken, an all-zero array left unrecorded.
maziggy 1 tuần trước cách đây
mục cha
commit
ff9b956156

+ 61 - 8
backend/app/services/archive.py

@@ -25,6 +25,52 @@ from backend.app.utils.safe_path import PathTraversalError, safe_join_under
 logger = logging.getLogger(__name__)
 
 
+# Bed temperature is not one key in a BambuStudio project. Every plate type has
+# its own per-filament array, and the plate actually fitted is named separately
+# in ``curr_bed_type`` -- so reading a bed temperature means picking the array
+# the plate points at. Keys and mapping are BambuStudio's own
+# ``get_bed_temp_1st_layer_key`` / ``get_bed_temp_key`` (PrintConfig.hpp), and
+# the plate names are the ``curr_bed_type`` enum values (PrintConfig.cpp).
+# First-layer temperature first: that is what the printer heats to before the
+# print starts, which is what preheat is trying to reach.
+#
+# ``Default Plate`` is deliberately absent -- BambuStudio maps it to no key at
+# all, so there is nothing to read and guessing a plate would invent a bed
+# temperature the slice never specified.
+_BED_TEMP_KEYS: dict[str, tuple[str, str]] = {
+    "Cool Plate": ("cool_plate_temp_initial_layer", "cool_plate_temp"),
+    "Engineering Plate": ("eng_plate_temp_initial_layer", "eng_plate_temp"),
+    "High Temp Plate": ("hot_plate_temp_initial_layer", "hot_plate_temp"),
+    "Textured PEI Plate": ("textured_plate_temp_initial_layer", "textured_plate_temp"),
+    "Supertack Plate": ("supertack_plate_temp_initial_layer", "supertack_plate_temp"),
+}
+
+# Fallback for a config that names no plate: the Orca/PrusaSlicer spelling,
+# which is a single value rather than a per-plate array.
+_GENERIC_BED_TEMP_KEYS = ("bed_temperature_initial_layer", "bed_temperature")
+
+
+def _plate_temperature(val) -> int | None:
+    """Bed temperature from one plate-temperature entry, or None.
+
+    The plate arrays carry one entry per filament in the project, and a 0 means
+    that filament cannot print on this plate. The bed only has one temperature,
+    so the print runs at the highest its filaments ask for -- taking entry 0 the
+    way the neighbouring scalar settings do would store a 0 for any project
+    whose first filament is not one this plate is heated for.
+    """
+    values = val if isinstance(val, list) else [val]
+    temps = []
+    for entry in values:
+        if isinstance(entry, bool) or not isinstance(entry, (int, float, str)):
+            continue
+        try:
+            temps.append(int(float(entry)))
+        except (TypeError, ValueError):
+            continue
+    return max(temps) if temps else None
+
+
 def _copy_and_fsync(src: Path, dst: Path, chunk_size: int = 1024 * 1024) -> None:
     """Copy src to dst with an explicit chunked read/write and fsync the dst.
 
@@ -498,14 +544,21 @@ class ThreeMFParser:
                 elif isinstance(val, (int, float, str)):
                     self.metadata["nozzle_diameter"] = float(val)
 
-            # Bed temperature - first layer or regular
-            for key in ["bed_temperature_initial_layer", "bed_temperature"]:
-                if key in data:
-                    val = data[key]
-                    if isinstance(val, list) and val:
-                        self.metadata["bed_temperature"] = int(float(val[0]))
-                    elif isinstance(val, (int, float, str)):
-                        self.metadata["bed_temperature"] = int(float(val))
+            # Bed temperature, for the plate this project is sliced for. This
+            # used to look for `bed_temperature` alone, a key BambuStudio does
+            # not write -- so every archive from a Bambu slice stored NULL, and
+            # preheat fell back to a configured bed temperature on every job
+            # (#2989). Orca-exported 3MFs keep working through the generic keys.
+            bed_type = str(data.get("curr_bed_type") or "").strip()
+            for key in (*_BED_TEMP_KEYS.get(bed_type, ()), *_GENERIC_BED_TEMP_KEYS):
+                if key not in data:
+                    continue
+                temperature = _plate_temperature(data[key])
+                # A plate array of all zeros means no filament in the project
+                # prints on this plate, which is not a bed temperature -- keep
+                # looking rather than recording a 0 that reads as "cold bed".
+                if temperature:
+                    self.metadata["bed_temperature"] = temperature
                     break
 
             # Nozzle temperature

+ 164 - 0
backend/tests/unit/test_archive_bed_temperature_2989.py

@@ -0,0 +1,164 @@
+"""BambuStudio does not write a ``bed_temperature`` key, so we never read one.
+
+The 3MF stores one bed-temperature array per plate type -- ``cool_plate_temp``,
+``eng_plate_temp``, ``hot_plate_temp``, ``textured_plate_temp``,
+``supertack_plate_temp`` -- and names the plate the project is sliced for in
+``curr_bed_type``. ``bed_temperature`` is the Orca/PrusaSlicer spelling. The
+extractor looked only for that spelling, so ``PrintArchive.bed_temperature`` was
+NULL for every archive produced from a Bambu slice: measured at 0 of 455 real
+3MFs on a live install, against 455 of 455 once the plate keys are read.
+
+That NULL is not cosmetic. Preheat reads ``archive.bed_temperature`` to decide
+what to heat the bed to, and with nothing there it falls back to the configured
+chamber-heating bed temperature (90 by default) on jobs that never wanted it --
+which is how #2989's PLA print came to sit behind a 90°C bed.
+
+Key names and the plate mapping are BambuStudio's own ``get_bed_temp_key`` /
+``get_bed_temp_1st_layer_key`` (PrintConfig.hpp); the plate names are the
+``curr_bed_type`` enum values (PrintConfig.cpp).
+"""
+
+import pytest
+
+from backend.app.services.archive import ThreeMFParser
+
+
+def _extract(config: dict) -> int | None:
+    parser = ThreeMFParser.__new__(ThreeMFParser)
+    parser.metadata = {}
+    parser._extract_print_settings(config)
+    return parser.metadata.get("bed_temperature")
+
+
+class TestThePlateTheProjectIsSlicedFor:
+    @pytest.mark.parametrize(
+        "bed_type,key",
+        [
+            ("Cool Plate", "cool_plate_temp"),
+            ("Engineering Plate", "eng_plate_temp"),
+            ("High Temp Plate", "hot_plate_temp"),
+            ("Textured PEI Plate", "textured_plate_temp"),
+            ("Supertack Plate", "supertack_plate_temp"),
+        ],
+    )
+    def test_every_plate_type_reads_its_own_array(self, bed_type, key):
+        """All five plates BambuStudio can name, each with its own key."""
+        assert _extract({"curr_bed_type": bed_type, key: ["65", "65"]}) == 65
+
+    def test_the_fitted_plate_wins_over_the_others(self):
+        """The real failure mode this replaces: reading whichever key happened
+        to be present. A Textured slice must not take the cool plate's 0."""
+        config = {
+            "curr_bed_type": "Textured PEI Plate",
+            "cool_plate_temp": ["0", "0", "0"],
+            "eng_plate_temp": ["90", "90", "90"],
+            "hot_plate_temp": ["90", "90", "90"],
+            "textured_plate_temp": ["55", "55", "55"],
+            "supertack_plate_temp": ["0", "0", "0"],
+        }
+        assert _extract(config) == 55
+
+    def test_the_first_layer_value_is_preferred(self):
+        """It is what the printer heats to before the print starts, which is
+        the number preheat is trying to reach."""
+        config = {
+            "curr_bed_type": "Textured PEI Plate",
+            "textured_plate_temp_initial_layer": ["65"],
+            "textured_plate_temp": ["60"],
+        }
+        assert _extract(config) == 65
+
+    def test_the_regular_value_when_there_is_no_first_layer_one(self):
+        config = {"curr_bed_type": "Textured PEI Plate", "textured_plate_temp": ["60"]}
+        assert _extract(config) == 60
+
+
+class TestThePerFilamentArray:
+    def test_the_highest_entry_wins(self):
+        """One bed, several filaments: the print runs at the highest its
+        filaments ask for."""
+        config = {"curr_bed_type": "High Temp Plate", "hot_plate_temp": ["55", "100", "90"]}
+        assert _extract(config) == 100
+
+    def test_a_leading_zero_does_not_win(self):
+        """0 means that filament cannot print on this plate. Taking entry 0 --
+        what the neighbouring scalar settings do -- would store a cold bed for
+        any project whose first filament is not one this plate is heated for."""
+        config = {"curr_bed_type": "High Temp Plate", "hot_plate_temp": ["0", "90"]}
+        assert _extract(config) == 90
+
+    def test_a_scalar_rather_than_an_array(self):
+        assert _extract({"curr_bed_type": "Cool Plate", "cool_plate_temp": 35}) == 35
+
+    def test_numeric_strings_and_floats(self):
+        assert _extract({"curr_bed_type": "Cool Plate", "cool_plate_temp": ["35.0"]}) == 35
+
+
+class TestWhatItRefusesToInvent:
+    def test_an_all_zero_plate_array_is_not_a_bed_temperature(self):
+        """No filament in the project prints on this plate. Recording the 0
+        would read downstream as a deliberate cold bed."""
+        config = {"curr_bed_type": "Cool Plate", "cool_plate_temp": ["0", "0"]}
+        assert _extract(config) is None
+
+    def test_a_plate_bambustudio_maps_to_no_key(self):
+        """``Default Plate`` has no temperature array of its own, and guessing
+        another plate's would invent a temperature the slice never specified."""
+        config = {"curr_bed_type": "Default Plate", "hot_plate_temp": ["90"]}
+        assert _extract(config) is None
+
+    def test_a_plate_name_we_do_not_know(self):
+        """A plate a future BambuStudio adds must not silently read another
+        plate's array."""
+        config = {"curr_bed_type": "Cryo Plate", "textured_plate_temp": ["55"]}
+        assert _extract(config) is None
+
+    def test_a_config_with_no_bed_temperature_at_all(self):
+        assert _extract({"curr_bed_type": "Textured PEI Plate", "layer_height": ["0.2"]}) is None
+
+    def test_junk_entries_are_stepped_over(self):
+        config = {"curr_bed_type": "Cool Plate", "cool_plate_temp": [None, {}, "abc", "35"]}
+        assert _extract(config) == 35
+
+
+class TestOrcaExportsStillWork:
+    """The generic spelling is the fallback, not the primary -- Orca-exported
+    3MFs and anything else Prusa-shaped keep parsing exactly as before."""
+
+    def test_the_generic_first_layer_key(self):
+        assert _extract({"bed_temperature_initial_layer": ["60"]}) == 60
+
+    def test_the_generic_key(self):
+        assert _extract({"bed_temperature": 60}) == 60
+
+    def test_the_plate_array_is_preferred_when_both_are_present(self):
+        config = {
+            "curr_bed_type": "Textured PEI Plate",
+            "textured_plate_temp": ["55"],
+            "bed_temperature": ["90"],
+        }
+        assert _extract(config) == 55
+
+
+class TestTheNeighbouringSettingsAreUntouched:
+    """The same method extracts three other values; none of them changed."""
+
+    def test_nozzle_temperature_layer_height_and_diameter(self):
+        parser = ThreeMFParser.__new__(ThreeMFParser)
+        parser.metadata = {}
+        parser._extract_print_settings(
+            {
+                "curr_bed_type": "Textured PEI Plate",
+                "textured_plate_temp": ["55"],
+                "nozzle_temperature_initial_layer": ["220", "220"],
+                "layer_height": ["0.2"],
+                "nozzle_diameter": ["0.4"],
+            }
+        )
+        assert parser.metadata == {
+            "bed_type": "Textured PEI Plate",
+            "layer_height": 0.2,
+            "nozzle_diameter": 0.4,
+            "bed_temperature": 55,
+            "nozzle_temperature": 220,
+        }