diff --git a/backend/app/services/archive.py b/backend/app/services/archive.py index a1c65f966..e6a61c6c1 100644 --- a/backend/app/services/archive.py +++ b/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 diff --git a/backend/tests/unit/test_archive_bed_temperature_2989.py b/backend/tests/unit/test_archive_bed_temperature_2989.py new file mode 100644 index 000000000..73b63a874 --- /dev/null +++ b/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, + }