diff --git a/docs/refactor/S1-imagexpress-installed-338.rst b/docs/refactor/S1-imagexpress-installed-338.rst new file mode 100644 index 0000000000..42eb25f1de --- /dev/null +++ b/docs/refactor/S1-imagexpress-installed-338.rst @@ -0,0 +1,99 @@ +Installed ImageXpress inventory acceptance (#338) +================================================ + +Parent integration owner. Feature head 2a4e6a103b602e4afc144622239b0b4cf0b071a5 +was merged normally with main 7b0ec3f5ab5a35a586d77c480fb7d5d6b1c85ba0 in +an isolated persistent worktree, producing candidate +69af3be07962ba66cdb0d3186bcdaa5734e54fb9. The five production files in #338 +are unchanged by integration. No agent worktree or installed user environment +was edited. This receipt supplements S1-imagexpress-readonly-inventory-337.rst. + +Installed boundary +------------------ + +A fresh private Python 3.12.3 venv installed the locally built OpenHCS wheel +with --ignore-installed --no-index --no-deps --no-cache-dir. Existing paired +PolyStore 0.3.0 and heavy dependencies were reused read-only; no package, +scientific dataset or Fiji download occurred. OpenHCS import origin was verified +in that private venv, and all five changed installed production files matched +the candidate source byte-for-byte. + +Wheel: openhcs-0.8.7-cp311-abi3-linux_x86_64.whl, 4174492 bytes. +SHA256: 5a208547d78f34b585a1da4f0613d64f8236bb72d1e96e16a5c2f88f5525b7ae. +Build: 6.55 seconds, 250.24 MiB RSS, one CPU with a 512 MiB bound. +Source submodules remain uninitialized in this private worktree; they are not +represented as a qualified full-context NRA environment. + +The actual installed ``openhcs.mcp.dev_client shell`` entrypoint maintained +one MCP process, PID 875357, throughout discovery, generation, raw inspection, +initialization and reinspection. Initial and final health were healthy, +packaged resources ready, with no stale source. The one validation lock +serialized heavy work. CPU-only environment and one-thread numeric limits +were set. Observed MCP RSS after initialization was 358372 KiB, not a measured +peak. Available host RAM was 21.6 GiB. No execution server, GUI, viewer or Java +process was launched. Exit left both owned client/server processes absent and +the nonblocking validation lock available; foreign endpoints were untouched. + +Continuous MCP journey +---------------------- + +The public generator made a four-plane, 32x32 engineering plate with one well, +one site, two channels and two Z planes. A first fixture retained explicit +filename coordinates; the second deliberately set include_all_components=false +so the same A01_s001_w1.tif and A01_s001_w2.tif basenames repeat in ZStep_1 +and ZStep_2. Both retained vendor HTD metadata. No image bytes were decoded +outside MCP. + +For the repeated-basename fixture, actual exposed read-only plate inspection +and file query returned four distinct physical paths, A01/site 1, channels 1/2, +Z indices 1/2 and timepoint 1, with no parsing failures. The filesystem inventory +contained only the four TIFFs and two generator-produced HTD files: no OpenHCS +workspace metadata was created by raw inspection. The human ``inspect-plate`` +renderer also reported the expected axes and 0.65 pixel calibration. + +The actual ``openhcs_inspect_pipeline_source_artifact_plan`` request initialized +that same owned writable fixture, reporting the workspace-metadata mutation. +It used one complete PipelineDocument with ImageXpress PipelineConfig and an +empty pipeline_steps list. This intentionally tests initialization, not execution +or a processing plan: step_count=0 and axis_count=0. The returned source workspace +has file_count=4 and A01 axis_file_count=4, with four distinct virtual names +and the exact original physical references. The native calibration authority +emitted XY spacing (0.65, 0.65) in micrometers; no Z spacing claim is made. + +Reinspection and query with auto detection selected the prepared-workspace +owner, correctly retaining the ImageXpress parser. Every well/site/channel/Z/time +identity and exact physical source path matched the raw inventory. The human +renderer displayed each virtual-to-physical route and calibration. The four +TIFFs and both original HTD files passed SHA256 checks after initialization. +Only the expected openhcs_metadata.json and its lock were added to the fixture. + +The retained verifier independently checks those recorded MCP payloads, +source identities, calibration, expected initialization limits and original +file hashes. It passed. One initial read-only configuration reflection request +used ``scope`` instead of required ``config_type`` and was rejected at the tool +boundary. It was corrected using live schema reflection before mutation. The +original rejection remains in the transcript and shell exit 1; it is not hidden +or relabeled as a wholly error-free shell session. + +Evidence and limits +------------------- + +``receipts/S1-imagexpress-installed-338-20261001.tar.gz`` preserves exact stdin, +stdout, timing, launcher, generator fixtures, complete submitted source, original +hash manifest, verifier, verification result and build receipts. +Archive SHA256: 2afac8c852d194ec1f89e55dd7d63a06bee21efd76fcdcb34c33e5e7a0a2f21b. + +This is installed/live acceptance for #338's read-only inventory and native +initialization boundary, plus the separate source family/MRO evidence in #338. +It is not a full-context NRA certificate, numeric processing acceptance, UI +acceptance, blinded biological success, accuracy improvement or completion of +S1/R0/L0/S2-S8/D1-D4. Earlier incomplete R1 evidence remains incomplete. + +Persistent private candidate and evidence roots: + +* /home/ts/wt/openhcs-imagexpress-installed-parent-20261001 +* /home/ts/wt/openhcs-issue-batch-20260929/imagexpress-installed-20261001 + +Owned disposable build/runtime cache: +/home/ts/.cache/agent-scratch/imagexpress-installed-20261001, 56 KiB after exit. +Its build records are archived; it contains no source, session or scientific data. diff --git a/docs/refactor/S1-imagexpress-readonly-inventory-337.rst b/docs/refactor/S1-imagexpress-readonly-inventory-337.rst new file mode 100644 index 0000000000..4764f15924 --- /dev/null +++ b/docs/refactor/S1-imagexpress-readonly-inventory-337.rst @@ -0,0 +1,185 @@ +S1 issue337: read-only acquisition path ownership +================================================= + +Implementation owner: Arendt. Integration/installed acceptance owner: parent. +Persistent worktree: /home/ts/wt/openhcs-imagexpress-readonly-inventory-337-20261001. +Branch: fix/imagexpress-readonly-inventory-337-20261001. Created at fetched main +8c512d404f8707a6a1be311406c0af2a089360f7, normally integrated merged333 main +f24a828da16691b83284622a01e1765b1d147e66 before shared handler changes. +Completed334 source and evidence are untouched. No installed/live/scientific work. + +Read before decisions: complete dispatch S1-IMAGEEXPRESS-READONLY-INVENTORY- +20261001.rst, binding GOAL-SCOPE, canonical 00-RULES/01-INDEX, full NRA SKILL and +batching, authoritative refactor-audit.skill SKILL, patterns README, full +BOUND/IMPL/MEMB/identity examples and surface-receipt. Antipattern review is actual +source review, not a global NRA proof. + +Before-source witnesses and ownership decision +---------------------------------------------- + +BOUND-2: PlateInspectionFilenameParser.parse at plate_inspection_service.py:549 +strips Path.name before parsing. PlateImageInventory._record at +plate_image_inventory.py:268 separately asks only the filename parser. Both bypass +ImageXpressHandler's existing TimePoint/ZStep acquisition interpretation at +imagexpress.py:128-290. Initialized virtual paths already carry the correct axes. + +IMPL-12/13: _flatten_timepoints, _flatten_zsteps and _flatten_indexed_folders repeat +traversal, folder recognition and coordinate replacement at different depths. +Move, do not copy, that format interpretation into small independent cooperative +TimePoint/ZStep hooks. Shared basename decode, path-coordinate composition and +mapping collection belong on the actual microscope image-path ancestor. Leaves +declare only format behavior. Initializer and read-only consumers use that owner. + +MEMB-1/2/4 and IDEN-5: existing MicroscopeHandler/AutoRegisterMeta own discovery. +No parser roster, microscope switch, mirrored DTO or parallel address store. +Reuse FilenameParseResult, its nominal components and SourcePixelRef. Keep raw +physical paths distinct even when basenames repeat. Metadata JSON remains the +existing external mapping; raw inventory is derived read-only state. + +Before census at integrated main: original debt_census.py, existing Python3.14, +all microscopes sources parsed; exact totals and per-file evidence archived with +validation. Source decision also traces PlateImageInventory.from_orchestrator, +PlateFileInventory.from_handler and inspection/query call sites. This source +census is a screening observation, not global detector/proof coverage. + +Shared-file sequencing: checked open PRs.333 owned imagexpress.py and filename +construction. Posted precise owner coordination on333 before touching it; parent +then explicitly verified333 merged at f24a828. Normal merge completed before +handler edits; no Averroes branch/worktree edits, no change to producer spelling. + +Acceptance and resource ownership +--------------------------------- + +Fresh owned raw fixture, never previously initialized: four physical planes, +same A01/site1 basenames across channels1/2 and Z1/2, time1, correct calibration. +Production inspect and inventory/query must retain all coordinates, source paths, +bounded samples/parse/failure/component limits, and unchanged source/metadata bytes. +Then separately initialize through the real source handler and compare native +inventory identities. Preserve other registered-handler controls and diagnostics. + +New-case experiment: a newly declared path-aware acquisition subtype selected +through existing registration must work in the unchanged generic inspector and +inventory. Exercise independent coordinate capabilities in both cooperative MRO +orders, common ancestor identity once. No consumer or registration edits. + +Resource helper warned: available RAM21.2GiB, /home19.6GiB, swap13.7GiB. +Only focused one-CPU/512MiB/60s source shards, existing interpreter/dependencies; +no agent/provider/package/Fiji/data downloads or runtime processes. Owned scratch: +/home/ts/.cache/agent-scratch/openhcs-imagexpress-readonly-inventory-337-20261001, +purpose bounded source census, compiler/test/guard logs and fresh unit fixtures. +Archive receipts, then clean this exact disposable root and owned build binaries. +Persistent worktrees remain. No scientific/held-out/reference files are accessed. + +Working checkpoint: ten production-path tests pass, 4.12s/230664 KiB RSS, one CPU. +Fresh raw four-plane inspection, real file query, explicit native initialization +and reinspection agree on axes and exact physical source paths. Original bytes +remain unchanged; read-only inspection never creates workspace metadata. Flat, +TimePoint-only, ZStep-only and nested paths retain filename/folder precedence. +Bounded skipped/unparsed/duplicate-axis failure receipts remain observable. + +Actual new-case evidence: one SiteAcquisitionHandler declaration composes the new +independent SiteFolders capability with ImageXpressHandler, both base orders. +Existing AutoRegisterMeta selects it through the real factory. Unchanged inspector +and file query retain site7/time3/Z1/2; the cooperative SiteFolders and PathTerminus +each execute once per interpretation, and the shared ancestor occurs once in MRO. +No consumer, registry, parser catalog or dispatch branch was added for the subtype. +Test-only declarations are removed from the existing registry on test teardown. + +Deleted: all three _flatten_* procedures; basename-only inspector decode; +inventory's filename-only delegate; separately supplied parser/metadata pairs in +the image/file inventory API. Existing result-artifact filename interpretation is +unchanged. MicroscopeHandler's filename delegates moved to its actual path ancestor +and were deleted from the oversized class; that class shrinks, not grows. +Shared path decode/composition, folder traversal and mapping collection are on +MicroscopeImagePathParser; format grammar and nominal coordinate choice are small +cooperative TimePoint/ZStep hooks. No second address store, DTO mirror or registry. + +Preserved persisted contracts: external ImageXpress filenames, folders and HTD +metadata unchanged; existing OpenHCS workspace JSON schema unchanged. Initialized +mapping derives the same canonical identity from the same path owner. Ambiguous +physical paths now fail explicitly rather than silently overwriting a source ref. +No migration, installed package or runtime change. + +Original failing reproducer and every attempted test log/XML are retained. +First added 512MiB address-space cap failed in pytest collection (193220 KiB RSS): +mapped-library virtual memory is not resident memory. Subsequent identical-input +measured run remains below 512MiB resident memory; original failure is not erased. +Two subsequent test-fixture mistakes (enum order and DTO field names) are corrected; +their original failures remain archived. No existing assertion was weakened. + +Existing regression closure: 42 service/new-path cases PASS, 11.77s/302392 KiB; +28 inventory/registered-handler/ImageXpress compatibility cases PASS, +7.57s/266464 KiB; 27 producer/parser-diamond/new-path/static-browser-inventory +cases PASS, 6.00s/282932 KiB. No GUI/viewer launched. Both parser diamond orders +are unchanged. Both new path capability orders now also initialize through the +real handler and preserve the same physical references in the native inventory. + +Actual regression found and repaired: result-only OpenHCS inspection's parser +property may raise MetadataNotFoundError, already captured by the existing +_parser diagnostic boundary. The initial optional-self view retried that property +and bypassed its recorded absence. That view was deleted; the caller retains +the existing observation and supplies the real path owner only when available. +Original 41-pass/one-failure service shard is retained. No legacy reader/fallback +or duplicate diagnostic store was introduced; existing warning receipts survive. + +Current pinned source and unchanged guards +----------------------------------------- + +Current tested source: 42ebd233c2bcbd0bb9baa52febda284df0885188. Normally +integrated356 at9bb5e9aaa, then merged parent-shipped334/main +20ca4f82526974f16017021e313e4e6cf265b547 without conflict. The five owned production +files and tests have zero diff across the latter merge; base356 and base20ca also +have zero diff in those production files. No scientific/installed slot was used. + +Production deletion first: 224 lines deleted,152 added, net72 deleted in five +production files versus main20ca. Original microscope census6920 ->6882 code +lines; ImageXpress alone489 ->391. No added string-key reads, literal gets, type +checks/switches, string dispatch, foreign absence probes or broad exceptions. +Three nominal classes added: actual path ancestor and two independent acquisition +capabilities. Five new None identity checks are explicit owned optional/parser +and unmatched-path facts, not foreign probes or hidden absence. No metric waiver. +All microscope files parsed; exact before/diff/after census is archived. + +Final tests at42ebd233: 42 service/path tests PASS,6.38s/302296 KiB; 45 existing +inventory/handler/producer/static-browser controls PASS,11.35s/310860 KiB. +87 distinct cases, CPU0/timeout60, under512MiB RSS, existing Python/dependencies. +Actual inspect-plate generated CLI --help exits0,5.10s/249008 KiB, no MCP launch. +Correctness Ruff E9/F63/F7/F82 and git diff --check PASS. Existing UI assertions +were only migrated to the single handler API; no GUI/viewer was started. + +Unchanged original R0 at3b03785: f24 ->3c7240016 PASS17.69s/87552 KiB; final +356910557 ->9bb5e9aaa PASS17.64s/87560 KiB, zero exceptions/increased measures. +After normal20ca merge the exact before/after selected production bytes are +unchanged; original measured comparison is retained, not relabelled as another run. +Scripts/benchmark have no owned Python diff, so no extra empty-scope pass claimed. + +Unchanged full-context R1 at NRA0844525 and original scripts/check_refactor_r1.py: +base356910557 ->9bb5e9aaa, original roots openhcs/scripts/benchmark plus ALL +recorded dependency Python context. One CPU,512MiB address-space ceiling, +original55s deadline and bounded Git pack mapping8m/128m. INCOMPLETE,exit1, +ScanDeadlineExceeded during parse_python_module55.000s/55.000s, wall56.19s, +264000 KiB RSS. Source materialization completed but parsing did not; no +before/after/descent certificate, scoped substitute or global proof. Later20ca +context was not rescanned; this omission is explicit. The tool cleaned its owned +snapshot directory. Incomplete unrelated NRA tooling did not block implementation. + +Authoritative archive SHA256 reverified: +100fbe8ef89664b866777e87b2c8640a3432e8a10e9188dff81c97942d551bf6. +Actual antipattern review is confirmed by the source witnesses/removed mechanisms +above and behavioral extension evidence, not by an incomplete analyzer pass. + +Archive: receipts/S1-imagexpress-readonly-inventory-337-20261001.tar.gz, complete +logs/XML/census including original failures, subsequent repairs and both normal +merge logs. Fresh extraction/content comparison must succeed before cleanup. +Named disposable scratch and source-only build binaries, plus owned pytest caches +in both S1 worktrees, are moved to recoverable trash after archival. Persistent +source/worktrees and all recorded evidence remain. No installed/package/skill or +managed blind instruction change. Upcoming H003g candidate/runtime is untouched. + +This completes the source fix checkpoint for337, NOT all S1/ZIP. Named remaining +S1 includes plate rendering, config/knowledge/function/authoring, object-state, +viewer/runtime, UI/code-document/state-surface. Separately owned339 generated +connection input projection has an issue/reproducer but no implementation yet; +it will have its own visible draft, not an expansion of334 or338. +Parent owns final installed public generate-inspect-initialize-reinspect journey +before merge. Neither source checks nor incomplete NRA evidence establish that. diff --git a/docs/refactor/receipts/S1-imagexpress-installed-338-20261001.tar.gz b/docs/refactor/receipts/S1-imagexpress-installed-338-20261001.tar.gz new file mode 100644 index 0000000000..779071cfd8 Binary files /dev/null and b/docs/refactor/receipts/S1-imagexpress-installed-338-20261001.tar.gz differ diff --git a/docs/refactor/receipts/S1-imagexpress-readonly-inventory-337-20261001.tar.gz b/docs/refactor/receipts/S1-imagexpress-readonly-inventory-337-20261001.tar.gz new file mode 100644 index 0000000000..a20f8f4507 Binary files /dev/null and b/docs/refactor/receipts/S1-imagexpress-readonly-inventory-337-20261001.tar.gz differ diff --git a/openhcs/agent/services/plate_inspection_service.py b/openhcs/agent/services/plate_inspection_service.py index dae396298f..df278685fb 100644 --- a/openhcs/agent/services/plate_inspection_service.py +++ b/openhcs/agent/services/plate_inspection_service.py @@ -78,6 +78,7 @@ FilenameParser, MetadataComponentValueSet, MetadataHandler, + MicroscopeImagePathParser, ) from polystore.filemanager import FileManager @@ -526,7 +527,7 @@ class PlateInspectionFilenameParser: def parse( self, *, - parser: "FilenameParser | None", + parser: "MicroscopeImagePathParser | None", image_files: tuple[str, ...], bounds: PlateInspectionBounds, ) -> PlateInspectionParsedFileSet: @@ -546,7 +547,7 @@ def parse( for filename in image_files[:parse_limit]: try: - parsed = parser.parse_filename(Path(filename).name) + parsed = parser.parse_image_path(filename) except Exception as exc: failed_count += 1 self._append_failure( @@ -1633,7 +1634,7 @@ def _inspect( record.virtual_path for record in file_inventory.image_records ) parsed = self._filename_parser.parse( - parser=parser, + parser=handler if parser is not None else None, image_files=image_files, bounds=bounds, ) @@ -1965,7 +1966,6 @@ def _plate_file_inventory_for_query( image_inventory = PlateInspectionService._image_inventory( handler, plate_path, - parser, filemanager, warnings, ) @@ -2008,7 +2008,6 @@ def _plate_file_inventory_for_query( PlateInspectionService._image_inventory( handler, plate_path, - parser, filemanager, warnings, ), @@ -2032,7 +2031,6 @@ def _plate_file_inventory( image_inventory = PlateInspectionService._image_inventory( handler, plate_path, - parser, filemanager, warnings, ) @@ -2048,7 +2046,6 @@ def _plate_file_inventory( def _image_inventory( handler: "MicroscopeHandler", plate_path: Path, - parser: "FilenameParser | None", filemanager: "FileManager", warnings: list[AgentWarning], ) -> PlateImageInventory: @@ -2065,8 +2062,7 @@ def _image_inventory( handler.register_workspace_backends(plate_path, filemanager) return PlateImageInventory.from_handler( plate_path=plate_path, - metadata_handler=handler.metadata_handler, - parser=parser, + handler=handler, filemanager=filemanager, backend=handler.get_primary_backend(plate_path, filemanager), source_projection=source_projection, diff --git a/openhcs/core/plate_image_inventory.py b/openhcs/core/plate_image_inventory.py index 9d65a9c9a5..28aa4b2fd8 100644 --- a/openhcs/core/plate_image_inventory.py +++ b/openhcs/core/plate_image_inventory.py @@ -29,6 +29,7 @@ if TYPE_CHECKING: from openhcs.core.orchestrator import PipelineOrchestrator + from openhcs.microscopes.microscope_base import MicroscopeHandler from openhcs.microscopes.microscope_interfaces import ( AnalysisResultDirectory, FilenameParser, @@ -111,8 +112,7 @@ def from_orchestrator( ) return cls.from_handler( plate_path=plate_path, - metadata_handler=handler.metadata_handler, - parser=handler.parser, + handler=handler, filemanager=orchestrator.filemanager, backend=handler.get_primary_backend( orchestrator.plate_path, @@ -127,26 +127,24 @@ def from_handler( cls, *, plate_path: Path, - metadata_handler: "MetadataHandler", - parser: "FilenameParser | None", + handler: "MicroscopeHandler", filemanager: "FileManager", backend: str, source_projection: VirtualWorkspaceSourceProjection | None, all_subdirs: bool = True, ) -> "PlateImageInventory": - source_dataset = metadata_handler.source_dataset(plate_path) + source_dataset = handler.metadata_handler.source_dataset(plate_path) if source_projection is None and source_dataset is not None: - if parser is None: - raise ValueError( - "Exact source datasets require a filename parser for inventory " - "projection." - ) + parser = handler.require_filename_parser( + "Exact source datasets require a filename parser for inventory projection." + ) return cls( plate_path=plate_path, records=tuple( cls._record_from_source_candidate( plate_path=plate_path, candidate=candidate, + handler=handler, parser=parser, filemanager=filemanager, ) @@ -159,7 +157,7 @@ def from_handler( else tuple( str(image_file) for image_file in sorted( - metadata_handler.get_image_files( + handler.metadata_handler.get_image_files( plate_path, all_subdirs=all_subdirs, ) @@ -170,7 +168,7 @@ def from_handler( cls._record( plate_path=plate_path, image_file=image_file, - parser=parser, + handler=handler, projection=source_projection, filemanager=filemanager, backend=backend, @@ -184,6 +182,7 @@ def _record_from_source_candidate( *, plate_path: Path, candidate: "SourceCandidate", + handler: "MicroscopeHandler", parser: "FilenameParser", filemanager: "FileManager", ) -> PlateImageRecord: @@ -195,10 +194,10 @@ def _record_from_source_candidate( ) return PlateImageInventory._record( plate_path=plate_path, - image_file=parser.construct_filename( + image_file=handler.construct_filename( parser.bind_declared_values(address.parsed_component_values()) ), - parser=parser, + handler=handler, projection=None, filemanager=filemanager, backend=candidate.source_ref.backend, @@ -223,7 +222,7 @@ def _record( *, plate_path: Path, image_file: str, - parser: "FilenameParser | None", + handler: "MicroscopeHandler", projection: VirtualWorkspaceSourceProjection | None, filemanager: "FileManager", backend: str, @@ -264,10 +263,9 @@ def _record( } if resolved_source_metadata is not None: metadata.update(dict(resolved_source_metadata)) - if parser is not None: - parsed = parser.parse_filename(image_file) - if parsed: - metadata.update(parsed.wire_mapping()) + parsed = handler.parse_image_path(image_file) + if parsed is not None: + metadata.update(parsed.wire_mapping()) source_file = Path(source_path) metadata["size"] = file_size_label(source_file) metadata["modified"] = file_modified_label(source_file) @@ -980,8 +978,7 @@ def from_handler( cls, *, plate_path: Path, - metadata_handler: "MetadataHandler", - parser: "FilenameParser | None", + handler: "MicroscopeHandler", filemanager: "FileManager", backend: str, path_config=None, @@ -990,13 +987,12 @@ def from_handler( """Build the same file inventory shape when only a handler is available.""" source_projection = PlateImageInventory._projection( plate_path, - metadata_handler, + handler.metadata_handler, filemanager, ) image_inventory = PlateImageInventory.from_handler( plate_path=plate_path, - metadata_handler=metadata_handler, - parser=parser, + handler=handler, filemanager=filemanager, backend=backend, source_projection=source_projection, @@ -1005,15 +1001,15 @@ def from_handler( if path_config is None: result_inventory = PlateResultFileInventory.from_handler( plate_path=plate_path, - metadata_handler=metadata_handler, - parser=parser, + metadata_handler=handler.metadata_handler, + parser=handler.parser, ) else: result_inventory = ( PlateResultFileInventory.from_handler_and_configured_output_root( plate_path=plate_path, - metadata_handler=metadata_handler, - parser=parser, + metadata_handler=handler.metadata_handler, + parser=handler.parser, path_config=path_config, ) ) diff --git a/openhcs/microscopes/imagexpress.py b/openhcs/microscopes/imagexpress.py index 6d70a28ef5..dcad2bcd4d 100644 --- a/openhcs/microscopes/imagexpress.py +++ b/openhcs/microscopes/imagexpress.py @@ -8,7 +8,7 @@ import logging import re from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple, Union, Type +from typing import List, Optional, Tuple, Union, Type from openhcs.constants.constants import AllComponents, Backend, Microscope from openhcs.core.components.parser_metaprogramming import ( @@ -16,7 +16,6 @@ ) from polystore.exceptions import MetadataNotFoundError from polystore.filemanager import FileManager -from polystore.virtual_workspace import SourcePixelRef from openhcs.microscopes.microscope_base import MicroscopeHandler from openhcs.microscopes.microscope_interfaces import ( DiskImageFileListingMetadataHandler, @@ -24,12 +23,51 @@ FilenameParser, MetadataComponentValueSet, MetadataHandler, + MicroscopeImagePathParser, ) logger = logging.getLogger(__name__) -class ImageXpressHandler(MicroscopeHandler): +class ImageXpressTimePointPaths(MicroscopeImagePathParser): + """TimePoint folders independently own the acquisition time coordinate.""" + + _timepoint_folder_pattern = re.compile(r"TimePoint[_-]?(\d+)", re.IGNORECASE) + + def image_path_components( + self, path: Path + ) -> tuple[tuple[AllComponents, int], ...]: + return ( + *super().image_path_components(path), + *self.indexed_folder_components( + path, + AllComponents.TIMEPOINT, + self._timepoint_folder_pattern, + ), + ) + + +class ImageXpressZStepPaths(MicroscopeImagePathParser): + """ZStep folders independently own the acquisition depth coordinate.""" + + _zstep_folder_pattern = re.compile(r"ZStep[_-]?(\d+)", re.IGNORECASE) + + def image_path_components( + self, path: Path + ) -> tuple[tuple[AllComponents, int], ...]: + return ( + *super().image_path_components(path), + *self.indexed_folder_components( + path, + AllComponents.Z_INDEX, + self._zstep_folder_pattern, + ), + ) + + +class ImageXpressHandler( + ImageXpressTimePointPaths, ImageXpressZStepPaths, MicroscopeHandler +): """ MicroscopeHandler implementation for Molecular Devices ImageXpress systems. @@ -106,12 +144,10 @@ def _build_virtual_mapping( f"🔄 BUILDING VIRTUAL MAPPING: ImageXpress folder flattening for {plate_path}" ) - # Initialize mapping dict (PLATE-RELATIVE paths) - workspace_mapping = {} - - # Flatten TimePoint and ZStep folders virtually (starting from plate root) - self._flatten_timepoints(plate_path, filemanager, workspace_mapping, plate_path) - self._flatten_zsteps(plate_path, filemanager, workspace_mapping, plate_path) + workspace_mapping = self.acquisition_workspace_mapping( + self.metadata_handler.get_image_files(plate_path, all_subdirs=True), + backend=Backend.DISK.value, + ) logger.info( f"Built {len(workspace_mapping)} virtual path mappings for ImageXpress" @@ -123,173 +159,6 @@ def _build_virtual_mapping( # Return the image directory return plate_path - def _flatten_zsteps( - self, - directory: Path, - fm: FileManager, - mapping_dict: Dict[str, SourcePixelRef], - plate_path: Path, - folder_components: tuple[tuple[AllComponents, int], ...] = (), - ): - """ - Process Z-step folders virtually by building plate-relative mapping dict. - - Args: - directory: Path to directory that might contain Z-step folders - fm: FileManager instance for file operations - mapping_dict: Dict to populate with virtual → real mappings - plate_path: Plate root path for computing relative paths - """ - zstep_pattern = re.compile(r"ZStep[_-]?(\d+)", re.IGNORECASE) - - # Find and process Z-step folders - self._flatten_indexed_folders( - directory=directory, - fm=fm, - folder_pattern=zstep_pattern, - component=AllComponents.Z_INDEX, - folder_type="ZStep", - mapping_dict=mapping_dict, - plate_path=plate_path, - folder_components=folder_components, - ) - - def _flatten_timepoints( - self, - directory: Path, - fm: FileManager, - mapping_dict: Dict[str, SourcePixelRef], - plate_path: Path, - ): - """ - Process TimePoint folders virtually by building plate-relative mapping dict. - - Args: - directory: Path to directory that might contain TimePoint folders - fm: FileManager instance for file operations - mapping_dict: Dict to populate with virtual → real mappings - plate_path: Plate root path for computing relative paths - """ - timepoint_pattern = re.compile(r"TimePoint[_-]?(\d+)", re.IGNORECASE) - - # First flatten Z-steps within each timepoint folder (if they exist) - entries = fm.list_dir(directory, Backend.DISK.value) - subdirs = [ - Path(directory) / entry - for entry in entries - if (Path(directory) / entry).is_dir() - ] - - for subdir in subdirs: - match = timepoint_pattern.search(subdir.name) - if match: - self._flatten_zsteps( - subdir, - fm, - mapping_dict, - plate_path, - folder_components=((AllComponents.TIMEPOINT, int(match.group(1))),), - ) - - # Then flatten timepoint folders themselves - self._flatten_indexed_folders( - directory=directory, - fm=fm, - folder_pattern=timepoint_pattern, - component=AllComponents.TIMEPOINT, - folder_type="TimePoint", - mapping_dict=mapping_dict, - plate_path=plate_path, - ) - - def _flatten_indexed_folders( - self, - directory: Path, - fm: FileManager, - folder_pattern: re.Pattern, - component: AllComponents, - folder_type: str, - mapping_dict: Dict[str, SourcePixelRef], - plate_path: Path, - folder_components: tuple[tuple[AllComponents, int], ...] = (), - ): - """ - Generic helper to flatten indexed folders virtually (TimePoint_N, ZStep_M, etc.). - - Builds plate-relative mapping dict instead of moving files. - - Args: - directory: Path to directory that might contain indexed folders - fm: FileManager instance for file operations - folder_pattern: Regex pattern to match folder names (must have one capture group for index) - component_name: Component to update in metadata (e.g., 'z_index', 'timepoint') - folder_type: Human-readable folder type name (for logging) - mapping_dict: Dict to populate with virtual → real mappings - plate_path: Plate root path for computing relative paths - """ - # List all subdirectories - entries = fm.list_dir(directory, Backend.DISK.value) - subdirs = [ - Path(directory) / entry - for entry in entries - if (Path(directory) / entry).is_dir() - ] - - # Find indexed folders - indexed_folders = [] - for d in subdirs: - match = folder_pattern.search(d.name) - if match: - index = int(match.group(1)) - indexed_folders.append((index, d)) - - if not indexed_folders: - return - - # Sort by index - indexed_folders.sort(key=lambda x: x[0]) - - logger.info( - f"Found {len(indexed_folders)} {folder_type} folders. Building virtual mapping..." - ) - - # Process each folder - for index, folder in indexed_folders: - logger.debug(f"Processing {folder.name} ({folder_type}={index})") - - # List all files in the folder - img_files = fm.list_files(str(folder), Backend.DISK.value) - - for img_file in img_files: - if not fm.is_file(img_file, Backend.DISK.value): - continue - - filename = Path(img_file).name - - # Parse existing filename - metadata = self.parser.parse_filename(filename) - if not metadata: - continue - - # Reconstruct filename - new_filename = self.parser.construct_filename( - metadata.with_values((*folder_components, (component, index))) - ) - - # Build PLATE-RELATIVE virtual flattened path (at plate root, not in subdirectory) - # This makes images appear at plate root in virtual workspace - virtual_relative = new_filename - - # Build PLATE-RELATIVE real path (in subfolder) - real_relative = Path(img_file).relative_to(plate_path).as_posix() - - # Add to mapping (both plate-relative) - mapping_dict[virtual_relative] = SourcePixelRef( - backend=Backend.DISK.value, - backend_address=real_relative, - ) - logger.debug(f" Mapped: {virtual_relative} → {real_relative}") - class ImageXpressFilenameParser(FilenameParser): """ diff --git a/openhcs/microscopes/microscope_base.py b/openhcs/microscopes/microscope_base.py index 24275e5779..2c09e82dc0 100644 --- a/openhcs/microscopes/microscope_base.py +++ b/openhcs/microscopes/microscope_base.py @@ -32,9 +32,9 @@ # Import interfaces from the base interfaces module from openhcs.microscopes.microscope_interfaces import ( - FilenameParseResult, FilenameParser, MetadataHandler, + MicroscopeImagePathParser, ) logger = logging.getLogger(__name__) @@ -177,6 +177,7 @@ def detect( class MicroscopeHandler( MetadataMicroscopeDetector, + MicroscopeImagePathParser, ViewerMicroscopeHandlerABC, ABC, metaclass=AutoRegisterMeta, @@ -746,16 +747,6 @@ def _build_virtual_mapping( f"Handlers that override initialize_workspace() completely (like OMERO, OpenHCS) don't need this." ) - # Delegate methods to parser - def parse_filename(self, filename: str) -> Optional[FilenameParseResult]: - """Delegate to parser.""" - return self.parser.parse_filename(filename) - - def construct_filename(self, components: FilenameParseResult) -> str: - """Delegate nominal filename construction to the parser.""" - - return self.parser.construct_filename(components) - def auto_detect_patterns( self, folder_path: Union[str, Path], diff --git a/openhcs/microscopes/microscope_interfaces.py b/openhcs/microscopes/microscope_interfaces.py index 941953a2bc..510cb7f639 100644 --- a/openhcs/microscopes/microscope_interfaces.py +++ b/openhcs/microscopes/microscope_interfaces.py @@ -11,6 +11,7 @@ from collections.abc import Hashable, Iterable, Iterator from dataclasses import dataclass from pathlib import Path +import re from typing import ClassVar, Dict, Mapping, Optional, TYPE_CHECKING, Tuple, Union from openhcs.constants.constants import Backend, AllComponents from openhcs.core.components.parser_metaprogramming import ( @@ -30,6 +31,7 @@ ViewerMicroscopeHandlerABC, ) from polystore.filemanager import FileManager +from polystore.virtual_workspace import SourcePixelRef if TYPE_CHECKING: from openhcs.core.source_projection import SourcePlaneDataset, SourceProjection @@ -376,6 +378,80 @@ def construct_filename(self, components: FilenameParseResult) -> str: pass +class MicroscopeImagePathParser(ABC): + """Interpret acquisition paths using the microscope's existing filename owner. + + Independent folder capabilities cooperate through ``image_path_components``; + consumers never need to know which acquisition layout supplies an axis. + """ + + parser: FilenameParser | None + + def parse_filename(self, filename: str) -> FilenameParseResult | None: + """Delegate to the declared filename parser, when available.""" + return None if self.parser is None else self.parser.parse_filename(filename) + + def construct_filename(self, components: FilenameParseResult) -> str: + """Delegate nominal filename construction to the parser.""" + return self.require_filename_parser( + "Filename construction requires a filename parser" + ).construct_filename(components) + + def require_filename_parser(self, absence_message: str) -> FilenameParser: + """Require this owner's optional parser with the caller's operation context.""" + if self.parser is None: + raise ValueError(absence_message) + return self.parser + + def parse_image_path(self, relative_path: str) -> FilenameParseResult | None: + """Decode once, then compose nominal coordinates from the relative path.""" + path = Path(relative_path) + parsed = self.parse_filename(path.name) + return ( + None + if parsed is None + else parsed.with_values(self.image_path_components(path)) + ) + + def image_path_components( + self, path: Path + ) -> tuple[tuple[AllComponents, int], ...]: + """Identity terminus for cooperative acquisition-coordinate capabilities.""" + return () + + @staticmethod + def indexed_folder_components( + path: Path, + component: AllComponents, + pattern: re.Pattern[str], + ) -> tuple[tuple[AllComponents, int], ...]: + """Project a declared folder grammar onto an existing nominal component.""" + return tuple( + (component, int(match.group(1))) + for part in path.parent.parts + if (match := pattern.search(part)) is not None + ) + + def acquisition_workspace_mapping( + self, + image_paths: Iterable[str], + *, + backend: str, + ) -> dict[str, SourcePixelRef]: + """Collect the same interpreted acquisition identities for initialization.""" + mapping: dict[str, SourcePixelRef] = {} + for relative_path in image_paths: + parsed = self.parse_image_path(relative_path) + if parsed is None: + continue + virtual_path = self.construct_filename(parsed) + source_ref = SourcePixelRef(backend=backend, backend_address=relative_path) + if virtual_path in mapping and mapping[virtual_path] != source_ref: + raise ValueError(f"Acquisition paths collide at {virtual_path!r}") + mapping[virtual_path] = source_ref + return mapping + + class MetadataHandler(ViewerMetadataHandlerABC, ABC): """ Abstract base class for handling microscope metadata. diff --git a/tests/pyqt_gui/test_image_browser_filter_controller.py b/tests/pyqt_gui/test_image_browser_filter_controller.py index 0d67ca993e..479d6f5223 100644 --- a/tests/pyqt_gui/test_image_browser_filter_controller.py +++ b/tests/pyqt_gui/test_image_browser_filter_controller.py @@ -16,7 +16,10 @@ PlateResultFileInventory, PlateResultFileRecord, ) -from openhcs.microscopes.microscope_interfaces import AnalysisResultDirectory +from openhcs.microscopes.microscope_interfaces import ( + AnalysisResultDirectory, + MicroscopeImagePathParser, +) from openhcs.pyqt_gui.widgets.image_browser import ( ImageBrowserFilterController, ImageBrowserWidget, @@ -145,7 +148,7 @@ def analysis_result_directories(self, _plate_path: Path): ) -class _InventoryMicroscopeHandler: +class _InventoryMicroscopeHandler(MicroscopeImagePathParser): def __init__(self, handler_result_dir: Path) -> None: self.metadata_handler = _InventoryMetadataHandler(handler_result_dir) self.parser = _InventoryParser() @@ -388,8 +391,7 @@ def test_plate_file_inventory_from_handler_matches_browser_file_shape( inventory = PlateFileInventory.from_handler( plate_path=plate_root, - metadata_handler=_InventoryMetadataHandler(handler_result_dir), - parser=_InventoryParser(), + handler=_InventoryMicroscopeHandler(handler_result_dir), filemanager=FileManager({"disk": DiskStorageBackend()}), backend="disk", path_config=config.path_planning_config, @@ -427,8 +429,7 @@ def test_plate_file_inventory_query_returns_unified_browser_records( inventory = PlateFileInventory.from_handler( plate_path=plate_root, - metadata_handler=_InventoryMetadataHandler(handler_result_dir), - parser=_InventoryParser(), + handler=_InventoryMicroscopeHandler(handler_result_dir), filemanager=FileManager({"disk": DiskStorageBackend()}), backend="disk", all_subdirs=True, diff --git a/tests/unit/agent/test_imagexpress_readonly_paths.py b/tests/unit/agent/test_imagexpress_readonly_paths.py new file mode 100644 index 0000000000..8e4470008a --- /dev/null +++ b/tests/unit/agent/test_imagexpress_readonly_paths.py @@ -0,0 +1,327 @@ +"""Acquisition-path ownership through the real read-only inspection consumer.""" + +from pathlib import Path +import re + +import pytest +from polystore.base import ensure_storage_registry, storage_registry +from polystore.filemanager import FileManager + +from openhcs.agent.dto.plate import ( + PlateFileQueryRequest, + PlateInspectionBounds, + PlatePathInspectionRequest, +) +from openhcs.agent.path_policy import AgentPathPolicy +from openhcs.agent.services.plate_inspection_service import ( + PlateInspectionFilenameParser, + PlateInspectionService, +) +from openhcs.constants.constants import AllComponents +from openhcs.core.plate_image_inventory import PlateFileInventory +from openhcs.microscopes import create_microscope_handler +from openhcs.microscopes.imagexpress import ImageXpressHandler +from openhcs.microscopes.microscope_base import MICROSCOPE_HANDLERS +from openhcs.microscopes.microscope_interfaces import MicroscopeImagePathParser + + +def write_plate(root: Path) -> Path: + plate = root / "plate" + for z in (1, 2): + folder = plate / "TimePoint_1" / f"ZStep_{z}" + folder.mkdir(parents=True) + for channel in (1, 2): + (folder / f"plate_A01_s1_w{channel}.tif").write_bytes(b"source") + (plate / "plate.HTD").write_text( + "\n".join( + ( + '"XSites", 1', + '"YSites", 1', + '"PixelSizeUM", 0.5', + '"WaveName1", "DAPI"', + '"WaveName2", "GFP"', + ) + ), + encoding="utf-8", + ) + return plate + + +def service_for(plate: Path): + return PlateInspectionService( + AgentPathPolicy.with_roots(readable_roots=(plate.parent,), writable_roots=()) + ) + + +def inspect_plate(plate: Path, microscope_type="imagexpress", **bounds): + return service_for(plate).inspect( + PlatePathInspectionRequest.from_fields( + plate_path=str(plate), + microscope_type=microscope_type, + **bounds, + ) + ) + + +def inventory_for(plate: Path): + ensure_storage_registry() + filemanager = FileManager(dict(storage_registry)) + handler = create_microscope_handler( + "imagexpress", plate_folder=plate, filemanager=filemanager + ) + return ( + handler, + filemanager, + PlateFileInventory.from_handler( + plate_path=plate, + handler=handler, + filemanager=filemanager, + backend=handler.get_primary_backend(plate, filemanager), + ), + ) + + +def identity(record): + return frozenset( + (c, record.metadata[c.value]) for c in AllComponents + ), record.source_path + + +def test_raw_inspection_retains_folder_z_and_never_initializes(tmp_path: Path): + plate = write_plate(tmp_path) + before = { + p.relative_to(plate): p.read_bytes() for p in plate.rglob("*") if p.is_file() + } + result = inspect_plate(plate, max_sample_files=4, max_component_values=10) + assert not result.errors + assert result.image_files.count == result.parse_summary.parsed_file_count == 4 + assert result.pixel_size == 0.5 + z = next( + item for item in result.components if item.component is AllComponents.Z_INDEX + ) + assert z.count == 2 + assert { + p.relative_to(plate): p.read_bytes() for p in plate.rglob("*") if p.is_file() + } == before + assert not (plate / "openhcs_metadata.json").exists() + + +def test_real_inventory_query_and_initialization_agree_on_physical_identity(tmp_path): + plate = write_plate(tmp_path) + handler, filemanager, raw = inventory_for(plate) + expected = { + ( + frozenset( + ( + (AllComponents.WELL, "A01"), + (AllComponents.SITE, 1), + (AllComponents.CHANNEL, channel), + (AllComponents.Z_INDEX, z), + (AllComponents.TIMEPOINT, 1), + ) + ), + str(plate / "TimePoint_1" / f"ZStep_{z}" / f"plate_A01_s1_w{channel}.tif"), + ) + for z in (1, 2) + for channel in (1, 2) + } + assert {identity(record) for record in raw.image_records} == expected + query = service_for(plate).query_files( + PlateFileQueryRequest.from_fields( + plate_path=str(plate), + microscope_type="imagexpress", + well="A01", + include_previews=False, + limit=3, + ) + ) + assert not query.errors + assert (query.total_count, query.returned_count, query.truncated_count) == (4, 3, 1) + assert {item.metadata["z_index"] for item in query.records} == {1, 2} + assert not (plate / "openhcs_metadata.json").exists() + original = {p: p.read_bytes() for p in plate.rglob("*") if p.is_file()} + handler.initialize_workspace(plate, filemanager) + prepared = PlateFileInventory.from_handler( + plate_path=plate, + handler=handler, + filemanager=filemanager, + backend=handler.get_primary_backend(plate, filemanager), + ) + assert {identity(record) for record in prepared.image_records} == expected + assert all(p.read_bytes() == data for p, data in original.items()) + inspection = inspect_plate(plate, max_component_values=10) + assert not inspection.errors + assert ( + inspection.image_files.count == inspection.parse_summary.parsed_file_count == 4 + ) + assert ( + next( + c for c in inspection.components if c.component is AllComponents.Z_INDEX + ).count + == 2 + ) + + +@pytest.mark.parametrize( + "relative_path,z,time", + [ + ("plate_A01_s1_w2.tif", 1, 1), + ("TimePoint-3/plate_A01_s1_w2.tif", 1, 3), + ("zstep2/plate_A01_s1_w2.tif", 2, 1), + ("TimePoint_3/ZStep-2/plate_A01_s1_w2_z9_t8.tif", 2, 3), + ], +) +def test_path_owner_preserves_flat_and_folder_precedence(relative_path, z, time): + ensure_storage_registry() + handler = ImageXpressHandler(FileManager(dict(storage_registry))) + parsed = handler.parse_image_path(relative_path) + assert parsed.value_for(AllComponents.Z_INDEX) == z + assert parsed.value_for(AllComponents.TIMEPOINT) == time + assert parsed.value_for(AllComponents.CHANNEL) == 2 + + +def test_parse_bounds_errors_unparsed_and_absence_keep_original_receipts(tmp_path): + plate = write_plate(tmp_path) + inspection = inspect_plate( + plate, max_files_to_parse=2, max_sample_files=1, max_component_values=1 + ) + assert ( + inspection.parse_summary.attempted_file_count, + inspection.parse_summary.skipped_file_count, + ) == (2, 2) + assert inspection.image_files.truncated_file_count == 3 + channel = next( + c for c in inspection.components if c.component is AllComponents.CHANNEL + ) + assert channel.count == 2 and len(channel.values) == 1 + handler, _, _ = inventory_for(plate) + parser = PlateInspectionFilenameParser() + paths = ("ZStep_1/ZStep_2/A01_w1.tif", "not-an-image", "other", "A01_w1.tif") + parsed = parser.parse( + parser=handler, + image_files=paths, + bounds=PlateInspectionBounds( + max_parse_failure_samples=1, + max_files_to_parse=4, + ), + ) + assert (parsed.summary.parsed_file_count, parsed.summary.failed_file_count) == ( + 1, + 3, + ) + assert parsed.summary.truncated_failure_count == 2 + assert parsed.summary.failure_samples[0].filename == paths[0] + assert "replaced more than once" in parsed.summary.failure_samples[0].reason + missing = parser.parse( + parser=None, image_files=paths, bounds=PlateInspectionBounds() + ) + assert missing.summary.skipped_file_count == 4 + assert ( + missing.summary.attempted_file_count == missing.summary.failed_file_count == 0 + ) + + +@pytest.mark.parametrize("reverse", [False, True]) +def test_new_declared_capability_through_unchanged_consumers_in_both_mro_orders( + tmp_path, reverse +): + events = [] + + class PathTerminus(MicroscopeImagePathParser): + def image_path_components(self, path): + events.append(("ancestor", str(path))) + return super().image_path_components(path) + + class SiteFolders(PathTerminus): + _site_pattern = re.compile(r"Site_(\d+)") + + def image_path_components(self, path): + events.append(("site", str(path))) + return ( + *super().image_path_components(path), + *self.indexed_folder_components( + path, + AllComponents.SITE, + self._site_pattern, + ), + ) + + # One new subtype declaration: inherited registry, factory and consumers unchanged. + parents = ( + (ImageXpressHandler, SiteFolders) + if reverse + else (SiteFolders, ImageXpressHandler) + ) + key = f"imagexpress_site_folders_{reverse}" + subtype = type("SiteAcquisitionHandler", parents, {"_microscope_type": key}) + try: + plate = write_plate(tmp_path) + source = plate / "TimePoint_1" + renamed = plate / "TimePoint_3" + source.rename(renamed) + site = renamed / "Site_7" + site.mkdir() + for folder in tuple(renamed.glob("ZStep*")): + folder.rename(site / folder.name) + result = inspect_plate(plate, microscope_type=key, max_component_values=10) + assert not result.errors + assert result.handler_class == subtype.__name__ + assert result.image_files.count == result.parse_summary.parsed_file_count == 4 + summaries = {item.component: item for item in result.components} + assert tuple(v.key for v in summaries[AllComponents.SITE].values) == ("7",) + assert tuple(v.key for v in summaries[AllComponents.TIMEPOINT].values) == ("3",) + assert summaries[AllComponents.Z_INDEX].count == 2 + query = service_for(plate).query_files( + PlateFileQueryRequest.from_fields( + plate_path=str(plate), + microscope_type=key, + include_previews=False, + ) + ) + assert not query.errors + assert query.total_count == 4 + assert {item.metadata["site"] for item in query.records} == {7} + # Inspector parses each path twice (inventory and bounded summary); query once. + for path in {path for _, path in events}: + assert events.count(("site", path)) == events.count(("ancestor", path)) == 3 + assert ( + subtype.__mro__.count(PathTerminus) + == subtype.__mro__.count(MicroscopeImagePathParser) + == 1 + ) + assert MICROSCOPE_HANDLERS[key] is subtype + assert not (plate / "openhcs_metadata.json").exists() + filemanager = FileManager(dict(storage_registry)) + handler = create_microscope_handler( + key, plate_folder=plate, filemanager=filemanager + ) + handler.initialize_workspace(plate, filemanager) + prepared = PlateFileInventory.from_handler( + plate_path=plate, + handler=handler, + filemanager=filemanager, + backend=handler.get_primary_backend(plate, filemanager), + ) + assert len(prepared.image_records) == 4 + assert {record.metadata["site"] for record in prepared.image_records} == {7} + assert {record.metadata["timepoint"] for record in prepared.image_records} == { + 3 + } + assert {record.source_path for record in prepared.image_records} == { + item.metadata["source_path"] for item in query.records + } + for path in {path for _, path in events}: + assert events.count(("site", path)) == events.count(("ancestor", path)) + finally: + del MICROSCOPE_HANDLERS[key] + + +def test_initializer_rejects_ambiguous_paths_instead_of_overwriting(tmp_path): + plate = write_plate(tmp_path) + handler, _, _ = inventory_for(plate) + with pytest.raises(ValueError, match="collide"): + handler.acquisition_workspace_mapping( + ("TimePoint_1/A01_w1.tif", "TimePoint_1/A01_s1_w1.tif"), + backend="disk", + ) + assert not (plate / "openhcs_metadata.json").exists()