From 7c1d917c1459670ebc23b4405012bed0cf73ea0b Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Tue, 15 Sep 2026 00:48:24 -0700 Subject: [PATCH] feat(ome-tiff): opt-in per-timepoint split for offloadable OME-TIFF acquisitions Third of three stacked PRs for large acquisitions. OME-TIFF wrote one pre-allocated file per FOV holding every timepoint, so nothing could be offloaded before the last timepoint. With the new opt-in split each timepoint gets its own /ome_tiff/_.ome.tiff (SizeT=1, TheT=0, DeltaT relative to the acquisition start, TimeIncrement kept), which finalizes after Z x C planes and is then listed in the transfer manifest like every other format. Strictly opt-in: OME_TIFF_SPLIT_TIMEPOINTS (default off) or a per-run choice; with it off the writer, layout and OME-XML are byte-identical (verified against the previous module versions, UUIDs aside). - utils_ome_tiff_writer / SaveOMETiffJob: when AcquisitionInfo. split_timepoints, the output folder is the timepoint folder's ome_tiff/, the on-disk progress file is keyed per timepoint (an aborted timepoint never contaminates the next), shape and expected count are per timepoint, plane indices are local (t=0) while DeltaT stays global, and the OME Description names the global timepoint. - AcquisitionInfo gains split_timepoints and acquisition_start_time; the worker passes the run's effective flag and start time. - Setting + per-run flag: AcquisitionParameters.split_ome_timepoints, MultiPointController.set_split_ome_timepoints, folded with the global setting in build_params, recorded as acquisition.ome_tiff_split_timepoints in acquisition.yaml and read back (absent = unchanged), reset after the run. Preferences: "Split OME-TIFF by timepoint" in Large Acquisitions. - Pre-flight "Not Enough Disk Space" dialog: for a multi-timepoint OME-TIFF run offers "Split OME-TIFF by timepoint and continue", which also enables large acquisition mode for the run. Tests: writer round trips (split/unsplit, DeltaT continuity, per-timepoint progress files, abort isolation, simulation), plumbing and pre-flight matrix, Preferences, and simulated runs: unsplit layout unchanged and listed once at the end; split run writes one SizeT=1 file per timepoint, lists each before its timepoint_done, records the flag; the upload tool moves and verifies a split run. Browsing split runs in the NDViewer needs the companion ndviewer_light change (separate PR, submodule bump to follow). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01F7SDD9fYF47FUoL8M1dfvF --- software/control/_def.py | 2 + software/control/acquisition_yaml_loader.py | 6 + software/control/core/acquisition_settings.py | 3 + software/control/core/job_processing.py | 23 +- .../control/core/multi_point_controller.py | 10 + software/control/core/multi_point_utils.py | 3 + software/control/core/multi_point_worker.py | 3 + .../control/core/utils_ome_tiff_writer.py | 55 ++- software/control/widgets.py | 74 +++- .../control/core/test_acquisition_settings.py | 30 ++ .../control/test_MultiPointController.py | 36 ++ .../control/test_acquisition_yaml_loader.py | 32 ++ software/tests/control/test_def.py | 2 + .../control/test_ome_split_integration.py | 118 +++++++ .../test_per_region_laser_af_offset.py | 1 + .../tests/control/test_preferences_dialog.py | 32 ++ .../control/test_preflight_disk_check.py | 124 +++++++ software/tests/test_ome_tiff_saving.py | 334 ++++++++++++++++++ 18 files changed, 869 insertions(+), 19 deletions(-) create mode 100644 software/tests/control/test_ome_split_integration.py diff --git a/software/control/_def.py b/software/control/_def.py index 2cc82dca7..ab22c4ada 100644 --- a/software/control/_def.py +++ b/software/control/_def.py @@ -871,6 +871,8 @@ class SOFTWARE_POS_LIMIT: DISK_SPACE_RESERVE_GB = 10.0 # Minimum free space to keep on the save disk before pausing DISK_SPACE_POLL_INTERVAL_S = 5.0 # How often to re-check free space while paused SIMULATED_DISK_CAPACITY_GB = 0.0 # Dev only: pretend the save disk has this capacity (0 = use the real disk) +# Write one OME-TIFF per timepoint (/ome_tiff/...) so timepoints can be offloaded during the run +OME_TIFF_SPLIT_TIMEPOINTS = False CAMERA_SN = {"ch 1": "SN1", "ch 2": "SN2"} # for multiple cameras, to be overwritten in the configuration file diff --git a/software/control/acquisition_yaml_loader.py b/software/control/acquisition_yaml_loader.py index f919684f9..f0d52f6e0 100644 --- a/software/control/acquisition_yaml_loader.py +++ b/software/control/acquisition_yaml_loader.py @@ -62,6 +62,9 @@ class AcquisitionYAMLData: # Per-run opt-in for large acquisition mode. None = the file did not say, so the controller's # current flag is left alone (the global setting still applies when the run's parameters are built). large_acquisition_mode: Optional[bool] = None + # Per-run opt-in for the OME-TIFF per-timepoint split. None = the file did not say, so the + # controller's current flag is left alone (the global setting still applies at build_params time). + ome_tiff_split_timepoints: Optional[bool] = None def parse_acquisition_yaml(file_path: str) -> AcquisitionYAMLData: @@ -186,6 +189,9 @@ def parse_acquisition_dict(data: dict, source: str = "") -> AcquisitionYAM large_acquisition_mode=( None if acq.get("large_acquisition_mode") is None else bool(acq.get("large_acquisition_mode")) ), + ome_tiff_split_timepoints=( + None if acq.get("ome_tiff_split_timepoints") is None else bool(acq.get("ome_tiff_split_timepoints")) + ), ) diff --git a/software/control/core/acquisition_settings.py b/software/control/core/acquisition_settings.py index a73cb37ea..5418b9895 100644 --- a/software/control/core/acquisition_settings.py +++ b/software/control/core/acquisition_settings.py @@ -189,6 +189,9 @@ def apply_acquisition_settings( # Only an explicit key opts this run in; absent leaves the controller's flag alone (a pre-flight # dialog may have set it). The global setting is folded in when the run's parameters are built. controller.set_large_acquisition_mode(data.large_acquisition_mode) + if data.ome_tiff_split_timepoints is not None: + # Same rule as large_acquisition_mode: only an explicit key opts this run in. + controller.set_split_ome_timepoints(data.ome_tiff_split_timepoints) controller.set_widget_type(data.widget_type) controller.set_scan_size(float(data.scan_size_mm or 0.0)) controller.set_overlap_percent(float(data.overlap_percent)) diff --git a/software/control/core/job_processing.py b/software/control/core/job_processing.py index 49c9b5d3b..0287b0bbb 100644 --- a/software/control/core/job_processing.py +++ b/software/control/core/job_processing.py @@ -51,6 +51,13 @@ class AcquisitionInfo: physical_size_z_um: Z step size in micrometers (for OME-XML). physical_size_x_um: Pixel size in X in micrometers (for OME-XML). physical_size_y_um: Pixel size in Y in micrometers (for OME-XML). + split_timepoints: When True, each timepoint is written to its own OME-TIFF file + (SizeT=1) under that timepoint's folder, so a finished timepoint can be handed to + the transfer manifest while the run continues. When False (the default), all + timepoints share one pre-allocated file per (region, FOV). + acquisition_start_time: Wall-clock start (time.time()) of the whole run, so that + per-timepoint files can express DeltaT relative to the acquisition start rather + than to their own first plane. Only used when split_timepoints is True. """ total_time_points: int @@ -62,6 +69,8 @@ class AcquisitionInfo: physical_size_z_um: Optional[float] = None physical_size_x_um: Optional[float] = None physical_size_y_um: Optional[float] = None + split_timepoints: bool = False + acquisition_start_time: Optional[float] = None # NOTE(imo): We want this to be fast. But pydantic does not support numpy serialization natively, which means @@ -299,10 +308,15 @@ def run(self) -> SaveResult: ome_folder = ome_tiff_writer.ome_output_folder(self.acquisition_info, self.capture_info) base_name = ome_tiff_writer.ome_base_name(self.capture_info) stack_key = os.path.join(ome_folder, base_name) + split = self.acquisition_info.split_timepoints + time_point = self.capture_info.time_point or 0 + if split: + # One simulated stack per timepoint, mirroring the per-timepoint files on disk. + stack_key = f"{stack_key}:t{time_point}" - # Determine 5D shape (T, Z, C, Y, X) + # Determine 5D shape (T, Z, C, Y, X); a split stack holds a single timepoint. shape = ( - self.acquisition_info.total_time_points, + 1 if split else self.acquisition_info.total_time_points, self.acquisition_info.total_z_levels, self.acquisition_info.total_channels, image.shape[0], @@ -313,7 +327,7 @@ def run(self) -> SaveResult: image=image, stack_key=stack_key, shape=shape, - time_point=self.capture_info.time_point or 0, + time_point=0 if split else time_point, z_index=self.capture_info.z_index, channel_index=self.capture_info.configuration_idx, ) @@ -376,7 +390,8 @@ def _save_ome_tiff(self, image: np.ndarray, info: CaptureInfo) -> Tuple[str, boo target_dtype = np.dtype(metadata[ome_tiff_writer.DTYPE_KEY]) image_to_store = image if image.dtype == target_dtype else image.astype(target_dtype) - time_point = int(info.time_point) + # A split file holds a single timepoint, so its plane index along T is always 0. + time_point = 0 if self.acquisition_info.split_timepoints else int(info.time_point) z_index = int(info.z_index) channel_index = int(info.configuration_idx) shape = tuple(metadata[ome_tiff_writer.SHAPE_KEY]) diff --git a/software/control/core/multi_point_controller.py b/software/control/core/multi_point_controller.py index 43595181c..ca32102c4 100644 --- a/software/control/core/multi_point_controller.py +++ b/software/control/core/multi_point_controller.py @@ -82,6 +82,7 @@ def _save_acquisition_yaml( "xy_mode": params.xy_mode, "skip_saving": params.skip_saving, "large_acquisition_mode": params.large_acquisition_mode, + "ome_tiff_split_timepoints": params.split_ome_timepoints, }, "objective": objective_info or {}, "sample": { @@ -245,6 +246,8 @@ def __init__( self.skip_saving = False # Per-run opt-in for large acquisition mode (pre-flight dialog); reset when the run finishes. self.large_acquisition_mode = False + # Per-run opt-in for the OME-TIFF per-timepoint split (pre-flight dialog); reset when the run finishes. + self.split_ome_timepoints = False # Single pause control point of the running acquisition; exists only for runs in large # acquisition mode (operator pause + disk-space guard hold it), None otherwise. self._pause_gate: Optional[PauseGate] = None @@ -460,6 +463,11 @@ def set_large_acquisition_mode(self, enabled: bool) -> None: self.large_acquisition_mode = bool(enabled) self._log.info(f"Large acquisition mode for the next run: {self.large_acquisition_mode}") + def set_split_ome_timepoints(self, enabled: bool) -> None: + """Opt this run into one OME-TIFF per timepoint (folded with the global setting in build_params).""" + self.split_ome_timepoints = bool(enabled) + self._log.info(f"OME-TIFF per-timepoint split for the next run: {self.split_ome_timepoints}") + def set_xy_mode(self, xy_mode): self.xy_mode = xy_mode @@ -1074,6 +1082,7 @@ def build_params( # Effective mode for this run: per-run opt-in (pre-flight dialog / YAML) or the global # setting. Recorded in acquisition.yaml so the run's behaviour is reproducible. large_acquisition_mode=self.large_acquisition_mode or control._def.LARGE_ACQUISITION_MODE, + split_ome_timepoints=self.split_ome_timepoints or control._def.OME_TIFF_SPLIT_TIMEPOINTS, plate_num_rows=plate_num_rows, plate_num_cols=plate_num_cols, xy_mode=self.xy_mode, @@ -1087,6 +1096,7 @@ def _on_acquisition_completed(self): # A per-run opt-in (the pre-flight dialog) must not leak into the next run. Reset it first so a # failure further down this method cannot leave the flag set. The global setting is unaffected. self.large_acquisition_mode = False + self.split_ome_timepoints = False if self._pause_gate is not None: self._pause_gate.clear() self._pause_gate = None diff --git a/software/control/core/multi_point_utils.py b/software/control/core/multi_point_utils.py index f2c603c90..66bedc6d5 100644 --- a/software/control/core/multi_point_utils.py +++ b/software/control/core/multi_point_utils.py @@ -65,6 +65,9 @@ class AcquisitionParameters: # Effective large acquisition mode for this run (per-run opt-in OR the global setting), resolved # once by MultiPointController.build_params. large_acquisition_mode: bool = False + # Effective OME-TIFF per-timepoint split for this run (per-run opt-in OR the global setting), + # resolved once by MultiPointController.build_params. Ignored by the other file formats. + split_ome_timepoints: bool = False # Plate dimensions (only used when xy_mode is plate-based, e.g. "Select Wells"). plate_num_rows: int = 8 # For 96-well plate diff --git a/software/control/core/multi_point_worker.py b/software/control/core/multi_point_worker.py index c99f2bfc8..08a4c8b3a 100644 --- a/software/control/core/multi_point_worker.py +++ b/software/control/core/multi_point_worker.py @@ -163,6 +163,9 @@ def __init__( physical_size_z_um=self._physical_size_z_um, physical_size_x_um=self._pixel_size_um, physical_size_y_um=self._pixel_size_um, + # Opt-in per-timepoint OME-TIFF split (one /ome_tiff/... file per timepoint); off = today's layout. + split_timepoints=acquisition_parameters.split_ome_timepoints, + acquisition_start_time=self.timestamp_acquisition_started, ) self.time_point = 0 diff --git a/software/control/core/utils_ome_tiff_writer.py b/software/control/core/utils_ome_tiff_writer.py index 77f00062a..4bb4c2883 100644 --- a/software/control/core/utils_ome_tiff_writer.py +++ b/software/control/core/utils_ome_tiff_writer.py @@ -37,9 +37,14 @@ PHYSICAL_SIZE_X_UNIT_KEY = "physical_size_x_unit" PHYSICAL_SIZE_Y_KEY = "physical_size_y" PHYSICAL_SIZE_Y_UNIT_KEY = "physical_size_y_unit" +SPLIT_TIMEPOINTS_KEY = "split_timepoints" +GLOBAL_TIME_POINT_KEY = "global_time_point" def ome_output_folder(acq_info: "AcquisitionInfo", info: "CaptureInfo") -> str: + if acq_info.split_timepoints: + # info.save_directory IS the timepoint folder, so each timepoint gets its own stack. + return os.path.join(info.save_directory, "ome_tiff") base_dir = acq_info.experiment_path or os.path.dirname(info.save_directory) return os.path.join(base_dir, "ome_tiff") @@ -47,6 +52,10 @@ def ome_output_folder(acq_info: "AcquisitionInfo", info: "CaptureInfo") -> str: def metadata_temp_path(acq_info: "AcquisitionInfo", info: "CaptureInfo", base_name: str) -> str: base_identifier = acq_info.experiment_path or info.save_directory key = f"{base_identifier}:{base_name}" + if acq_info.split_timepoints: + # Per-timepoint progress files, so an aborted timepoint's leftover progress is never + # adopted by the next timepoint (which writes a different file). + key = f"{key}:t{info.time_point}" digest = hashlib.sha1(key.encode("utf-8")).hexdigest() # Use squid_ome_ prefix to avoid conflicts with other OME-TIFF applications return os.path.join(tempfile.gettempdir(), f"squid_ome_{digest}_metadata.json") @@ -94,11 +103,19 @@ def initialize_metadata(acq_info: "AcquisitionInfo", info: "CaptureInfo", image: physical_size_x_unit = "µm" if physical_size_x is not None else None physical_size_y = float(acq_info.physical_size_y_um) if acq_info.physical_size_y_um is not None else None physical_size_y_unit = "µm" if physical_size_y is not None else None + split_timepoints = bool(acq_info.split_timepoints) + # A split file holds a single timepoint; DeltaT stays relative to the acquisition start so + # that the per-timepoint files remain comparable to each other. + size_t = 1 if split_timepoints else int(acq_info.total_time_points) + if split_timepoints and acq_info.acquisition_start_time is not None: + start_time = acq_info.acquisition_start_time + else: + start_time = info.capture_time return { DTYPE_KEY: np.dtype(image.dtype).str, AXES_KEY: "TZCYX", SHAPE_KEY: [ - int(acq_info.total_time_points), + size_t, int(acq_info.total_z_levels), int(acq_info.total_channels), int(image.shape[-2]), @@ -107,12 +124,12 @@ def initialize_metadata(acq_info: "AcquisitionInfo", info: "CaptureInfo", image: CHANNEL_NAMES_KEY: channel_names, WRITTEN_INDICES_KEY: [], SAVED_COUNT_KEY: 0, - EXPECTED_COUNT_KEY: int(acq_info.total_time_points) - * int(acq_info.total_z_levels) - * int(acq_info.total_channels), + EXPECTED_COUNT_KEY: size_t * int(acq_info.total_z_levels) * int(acq_info.total_channels), PLANES_KEY: {}, - START_TIME_KEY: info.capture_time, + START_TIME_KEY: start_time, COMPLETED_KEY: False, + SPLIT_TIMEPOINTS_KEY: split_timepoints, + GLOBAL_TIME_POINT_KEY: int(info.time_point) if split_timepoints and info.time_point is not None else None, TIME_INCREMENT_KEY: time_increment, TIME_INCREMENT_UNIT_KEY: time_increment_unit, PHYSICAL_SIZE_Z_KEY: physical_size_z, @@ -125,9 +142,12 @@ def initialize_metadata(acq_info: "AcquisitionInfo", info: "CaptureInfo", image: def update_plane_metadata(metadata: Dict[str, Any], info: "CaptureInfo") -> Dict[str, Any]: - plane_key = f"{info.time_point}-{info.configuration_idx}-{info.z_index}" + # In a split file the plane index is local to that file, which holds a single timepoint. + # DeltaT below still measures from START_TIME_KEY (the acquisition start when split). + the_t = 0 if metadata.get(SPLIT_TIMEPOINTS_KEY) else int(info.time_point) + plane_key = f"{the_t}-{info.configuration_idx}-{info.z_index}" plane_data: Dict[str, Any] = { - "TheT": int(info.time_point), + "TheT": the_t, "TheZ": int(info.z_index), "TheC": int(info.configuration_idx), } @@ -176,6 +196,14 @@ def metadata_for_imwrite(metadata: Dict[str, Any]) -> Dict[str, Any]: return meta +def ome_description(metadata: Dict[str, Any]) -> Optional[str]: + """Description text for a split file, which alone cannot say which timepoint it holds.""" + global_time_point = metadata.get(GLOBAL_TIME_POINT_KEY) + if global_time_point is None: + return None + return f"Timepoint {global_time_point} of the acquisition (one timepoint per file)" + + def build_base_ome_xml(metadata: Dict[str, Any]) -> str: # Lazy import: xml.etree.ElementTree only needed for XML generation functions import xml.etree.ElementTree as ET @@ -206,6 +234,10 @@ def build_base_ome_xml(metadata: Dict[str, Any]) -> str: image.set("AcquisitionDate", acq_time) except Exception: pass + description = ome_description(metadata) + if description is not None: + # OME requires Description before Pixels, so add it while Image is still empty. + ET.SubElement(image, "{ns}Description".format(ns="{" + ns + "}")).text = description pixels = ET.SubElement( image, "{ns}Pixels".format(ns="{" + ns + "}"), @@ -264,6 +296,15 @@ def augment_ome_xml(existing_xml: Optional[str], metadata: Dict[str, Any]) -> st except Exception: pass + description = ome_description(metadata) + if description is not None: + description_elem = image.find("ome:Description", ns) + if description_elem is None: + # OME requires Description to precede Pixels. + description_elem = ET.Element("{http://www.openmicroscopy.org/Schemas/OME/2016-06}Description") + image.insert(0, description_elem) + description_elem.text = description + pixels = image.find("ome:Pixels", ns) if pixels is None: return existing_xml or "" diff --git a/software/control/widgets.py b/software/control/widgets.py index 4e197b504..1818fc1fa 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -74,13 +74,19 @@ def error_dialog(message: str, title: str = "Error"): ) +OME_TIFF_SPLIT_EXPLANATION = ( + "Splitting writes one OME-TIFF per timepoint under /ome_tiff/, so finished timepoints can be offloaded" + " while the run continues. The NDViewer cannot yet browse split runs." +) + + class PreflightDiskOptions(NamedTuple): """What the pre-flight disk check should put in front of the operator. Attributes: needs_dialog: The acquisition needs more space than the save disk has. offer_enable_mode: Offer to turn large acquisition mode on for this run (it is off in Settings). - offer_split_ome: Reserved for the OME-TIFF split PR; always False here. + offer_split_ome: Offer to split the OME-TIFF output by timepoint for this run. """ needs_dialog: bool @@ -88,21 +94,32 @@ class PreflightDiskOptions(NamedTuple): offer_split_ome: bool -def preflight_disk_options(space_required: float, available: int, mode_on: bool) -> PreflightDiskOptions: - """Decide what the pre-flight disk check should do. Pure: no Qt, no I/O.""" +def preflight_disk_options( + space_required: float, + available: int, + mode_on: bool, + file_saving_option: Optional[FileSavingOption] = None, + nt: int = 1, + split_on: bool = False, +) -> PreflightDiskOptions: + """Decide what the pre-flight disk check should do. Pure: no Qt, no I/O. + + The per-timepoint split only helps a multi-timepoint OME-TIFF run: a single-timepoint run already + writes one file per timepoint, and the other file formats do not go through the OME-TIFF writer. + """ needs_dialog = space_required > available return PreflightDiskOptions( needs_dialog=needs_dialog, offer_enable_mode=needs_dialog and not mode_on, - offer_split_ome=False, + offer_split_ome=(needs_dialog and file_saving_option == FileSavingOption.OME_TIFF and nt > 1 and not split_on), ) def preflight_disk_dialog(message: str, options: PreflightDiskOptions) -> str: """Ask the operator what to do about a save disk that is too small for this acquisition. - Returns "enable" (turn large acquisition mode on for this run), "continue" (start anyway, the mode is - already on in Settings) or "cancel". + Returns "enable" (turn large acquisition mode on for this run), "split" (also write one OME-TIFF per + timepoint), "continue" (start anyway, the mode is already on in Settings) or "cancel". """ msg = QMessageBox() msg.setIcon(QMessageBox.Warning) @@ -114,10 +131,18 @@ def preflight_disk_dialog(message: str, options: PreflightDiskOptions) -> str: else: proceed_action = "continue" proceed_button = msg.addButton("Continue", QMessageBox.AcceptRole) + split_button = None + if options.offer_split_ome: + msg.setInformativeText(OME_TIFF_SPLIT_EXPLANATION) + split_button = msg.addButton("Split OME-TIFF by timepoint and continue", QMessageBox.AcceptRole) + split_button.setToolTip(OME_TIFF_SPLIT_EXPLANATION) cancel_button = msg.addButton("Cancel", QMessageBox.RejectRole) msg.setDefaultButton(cancel_button) msg.exec_() - return proceed_action if msg.clickedButton() is proceed_button else "cancel" + clicked = msg.clickedButton() + if split_button is not None and clicked is split_button: + return "split" + return proceed_action if clicked is proceed_button else "cancel" def check_space_available_with_error_dialog( @@ -133,7 +158,14 @@ def check_space_available_with_error_dialog( logger.info( f"Checking space available: {space_required=}, {available_disk_space=}, {image_count=}, {save_directory=}" ) - options = preflight_disk_options(space_required, available_disk_space, control._def.LARGE_ACQUISITION_MODE) + options = preflight_disk_options( + space_required, + available_disk_space, + control._def.LARGE_ACQUISITION_MODE, + file_saving_option=control._def.FILE_SAVING_OPTION, + nt=multi_point_controller.Nt, + split_on=control._def.OME_TIFF_SPLIT_TIMEPOINTS, + ) if not options.needs_dialog: return True @@ -145,6 +177,14 @@ def check_space_available_with_error_dialog( ) # Module-level lookup (not a default argument) so tests can monkeypatch the dialog. action = preflight_disk_dialog(f"{error_message}\n\n{LARGE_ACQUISITION_MODE_EXPLANATION}", options) + if action == "split": + logger.info("Operator chose to split the OME-TIFF output by timepoint for this run.") + multi_point_controller.set_split_ome_timepoints(True) + if options.offer_enable_mode: + # Splitting only helps if the run also pauses instead of failing when the disk fills up. + logger.info("Also enabling large acquisition mode for this run.") + multi_point_controller.set_large_acquisition_mode(True) + return True if action == "enable": logger.info("Operator enabled large acquisition mode for this run from the pre-flight disk check.") multi_point_controller.set_large_acquisition_mode(True) @@ -1499,6 +1539,13 @@ def _create_acquisition_tab(self): ) large_acq_layout.addRow("Disk re-check interval (s):", self.disk_space_poll_interval_spinbox) + self.ome_tiff_split_timepoints_checkbox = QCheckBox() + self.ome_tiff_split_timepoints_checkbox.setChecked( + self._get_config_bool("GENERAL", "ome_tiff_split_timepoints", control._def.OME_TIFF_SPLIT_TIMEPOINTS) + ) + self.ome_tiff_split_timepoints_checkbox.setToolTip(OME_TIFF_SPLIT_EXPLANATION) + large_acq_layout.addRow("Split OME-TIFF by timepoint:", self.ome_tiff_split_timepoints_checkbox) + large_acq_group.content.addLayout(large_acq_layout) layout.addRow(large_acq_group) @@ -2184,6 +2231,11 @@ def _apply_settings(self) -> bool: ) self.config.set("GENERAL", "disk_space_reserve_gb", str(self.disk_space_reserve_spinbox.value())) self.config.set("GENERAL", "disk_space_poll_interval_s", str(self.disk_space_poll_interval_spinbox.value())) + self.config.set( + "GENERAL", + "ome_tiff_split_timepoints", + "true" if self.ome_tiff_split_timepoints_checkbox.isChecked() else "false", + ) # Camera settings self.config.set("CAMERA_CONFIG", "binning_factor_default", str(self.binning_spinbox.value())) @@ -2392,6 +2444,7 @@ def _apply_live_settings(self): control._def.LARGE_ACQUISITION_MODE = self.large_acquisition_mode_checkbox.isChecked() control._def.DISK_SPACE_RESERVE_GB = self.disk_space_reserve_spinbox.value() control._def.DISK_SPACE_POLL_INTERVAL_S = self.disk_space_poll_interval_spinbox.value() + control._def.OME_TIFF_SPLIT_TIMEPOINTS = self.ome_tiff_split_timepoints_checkbox.isChecked() # Acquisition throttling settings control._def.ACQUISITION_THROTTLING_ENABLED = self.throttling_enabled_checkbox.isChecked() @@ -2493,6 +2546,11 @@ def _get_changes(self): if not self._floats_equal(old_val, new_val): changes.append(("Disk Re-check Interval", f"{old_val} s", f"{new_val} s", False)) + old_val = self._get_config_bool("GENERAL", "ome_tiff_split_timepoints", control._def.OME_TIFF_SPLIT_TIMEPOINTS) + new_val = self.ome_tiff_split_timepoints_checkbox.isChecked() + if old_val != new_val: + changes.append(("Split OME-TIFF by Timepoint", str(old_val), str(new_val), False)) + # Camera settings (require restart) old_val = self._get_config_int("CAMERA_CONFIG", "binning_factor_default", 2) new_val = self.binning_spinbox.value() diff --git a/software/tests/control/core/test_acquisition_settings.py b/software/tests/control/core/test_acquisition_settings.py index 19021244c..926ec3d55 100644 --- a/software/tests/control/core/test_acquisition_settings.py +++ b/software/tests/control/core/test_acquisition_settings.py @@ -166,6 +166,36 @@ def test_apply_sets_large_acquisition_mode_when_the_yaml_specifies_it(): assert mpc.large_acquisition_mode is True +def test_apply_sets_split_ome_timepoints_when_the_yaml_specifies_it(): + scope, mpc = _controller() + data = AcquisitionYAMLData( + widget_type="wellplate", + channel_names=_channel_names(scope, mpc, 1), + wellplate_regions=[{"name": "A1", "fovs": _some_fovs(mpc)}], + ome_tiff_split_timepoints=True, + ) + + apply_acquisition_settings(mpc, mpc.scanCoordinates, scope, data) + + assert mpc.split_ome_timepoints is True + + +def test_apply_leaves_split_ome_timepoints_alone_when_the_yaml_omits_it(): + """Absent means 'not specified' - the pre-flight dialog's opt-in must survive; the global + setting is folded in when the run's parameters are built, not here.""" + scope, mpc = _controller() + mpc.set_split_ome_timepoints(True) + data = AcquisitionYAMLData( + widget_type="wellplate", + channel_names=_channel_names(scope, mpc, 1), + wellplate_regions=[{"name": "A1", "fovs": _some_fovs(mpc)}], + ) + + apply_acquisition_settings(mpc, mpc.scanCoordinates, scope, data) + + assert mpc.split_ome_timepoints is True + + def test_apply_leaves_large_acquisition_mode_alone_when_the_yaml_omits_it(): """Absent means 'not specified' - the pre-flight dialog's opt-in must survive; the global setting is folded in when the run's parameters are built, not here.""" diff --git a/software/tests/control/test_MultiPointController.py b/software/tests/control/test_MultiPointController.py index 237400c03..9e5cbcb0b 100644 --- a/software/tests/control/test_MultiPointController.py +++ b/software/tests/control/test_MultiPointController.py @@ -673,6 +673,42 @@ def test_large_acquisition_mode_reaches_the_params_and_is_reset_after_the_run(tm assert mpc.large_acquisition_mode is False +def test_split_ome_timepoints_reaches_the_params_and_is_reset_after_the_run(tmp_path, monkeypatch): + import yaml + + monkeypatch.setattr(control._def, "OME_TIFF_SPLIT_TIMEPOINTS", False) + + scope, tt, mpc = _controller_with_tracker() + assert mpc.split_ome_timepoints is False # strictly opt-in + + mpc.set_base_path(str(tmp_path)) + mpc.start_new_experiment("R01_split", add_timestamp=False) + mpc.set_split_ome_timepoints(True) + + mpc.timestamp_acquisition_started = 0.0 # normally set by run_acquisition, which build_params reads + assert mpc.build_params(scan_position_information=_empty_scan_positions()).split_ome_timepoints is True + + mpc.run_acquisition() + assert tt.finished_event.wait(30) + mpc.thread.join(10) + + with open(tmp_path / "R01_split" / "acquisition.yaml", encoding="utf-8") as f: + assert yaml.safe_load(f)["acquisition"]["ome_tiff_split_timepoints"] is True + + # A dialog-granted per-run opt-in must not leak into the next run. + assert mpc.split_ome_timepoints is False + + +def test_split_ome_timepoints_follows_the_global_setting_when_not_opted_in_per_run(monkeypatch): + monkeypatch.setattr(control._def, "OME_TIFF_SPLIT_TIMEPOINTS", True) + + scope, tt, mpc = _controller_with_tracker() + mpc.timestamp_acquisition_started = 0.0 + + assert mpc.split_ome_timepoints is False + assert mpc.build_params(scan_position_information=_empty_scan_positions()).split_ome_timepoints is True + + def _empty_scan_positions(): from control.core.multi_point_utils import ScanPositionInformation diff --git a/software/tests/control/test_acquisition_yaml_loader.py b/software/tests/control/test_acquisition_yaml_loader.py index 444d7c6e0..ae09d3e80 100644 --- a/software/tests/control/test_acquisition_yaml_loader.py +++ b/software/tests/control/test_acquisition_yaml_loader.py @@ -593,3 +593,35 @@ def test_round_trip_through_a_yaml_file(self, tmp_path): ) assert parse_acquisition_yaml(str(tmp_path / "acquisition.yaml")).large_acquisition_mode is True + + +class TestOmeTiffSplitTimepoints: + """acquisition.ome_tiff_split_timepoints is a per-run opt-in: absent means 'not specified'.""" + + def test_dataclass_default_is_unspecified(self): + assert AcquisitionYAMLData(widget_type="wellplate").ome_tiff_split_timepoints is None + + @pytest.mark.parametrize("value,expected", [(True, True), (False, False)]) + def test_parse_dict_reads_the_key(self, value, expected): + from control.acquisition_yaml_loader import parse_acquisition_dict + + data = parse_acquisition_dict({"acquisition": {"widget_type": "wellplate", "ome_tiff_split_timepoints": value}}) + + assert data.ome_tiff_split_timepoints is expected + + def test_parse_dict_without_the_key_leaves_it_unspecified(self): + from control.acquisition_yaml_loader import parse_acquisition_dict + + data = parse_acquisition_dict({"acquisition": {"widget_type": "wellplate"}}) + + assert data.ome_tiff_split_timepoints is None + + def test_round_trip_through_a_yaml_file(self, tmp_path): + import yaml + + # The same "acquisition" block the controller writes for a run with the split on. + (tmp_path / "acquisition.yaml").write_text( + yaml.safe_dump({"acquisition": {"widget_type": "wellplate", "ome_tiff_split_timepoints": True}}) + ) + + assert parse_acquisition_yaml(str(tmp_path / "acquisition.yaml")).ome_tiff_split_timepoints is True diff --git a/software/tests/control/test_def.py b/software/tests/control/test_def.py index bd3af375c..eda2f7a37 100644 --- a/software/tests/control/test_def.py +++ b/software/tests/control/test_def.py @@ -256,3 +256,5 @@ def test_large_acquisition_mode_defaults_off(): assert "LARGE_ACQUISITION_MODE = False" in source assert "DISK_SPACE_RESERVE_GB = 10.0" in source assert "SIMULATED_DISK_CAPACITY_GB = 0.0" in source + # The OME-TIFF per-timepoint split is opt-in too: it changes the on-disk layout. + assert "OME_TIFF_SPLIT_TIMEPOINTS = False" in source diff --git a/software/tests/control/test_ome_split_integration.py b/software/tests/control/test_ome_split_integration.py new file mode 100644 index 000000000..8dc7fe5d4 --- /dev/null +++ b/software/tests/control/test_ome_split_integration.py @@ -0,0 +1,118 @@ +"""Simulated OME-TIFF runs with the opt-in per-timepoint split: one file per timepoint under /ome_tiff/, +each listed in the transfer manifest as soon as its planes are written, and movable by the upload tool.""" + +import subprocess +import sys +from pathlib import Path + +import tifffile + +import control._def +import control.microscope +import control.core.multi_point_worker as mpw +from control._def import FileSavingOption +from control.core.transfer_manifest import MANIFEST_FILE_NAME, read_manifest +import tests.control.test_stubs as ts +from tests.control.test_MultiPointController import TestAcquisitionTracker, select_some_configs +from tests.control.test_transfer_manifest_integration import _one_fov, _tp_dir + +TOOL = Path(__file__).resolve().parents[2] / "tools" / "upload_acquisition.py" + + +def _run(tmp_path, monkeypatch, split: bool, nt: int = 2): + control._def.MERGE_CHANNELS = False + monkeypatch.setattr(control._def, "FILE_SAVING_OPTION", FileSavingOption.OME_TIFF) + monkeypatch.setattr(mpw, "FILE_SAVING_OPTION", FileSavingOption.OME_TIFF) + monkeypatch.setattr(control._def, "LARGE_ACQUISITION_MODE", False) + monkeypatch.setattr(control._def, "OME_TIFF_SPLIT_TIMEPOINTS", False) + monkeypatch.setattr(control._def, "SIMULATED_DISK_CAPACITY_GB", 1000.0) + monkeypatch.setattr(control._def, "DISK_SPACE_RESERVE_GB", 0.0) + + scope = control.microscope.Microscope.build_from_global_config(True) + tt = TestAcquisitionTracker() + mpc = ts.get_test_multi_point_controller(microscope=scope, callbacks=tt.get_callbacks()) + mpc.set_base_path(str(tmp_path)) + mpc.start_new_experiment("ome_run", add_timestamp=False) + _one_fov(mpc) + select_some_configs(mpc, scope.objective_store.current_objective) + mpc.set_Nt(nt) + mpc.set_deltat(0.0) + mpc.set_large_acquisition_mode(True) + mpc.set_split_ome_timepoints(split) + + mpc.run_acquisition() + assert tt.started_event.wait(10) + assert tt.finished_event.wait(120) + mpc.thread.join(10) + assert mpc.last_end_reason == "completed" + assert tt.image_count == mpc.get_acquisition_image_count() + return tmp_path / "ome_run", tt, mpc + + +def test_unsplit_ome_tiff_layout_is_unchanged_and_listed_only_when_the_stack_completes(tmp_path, monkeypatch): + exp, tt, mpc = _run(tmp_path, monkeypatch, split=False, nt=2) + files = sorted((exp / "ome_tiff").glob("*.ome.tiff")) + assert len(files) == 1, files + with tifffile.TiffFile(files[0]) as tif: + assert 'SizeT="2"' in tif.ome_metadata, "one file holds both timepoints" + records = read_manifest(exp / MANIFEST_FILE_NAME) + completes = [r for r in records if r["event"] == "complete" and r["path"].endswith(".ome.tiff")] + assert ( + len(completes) == 1 and completes[0]["t"] == 1 + ), "listed once, when the writer finalized at the last timepoint" + assert not any(exp.glob("*/ome_tiff")), "no per-timepoint folders without the split" + + +def test_split_ome_tiff_writes_one_file_per_timepoint_and_lists_each(tmp_path, monkeypatch): + exp, tt, mpc = _run(tmp_path, monkeypatch, split=True, nt=2) + assert not (exp / "ome_tiff").exists(), "split runs have no run-level ome_tiff folder" + per_t = {t: sorted((exp / _tp_dir(t) / "ome_tiff").glob("*.ome.tiff")) for t in range(2)} + assert all(len(files) == 1 for files in per_t.values()), per_t + n_channels = len(mpc.selected_configurations) + for t, files in per_t.items(): + with tifffile.TiffFile(files[0]) as tif: + assert 'SizeT="1"' in tif.ome_metadata, f"SizeT must be 1 in {files[0]}" + assert f'SizeC="{n_channels}"' in tif.ome_metadata + stale = list(Path(__import__("tempfile").gettempdir()).glob("squid_ome_*_metadata.json")) + assert not [p for p in stale if "ome_run" in p.read_text()], "no leftover OME progress files for this run" + + records = read_manifest(exp / MANIFEST_FILE_NAME) + listed = [r for r in records if r["event"] == "complete" and r["path"].endswith(".ome.tiff")] + assert sorted(r["path"] for r in listed) == sorted(f"{_tp_dir(t)}/ome_tiff/{per_t[t][0].name}" for t in range(2)) + for r in listed: + assert r["bytes"] is not None or r["kind"] == "file" + assert (exp / Path(*r["path"].split("/"))).is_file() + # Each timepoint's file precedes its timepoint_done record. + events = [(r["event"], r.get("t")) for r in records] + for t in range(2): + assert events.index(("timepoint_done", t)) > max( + i + for i, r in enumerate(records) + if r["event"] == "complete" and r.get("t") == t and r["path"].endswith(".ome.tiff") + ) + assert (exp / "acquisition.yaml").read_text().find("ome_tiff_split_timepoints: true") >= 0 + assert mpc.split_ome_timepoints is False, "per-run flag reset after the run" + + +def test_upload_tool_moves_and_verifies_a_split_run(tmp_path, monkeypatch): + exp, tt, mpc = _run(tmp_path, monkeypatch, split=True, nt=2) + dest = tmp_path / "nas" + dest.mkdir() + run = subprocess.run( + [sys.executable, str(TOOL), str(exp), str(dest), "--mode", "move", "--quiesce-s", "0"], + cwd=str(TOOL.parents[1]), + capture_output=True, + text=True, + ) + assert run.returncode == 0, run.stdout + run.stderr + dest_exp = dest / "ome_run" + for t in range(2): + assert list((dest_exp / _tp_dir(t) / "ome_tiff").glob("*.ome.tiff")), t + assert [p for p in exp.rglob("*") if p.is_file()] == [] if exp.exists() else True + verify = subprocess.run( + [sys.executable, str(TOOL), "verify", str(dest_exp), str(dest)], + cwd=str(TOOL.parents[1]), + capture_output=True, + text=True, + ) + assert verify.returncode == 0, verify.stdout + verify.stderr diff --git a/software/tests/control/test_per_region_laser_af_offset.py b/software/tests/control/test_per_region_laser_af_offset.py index 78f3202d9..007092f87 100644 --- a/software/tests/control/test_per_region_laser_af_offset.py +++ b/software/tests/control/test_per_region_laser_af_offset.py @@ -40,6 +40,7 @@ def __init__(self): self.z_range = (0.0, 0.0) self.skip_saving = False self.large_acquisition_mode = False + self.split_ome_timepoints = False self.xy_mode = "Current Position" build_params = MultiPointController.build_params diff --git a/software/tests/control/test_preferences_dialog.py b/software/tests/control/test_preferences_dialog.py index 77d130867..a5989e11d 100644 --- a/software/tests/control/test_preferences_dialog.py +++ b/software/tests/control/test_preferences_dialog.py @@ -753,6 +753,7 @@ def pinned_defaults(self, monkeypatch): monkeypatch.setattr(control._def, "DISK_SPACE_RESERVE_GB", 10.0) monkeypatch.setattr(control._def, "DISK_SPACE_POLL_INTERVAL_S", 5.0) monkeypatch.setattr(control._def, "SIMULATED_DISK_CAPACITY_GB", 0.0) + monkeypatch.setattr(control._def, "OME_TIFF_SPLIT_TIMEPOINTS", False) @pytest.fixture def dialog(self, qtbot, sample_config, temp_config_file, pinned_defaults): @@ -830,3 +831,34 @@ def test_changes_appear_in_diff_summary_without_restart(self, dialog): def test_no_phantom_changes_at_defaults(self, dialog): assert dialog._get_changes() == [] + + def test_split_ome_checkbox_defaults_off(self, dialog): + assert dialog.ome_tiff_split_timepoints_checkbox.isChecked() is False + + def test_split_ome_checkbox_loads_from_config(self, qtbot, sample_config, temp_config_file, pinned_defaults): + sample_config.set("GENERAL", "ome_tiff_split_timepoints", "true") + + dlg = control.widgets.PreferencesDialog(sample_config, temp_config_file) + qtbot.addWidget(dlg) + + assert dlg.ome_tiff_split_timepoints_checkbox.isChecked() is True + + def test_split_ome_round_trip_and_live_apply(self, dialog, temp_config_file): + import control._def + + dialog.ome_tiff_split_timepoints_checkbox.setChecked(True) + + with patch("qtpy.QtWidgets.QDialog.exec_", return_value=True): + dialog.accept = MagicMock() + dialog._save_and_close() + + saved = ConfigParser() + saved.read(temp_config_file) + assert saved.get("GENERAL", "ome_tiff_split_timepoints").lower() == "true" + assert control._def.OME_TIFF_SPLIT_TIMEPOINTS is True + + def test_split_ome_change_appears_in_diff_summary_without_restart(self, dialog): + dialog.ome_tiff_split_timepoints_checkbox.setChecked(True) + + changes = {c[0]: c for c in dialog._get_changes()} + assert changes["Split OME-TIFF by Timepoint"][1:] == ("False", "True", False) diff --git a/software/tests/control/test_preflight_disk_check.py b/software/tests/control/test_preflight_disk_check.py index cd9971507..c21bc9fcf 100644 --- a/software/tests/control/test_preflight_disk_check.py +++ b/software/tests/control/test_preflight_disk_check.py @@ -23,12 +23,16 @@ def test_logger(): def controller(tmp_path): """Minimal stand-in for MultiPointController with the attributes the check reads.""" calls = [] + split_calls = [] return SimpleNamespace( base_path=str(tmp_path), + Nt=1, get_estimated_acquisition_disk_storage=lambda: 1000, get_acquisition_image_count=lambda: 42, set_large_acquisition_mode=lambda enabled: calls.append(enabled), large_acquisition_mode_calls=calls, + set_split_ome_timepoints=lambda enabled: split_calls.append(enabled), + split_ome_timepoints_calls=split_calls, ) @@ -87,6 +91,54 @@ def test_enough_space_never_offers_enable_even_with_mode_off(self): assert options.offer_enable_mode is False +class TestPreflightOfferSplitOme: + """The OME-TIFF per-timepoint split is only worth offering for a multi-timepoint OME-TIFF run.""" + + @staticmethod + def _options(**overrides): + kwargs = dict( + space_required=300.0, + available=200, + mode_on=False, + file_saving_option=control._def.FileSavingOption.OME_TIFF, + nt=3, + split_on=False, + ) + kwargs.update(overrides) + return control.widgets.preflight_disk_options(**kwargs) + + def test_ome_tiff_multi_timepoint_with_split_off_offers_the_split(self): + assert self._options().offer_split_ome is True + + def test_single_timepoint_does_not_offer_the_split(self): + assert self._options(nt=1).offer_split_ome is False + + def test_split_already_on_does_not_offer_it_again(self): + assert self._options(split_on=True).offer_split_ome is False + + @pytest.mark.parametrize( + "file_saving_option", + [ + control._def.FileSavingOption.ZARR_V3, + control._def.FileSavingOption.INDIVIDUAL_IMAGES, + control._def.FileSavingOption.MULTI_PAGE_TIFF, + None, + ], + ) + def test_other_formats_do_not_offer_the_split(self, file_saving_option): + assert self._options(file_saving_option=file_saving_option).offer_split_ome is False + + def test_enough_space_does_not_offer_the_split(self): + assert self._options(space_required=1.0).offer_split_ome is False + + def test_the_split_is_offered_even_when_the_mode_is_already_on(self): + assert self._options(mode_on=True).offer_split_ome is True + + def test_defaults_keep_the_pre_split_behaviour(self): + """Callers that pass only the original three arguments never see the split offer.""" + assert control.widgets.preflight_disk_options(300.0, 200, False).offer_split_ome is False + + class TestCheckSpaceAvailable: """The wrapper, with the dialog stubbed out.""" @@ -107,6 +159,7 @@ def test_enable_turns_the_mode_on_for_this_run( assert control.widgets.check_space_available_with_error_dialog(controller, test_logger) is True assert controller.large_acquisition_mode_calls == [True] + assert controller.split_ome_timepoints_calls == [] assert len(recorded) == 1 message, options = recorded[0] assert options.offer_enable_mode is True @@ -122,6 +175,7 @@ def test_cancel_returns_false_and_leaves_the_mode_alone( assert control.widgets.check_space_available_with_error_dialog(controller, test_logger) is False assert controller.large_acquisition_mode_calls == [] + assert controller.split_ome_timepoints_calls == [] def test_mode_already_on_offers_continue(self, controller, test_logger, free_space, dialog_action, monkeypatch): monkeypatch.setattr(control._def, "LARGE_ACQUISITION_MODE", True) @@ -142,3 +196,73 @@ def test_mode_already_on_cancel_returns_false( assert control.widgets.check_space_available_with_error_dialog(controller, test_logger) is False assert controller.large_acquisition_mode_calls == [] + + +class TestCheckSpaceAvailableSplitOme: + """The wrapper's OME-TIFF split offer, with the dialog stubbed out.""" + + @pytest.fixture + def ome_multi_timepoint(self, controller, monkeypatch): + """A run that would produce a multi-timepoint OME-TIFF with the split off.""" + monkeypatch.setattr(control._def, "FILE_SAVING_OPTION", control._def.FileSavingOption.OME_TIFF) + monkeypatch.setattr(control._def, "OME_TIFF_SPLIT_TIMEPOINTS", False) + controller.Nt = 3 + return controller + + def test_split_sets_both_flags_for_this_run( + self, ome_multi_timepoint, test_logger, free_space, dialog_action, monkeypatch + ): + monkeypatch.setattr(control._def, "LARGE_ACQUISITION_MODE", False) + free_space(10) + recorded = dialog_action("split") + + assert control.widgets.check_space_available_with_error_dialog(ome_multi_timepoint, test_logger) is True + assert ome_multi_timepoint.split_ome_timepoints_calls == [True] + # Splitting is only useful if the run also pauses instead of dying when the disk fills up. + assert ome_multi_timepoint.large_acquisition_mode_calls == [True] + assert recorded[0][1].offer_split_ome is True + + def test_split_with_the_mode_already_on_only_sets_the_split( + self, ome_multi_timepoint, test_logger, free_space, dialog_action, monkeypatch + ): + monkeypatch.setattr(control._def, "LARGE_ACQUISITION_MODE", True) + free_space(10) + recorded = dialog_action("split") + + assert control.widgets.check_space_available_with_error_dialog(ome_multi_timepoint, test_logger) is True + assert ome_multi_timepoint.split_ome_timepoints_calls == [True] + assert ome_multi_timepoint.large_acquisition_mode_calls == [] + assert recorded[0][1].offer_split_ome is True + + def test_cancel_leaves_the_split_alone( + self, ome_multi_timepoint, test_logger, free_space, dialog_action, monkeypatch + ): + monkeypatch.setattr(control._def, "LARGE_ACQUISITION_MODE", False) + free_space(10) + dialog_action("cancel") + + assert control.widgets.check_space_available_with_error_dialog(ome_multi_timepoint, test_logger) is False + assert ome_multi_timepoint.split_ome_timepoints_calls == [] + assert ome_multi_timepoint.large_acquisition_mode_calls == [] + + def test_single_timepoint_ome_run_is_not_offered_the_split( + self, ome_multi_timepoint, test_logger, free_space, dialog_action, monkeypatch + ): + monkeypatch.setattr(control._def, "LARGE_ACQUISITION_MODE", False) + ome_multi_timepoint.Nt = 1 + free_space(10) + recorded = dialog_action("enable") + + assert control.widgets.check_space_available_with_error_dialog(ome_multi_timepoint, test_logger) is True + assert recorded[0][1].offer_split_ome is False + + def test_split_already_on_globally_is_not_offered_again( + self, ome_multi_timepoint, test_logger, free_space, dialog_action, monkeypatch + ): + monkeypatch.setattr(control._def, "LARGE_ACQUISITION_MODE", False) + monkeypatch.setattr(control._def, "OME_TIFF_SPLIT_TIMEPOINTS", True) + free_space(10) + recorded = dialog_action("enable") + + assert control.widgets.check_space_available_with_error_dialog(ome_multi_timepoint, test_logger) is True + assert recorded[0][1].offer_split_ome is False diff --git a/software/tests/test_ome_tiff_saving.py b/software/tests/test_ome_tiff_saving.py index 4bb7b3ccb..566dec6d6 100644 --- a/software/tests/test_ome_tiff_saving.py +++ b/software/tests/test_ome_tiff_saving.py @@ -308,3 +308,337 @@ def test_job_runner_cleanup_flag() -> None: finally: # Signal shutdown (don't call shutdown() since process wasn't started) runner._shutdown_event.set() + + +# --------------------------------------------------------------------------- +# Per-timepoint split (opt-in) helpers and tests +# --------------------------------------------------------------------------- + +# Tiny images keep these tests cheap on disk (the split tests write real files). +SPLIT_IMAGE_SHAPE = (8, 8) +REGION_ID = 1 +FOV = 0 + + +def _build_channels(names): + from control.models import AcquisitionChannel, CameraSettings, IlluminationSettings + + return [ + AcquisitionChannel( + name=name, + display_color="#FFFFFF", + camera=1, + illumination_settings=IlluminationSettings(illumination_channel=name, intensity=5.0), + camera_settings=CameraSettings(exposure_time_ms=10.0, gain_mode=1.0), + z_offset_um=0.0, + ) + for name in names + ] + + +def _make_capture_info(channel, channel_idx: int, time_point: int, save_directory: str, capture_time: float): + from control.core.job_processing import CaptureInfo + import squid.abc + + return CaptureInfo( + position=squid.abc.Pos(x_mm=0.1, y_mm=0.2, z_mm=0.3, theta_rad=None), + z_index=0, + capture_time=capture_time, + configuration=channel, + save_directory=save_directory, + file_id=f"test_{time_point}_{channel_idx}_0", + region_id=REGION_ID, + fov=FOV, + configuration_idx=channel_idx, + z_piezo_um=0.0, + time_point=time_point, + ) + + +def _run_plane(acquisition_info, capture_info, fill_value: int): + """Run one SaveOMETiffJob for a single 2D plane and return its SaveResult.""" + from control.core.job_processing import SaveOMETiffJob, JobImage + + image = np.full(SPLIT_IMAGE_SHAPE, fill_value=fill_value, dtype=np.uint16) + job = SaveOMETiffJob(capture_info=capture_info, capture_image=JobImage(image_array=image)) + job.acquisition_info = acquisition_info + return job.run() + + +def _read_stack(path): + """Return (TZCYX shape, TZCYX data, Pixels element, Plane elements) of an OME-TIFF stack.""" + import tifffile + + with tifffile.TiffFile(path) as tif: + series = tif.series[0] + # squeeze=False keeps singleton T/Z dimensions, which these small stacks rely on. + axes = series.get_axes(False).upper() + shape = series.get_shape(False) + if axes.endswith("S"): # trailing samples-per-pixel axis + axes, shape = axes[:-1], shape[:-1] + assert axes == "TZCYX", axes + data = series.asarray().reshape(shape) + ome_xml = tif.ome_metadata or "" + ns = {"ome": "http://www.openmicroscopy.org/Schemas/OME/2016-06"} + root = ET.fromstring(ome_xml) + return shape, data, root.find("ome:Image/ome:Pixels", ns), root.findall(".//ome:Plane", ns), root + + +def _remove_metadata_files(*metadata_paths): + for metadata_path in metadata_paths: + for path in (metadata_path, metadata_path + ".lock"): + try: + os.remove(path) + except OSError: + pass + + +def test_split_timepoints_round_trip(tmp_path) -> None: + """With split_timepoints=True each timepoint gets its own single-T OME-TIFF file.""" + from control.core.job_processing import AcquisitionInfo + from control.core import utils_ome_tiff_writer as ome_tiff_writer + + channels = _build_channels(["DAPI", "GFP"]) + start_time = time.time() + acquisition_info = AcquisitionInfo( + total_time_points=2, + total_z_levels=1, + total_channels=len(channels), + channel_names=[c.name for c in channels], + experiment_path=str(tmp_path), + time_increment_s=1.5, + physical_size_z_um=4.5, + physical_size_x_um=0.75, + physical_size_y_um=0.8, + split_timepoints=True, + acquisition_start_time=start_time, + ) + + results = {} + capture_infos = {} + metadata_paths = [] + try: + for t in range(2): + # FILE_ID_PADDING is 0 in the test config, so the worker's timepoint folder is "0"/"1". + time_point_dir = tmp_path / f"{t}" + time_point_dir.mkdir(parents=True, exist_ok=True) + for c, channel in enumerate(channels): + capture_info = _make_capture_info( + channel, c, t, str(time_point_dir), capture_time=start_time + 10.0 * t + c + ) + capture_infos[t] = capture_info + results[(t, c)] = _run_plane(acquisition_info, capture_info, fill_value=(t + 1) * 10 + c) + + base_name = ome_tiff_writer.ome_base_name(capture_infos[0]) + metadata_paths = [ + ome_tiff_writer.metadata_temp_path(acquisition_info, capture_infos[t], base_name) for t in range(2) + ] + + # One file per timepoint, under that timepoint's folder. + paths = [tmp_path / f"{t}" / "ome_tiff" / f"{base_name}.ome.tiff" for t in range(2)] + for path in paths: + assert path.exists(), f"expected per-timepoint stack at {path}" + assert not (tmp_path / "ome_tiff").exists(), "split mode must not write the experiment-level stack" + + ns = {"ome": "http://www.openmicroscopy.org/Schemas/OME/2016-06"} + delta_ts = [] + for t, path in enumerate(paths): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + shape, data, pixels, planes, root = _read_stack(path) + assert not caught, [str(w.message) for w in caught] + assert shape == (1, 1, len(channels), *SPLIT_IMAGE_SHAPE) + # The file itself records which global timepoint it holds. + description = root.find("ome:Image/ome:Description", ns) + assert description is not None and f"Timepoint {t}" in description.text + for c in range(len(channels)): + np.testing.assert_array_equal(data[0, 0, c], (t + 1) * 10 + c) + assert pixels is not None + assert pixels.get("SizeT") == "1" + assert pixels.get("SizeZ") == "1" + assert pixels.get("SizeC") == str(len(channels)) + assert float(pixels.get("TimeIncrement", "nan")) == pytest.approx(1.5) + assert len(planes) == len(channels) + for plane in planes: + # TheT is the index inside this file, which holds a single timepoint. + assert plane.get("TheT") == "0" + delta_ts.append(min(float(plane.get("DeltaT", "nan")) for plane in planes)) + + # DeltaT stays relative to the acquisition start, so later files have later deltas. + assert delta_ts[0] >= 0.0 + assert delta_ts[1] >= delta_ts[0] + assert delta_ts[1] == pytest.approx(10.0, abs=1e-3) + + # Each timepoint tracks its own progress file. + assert metadata_paths[0] != metadata_paths[1] + + # The last channel of each timepoint finalizes that timepoint's file. + for t, path in enumerate(paths): + assert results[(t, 0)].unit_complete is False + assert results[(t, 1)].unit_complete is True + assert results[(t, 1)].unit_paths == (os.path.abspath(str(path)),) + assert results[(t, 1)].unit_kind == "file" + + # Progress files are removed once each stack completes. + for metadata_path in metadata_paths: + assert not os.path.exists(metadata_path), f"stale progress file left behind: {metadata_path}" + finally: + _remove_metadata_files(*metadata_paths) + + +def test_unsplit_two_timepoints_single_file(tmp_path) -> None: + """Default (split_timepoints=False) still produces one multi-T stack per FOV.""" + from control.core.job_processing import AcquisitionInfo + from control.core import utils_ome_tiff_writer as ome_tiff_writer + + channels = _build_channels(["DAPI", "GFP"]) + start_time = time.time() + acquisition_info = AcquisitionInfo( + total_time_points=2, + total_z_levels=1, + total_channels=len(channels), + channel_names=[c.name for c in channels], + experiment_path=str(tmp_path), + time_increment_s=1.5, + ) + assert acquisition_info.split_timepoints is False + + results = {} + capture_info = None + metadata_path = None + try: + for t in range(2): + time_point_dir = tmp_path / f"{t}" + time_point_dir.mkdir(parents=True, exist_ok=True) + for c, channel in enumerate(channels): + capture_info = _make_capture_info( + channel, c, t, str(time_point_dir), capture_time=start_time + 10.0 * t + c + ) + results[(t, c)] = _run_plane(acquisition_info, capture_info, fill_value=(t + 1) * 10 + c) + + base_name = ome_tiff_writer.ome_base_name(capture_info) + metadata_path = ome_tiff_writer.metadata_temp_path(acquisition_info, capture_info, base_name) + + output_path = tmp_path / "ome_tiff" / f"{base_name}.ome.tiff" + assert output_path.exists() + assert not (tmp_path / "0" / "ome_tiff").exists() + + shape, data, pixels, planes, root = _read_stack(output_path) + assert shape == (2, 1, len(channels), *SPLIT_IMAGE_SHAPE) + # No per-timepoint provenance is added to the unsplit stack. + assert ( + root.find("ome:Image/ome:Description", {"ome": "http://www.openmicroscopy.org/Schemas/OME/2016-06"}) is None + ) + for t in range(2): + for c in range(len(channels)): + np.testing.assert_array_equal(data[t, 0, c], (t + 1) * 10 + c) + assert pixels.get("SizeT") == "2" + assert sorted(plane.get("TheT") for plane in planes) == ["0", "0", "1", "1"] + + # Only the very last plane of the whole run finalizes the single file. + assert results[(0, 1)].unit_complete is False + assert results[(1, 1)].unit_complete is True + assert results[(1, 1)].unit_paths == (os.path.abspath(str(output_path)),) + assert not os.path.exists(metadata_path) + finally: + if metadata_path: + _remove_metadata_files(metadata_path) + + +def test_split_timepoints_simulation_mode(tmp_path) -> None: + """Simulated disk I/O with split enabled writes nothing but keeps the plane identity.""" + import control._def as _def + from control.core.job_processing import AcquisitionInfo + + channels = _build_channels(["DAPI"]) + acquisition_info = AcquisitionInfo( + total_time_points=2, + total_z_levels=1, + total_channels=1, + channel_names=["DAPI"], + experiment_path=str(tmp_path), + split_timepoints=True, + acquisition_start_time=time.time(), + ) + + original_enabled = _def.SIMULATED_DISK_IO_ENABLED + original_speed = _def.SIMULATED_DISK_IO_SPEED_MB_S + _def.SIMULATED_DISK_IO_ENABLED = True + _def.SIMULATED_DISK_IO_SPEED_MB_S = 10000.0 + try: + for t in range(2): + time_point_dir = tmp_path / f"{t}" + time_point_dir.mkdir(parents=True, exist_ok=True) + capture_info = _make_capture_info(channels[0], 0, t, str(time_point_dir), capture_time=time.time()) + result = _run_plane(acquisition_info, capture_info, fill_value=t) + + assert result.unit_complete is False + assert result.unit_paths == () + assert result.bytes_written > 0 + assert result.time_point == t + assert result.region_id == str(REGION_ID) + assert result.fov == FOV + assert result.channel_idx == 0 + assert not (time_point_dir / "ome_tiff").exists() + finally: + _def.SIMULATED_DISK_IO_ENABLED = original_enabled + _def.SIMULATED_DISK_IO_SPEED_MB_S = original_speed + + +def test_split_timepoints_abort_does_not_leak_into_next_timepoint(tmp_path) -> None: + """An aborted timepoint leaves its own stack incomplete; the next timepoint still finalizes.""" + from control.core.job_processing import AcquisitionInfo + from control.core import utils_ome_tiff_writer as ome_tiff_writer + + channels = _build_channels(["DAPI", "GFP"]) + start_time = time.time() + acquisition_info = AcquisitionInfo( + total_time_points=2, + total_z_levels=1, + total_channels=len(channels), + channel_names=[c.name for c in channels], + experiment_path=str(tmp_path), + split_timepoints=True, + acquisition_start_time=start_time, + ) + + metadata_paths = [] + try: + # t=0 aborts after one of two channels. + dir_t0 = tmp_path / "0" + dir_t0.mkdir(parents=True, exist_ok=True) + info_t0 = _make_capture_info(channels[0], 0, 0, str(dir_t0), capture_time=start_time) + result_t0 = _run_plane(acquisition_info, info_t0, fill_value=1) + assert result_t0.unit_complete is False + + # t=1 runs to completion. + dir_t1 = tmp_path / "1" + dir_t1.mkdir(parents=True, exist_ok=True) + results_t1 = [] + for c, channel in enumerate(channels): + info_t1 = _make_capture_info(channel, c, 1, str(dir_t1), capture_time=start_time + 10.0 + c) + results_t1.append(_run_plane(acquisition_info, info_t1, fill_value=20 + c)) + + base_name = ome_tiff_writer.ome_base_name(info_t0) + metadata_paths = [ + ome_tiff_writer.metadata_temp_path(acquisition_info, info_t0, base_name), + ome_tiff_writer.metadata_temp_path(acquisition_info, info_t1, base_name), + ] + + path_t0 = dir_t0 / "ome_tiff" / f"{base_name}.ome.tiff" + path_t1 = dir_t1 / "ome_tiff" / f"{base_name}.ome.tiff" + assert path_t0.exists(), "the aborted timepoint's partial stack is still on disk" + assert path_t1.exists() + + # t=1 finalized on its own progress file, t=0's is still pending. + assert results_t1[-1].unit_complete is True + assert results_t1[-1].unit_paths == (os.path.abspath(str(path_t1)),) + assert not os.path.exists(metadata_paths[1]) + assert os.path.exists(metadata_paths[0]), "the aborted timepoint keeps its own progress file" + + t0_metadata = ome_tiff_writer.load_metadata(metadata_paths[0]) + assert t0_metadata[ome_tiff_writer.COMPLETED_KEY] is False + assert t0_metadata[ome_tiff_writer.SAVED_COUNT_KEY] == 1 + assert t0_metadata[ome_tiff_writer.EXPECTED_COUNT_KEY] == len(channels) + finally: + _remove_metadata_files(*metadata_paths)