diff --git a/nottcontrol/camera/macie/h2rg_gui.py b/nottcontrol/camera/macie/h2rg_gui.py index f4fc4bbf..2968cdc8 100644 --- a/nottcontrol/camera/macie/h2rg_gui.py +++ b/nottcontrol/camera/macie/h2rg_gui.py @@ -1179,6 +1179,25 @@ def acquire_archive_science_params(ctx: dict) -> dict: } +def science_image_for_archive( + data, + header: dict | None, + params: dict, +): + """Reduce a raw ramp cube with the acquire-time snapshot, not live GUI fields. + + ``_load_fits_from_path`` uses the current CDS/Fowler combo. The GUI is + re-enabled as soon as ZMQ returns, so that combo can change while FITS + are still being collected. Science products must ignore that change. + """ + return science_image_from_cube( + data, + header, + reduction=str(params.get("ramp_mode") or "CDS"), + fowler_pairs=int(params.get("fowler_pairs") or 2), + ) + + def path_is_directory(path: Path, timeout_s: float = FITS_DIR_CHECK_TIMEOUT_S) -> bool: result: list[bool | None] = [None] @@ -4073,6 +4092,14 @@ def _finish_acquire_archive(self, ctx: dict) -> None: if frame is not None and preview_path is not None: science_paths: list[Path] = [] params = acquire_archive_science_params(ctx) + # Display load uses the live combo; re-reduce from the raw cube + # so the last-ramp image matches the acquire-time snapshot. + if self._raw_fits_cube is not None: + frame = science_image_for_archive( + self._raw_fits_cube, + self._raw_fits_header, + params, + ) cards = self._acquisition_fits_header_cards( ramp_mode=params["ramp_mode"], tint_ms=params["tint_ms"], @@ -4082,28 +4109,17 @@ def _finish_acquire_archive(self, ctx: dict) -> None: # Always stamp DETMODE / cryo cards on the ramp archive, # including when science FITS writes are disabled. self._apply_cryo_temps_to_ramp(ramp_path, cards) - if ( - ramp_path.name == preview_path.name - and self._raw_fits_header is not None - ): - science_path = self._save_science_fits( - frame, - ramp_path, - reduction=params["ramp_mode"], - fowler_pairs=params["fowler_pairs"], - tint_ms=params["tint_ms"], - keep_files=params["keep_files"], - report=params["exposure_report"], - ) - else: - science_path = self._save_science_fits_from_ramp( - ramp_path, - reduction=params["ramp_mode"], - fowler_pairs=params["fowler_pairs"], - tint_ms=params["tint_ms"], - keep_files=params["keep_files"], - report=params["exposure_report"], - ) + # Reduce every ramp from disk with the snapshot. The last + # ramp used to reuse the display frame, which followed the + # live CDS/Fowler combo after ZMQ returned. + science_path = self._save_science_fits_from_ramp( + ramp_path, + reduction=params["ramp_mode"], + fowler_pairs=params["fowler_pairs"], + tint_ms=params["tint_ms"], + keep_files=params["keep_files"], + report=params["exposure_report"], + ) if science_path is not None: science_paths.append(science_path) preview_science = ( diff --git a/nottcontrol/camera/macie/test_h2rg_fits_helpers.py b/nottcontrol/camera/macie/test_h2rg_fits_helpers.py index 711de894..618163f8 100644 --- a/nottcontrol/camera/macie/test_h2rg_fits_helpers.py +++ b/nottcontrol/camera/macie/test_h2rg_fits_helpers.py @@ -19,6 +19,7 @@ _centered_window, _channel_window, acquire_archive_science_params, + science_image_for_archive, central_value_median, fits_basename, fits_frame_number_label, @@ -448,6 +449,34 @@ def test_snapshot_is_not_the_live_gui_mode(self) -> None: self.assertNotEqual(params["ramp_mode"], live_gui["ramp_mode"]) self.assertNotEqual(params["keep_files"], live_gui["keep_files"]) + def test_last_ramp_science_uses_snapshot_not_live_combo(self) -> None: + """Display load follows the live combo; archive must re-reduce as CDS. + + Trigger: Acquire CDS, GUI re-enables when ZMQ returns, operator + switches to Fowler before the last ramp is written as science FITS. + """ + from nottcontrol.camera.macie.fits_science import science_image_from_cube + + cube = numpy.array( + [ + [[0.0, 0.0], [0.0, 0.0]], + [[10.0, 20.0], [30.0, 40.0]], + [[1.0, 2.0], [3.0, 4.0]], + [[11.0, 22.0], [33.0, 44.0]], + ], + dtype=numpy.float32, + ) + header = {"NAXIS": 3, "NAXIS3": 4} + params = acquire_archive_science_params( + {"ramp_mode": "CDS", "fowler_pairs": 2} + ) + live = science_image_from_cube( + cube, header, reduction="Fowler", fowler_pairs=2 + ) + archived = science_image_for_archive(cube, header, params) + numpy.testing.assert_allclose(archived, [[11.0, 22.0], [33.0, 44.0]]) + self.assertFalse(numpy.allclose(live, archived)) + def test_invalid_pairs_and_missing_mode_fall_back(self) -> None: params = acquire_archive_science_params( {"fowler_pairs": "x", "tint_ms": "bad"}