diff --git a/docs/validation/opera_singleton_172.rst b/docs/validation/opera_singleton_172.rst new file mode 100644 index 0000000000..e2291c7b1f --- /dev/null +++ b/docs/validation/opera_singleton_172.rst @@ -0,0 +1,228 @@ +Opera singleton initialization, issue 172 +======================================= + +Source owner: this implementation branch. Integration, installation, MCP and +merge owner: parent. Base audited: OpenHCS +``5ce9e3087e603aaf5177ea88363ef48a804c1163`` (PR 206 pending at dispatch), +PolyStore gitlink ``91fd7e854ca760a16e7472d8d558b4012e89b6ba``. +Branch: ``fix/opera-singleton-172-20261001``. Worktree: +``/home/ts/wt/openhcs-opera-singleton-172-20261001``. + +Boundary and preserved witness +------------------------------ + +Harmony Index.xml is an external format. The existing OperaPhenixXmlParser +owns stage-coordinate extraction and field remapping. OperaPhenixMetadataHandler +consumes its ``(columns, rows)`` grid and reverses it to ``(rows, columns)``. +No stored format or metadata namespace changes here. + +Parent's installed MCP generation (PID 370288) with OperaPhenix, grid 1x1, +tile 64x64, overlap 0, stage error 0, one wavelength, one z level, four cells +and seed 7, without --openhcs-format, emitted +``plate_grid_dimensions_unavailable``. Artifact-plan with complete +``PipelineDocument(pipeline_config=PipelineConfig(), pipeline_steps=[])`` raised +``OperaPhenixXmlContentError: Could not determine grid size from XML data``. + +Originals, read-only: +``/home/ts/wt/openhcs-issue-batch-20260929/namespace-installed-20261001/fixtures/raw-opera/Images/Index.xml`` +and +``/home/ts/wt/openhcs-issue-batch-20260929/namespace-installed-20261001/live.stdout`` +(generation/compile records at lines 1594-1680). +XML SHA256: +``b3f2c03c8e7b6e7e3196535b934f01f65c7c6b6f6bb22cf6fd6fd308e54db276``. +The XML declares field 1 at finite coordinates (0.000576762, 0.000576762) m. +The base grid reader's ``len(images) > 1`` gate at line 261 rejects this valid +singleton. Colocated channels/planes remain separate singleton groups and fail +the same gate. No original fixture, metadata or live process is modified. + +Ownership decision and exact patterns considered +------------------------------------------------ + +Before edits, read the complete nra-refactoring SKILL.md, local refactor-audit +SKILL.md, current refactor-audit.skill ZIP entrypoint, pattern README, surface +receipt and complete identity, boundaries, implementation and over-time pattern +references. User-supplied standing AGENTS instructions apply; no filesystem +AGENTS.md was found in the owning ancestry or this source tree. + +* BOUND-1: duplicated FieldID/PositionX/PositionY raw XML decoding in + get_grid_size and get_field_positions. Consolidate in the existing parser's + typed position decoder, used by both consumers; no parallel XML/store owner. +* BOUND-2: considered SourceTileLayout, PolyStore SourceTileGeometry and + SpatialGridAxis. They own calibrated pixel/projection/runtime geometry, not + raw Harmony stage coordinates or native field IDs. Do not introduce that + dependency or a new carrier for this boundary. +* IDEN-6: preserve well/channel/plane grouping, expressed as numeric tuple + identity rather than an assembled string; do not merge coordinate populations + across wells or channels to make the singleton work. Field IDs identify fields, + not grid cardinality. Existing remapping's first-position-per-field contract + remains distinct from grid grouping. +* IMPL-12 / IMPL-13: collapse duplicate coordinate decoding rather than add a + third implementation. The existing text helper and parser remain owners. +* IMPL-14: eliminate the 8-term and 6-term anonymous XML admission chains by + numeric boundary decoding; empty/invalid values remain ineligible. +* TIME-1 / TIME-6: remove the unreachable square/factor field-ID guessing path. + Nonempty valid coordinate sets already have positive axis cardinalities. +* TIME-5 / TIME-9: no product test switch, fallback reader, alias, cached mutation + or adapter. Tests load the exact parser source file to avoid microscope package + discovery and science/native/viewer runtime, and assert that source identity. + +Required relation: any nonempty valid stage-coordinate group yields the count +of distinct X and Y positions using the existing 1e-10 m quantization. One point +has one position on each axis. No singleton-specific dispatch is necessary. +Missing, malformed or nonfinite coordinates cannot justify guessed geometry. +Preserve the first multi-image group's existing precedence when a partial +singleton group precedes it; otherwise use the first valid singleton group. + +New-case experiment: add a one-field group with 3 channels x 3 planes and field +ID 17; add 1x4, 4x1, 2x3, 3x2 and 3x3 grids with non-contiguous/reversed IDs. +Before: singleton requires a consumer special case; square-ID inference cannot +prove rectangular orientation. After: zero production edits; the existing +coordinate relation and raster remapping handle these external records. + +Resource ledger and acceptance boundary +-------------------------------------- + +Owned disposable scratch: +``/home/ts/.cache/agent-scratch/opera-singleton-172-20261001`` (owner: this +source task; purpose: original traceback, bounded test/AST/guard evidence). +Supervisor bounds each terminal process group to one CPU, 512 MiB RSS, +60 seconds per shard, 256 MiB scratch. Existing Python 3.12.3 for source tests; +existing system Python 3.14.7 for the unchanged CI structural ratchet. +Resource guard before tests: /home 24.3 GiB free, RAM available 20.9 GiB; +warning from historical swap 13.2 GiB. No large job/fleet is started; own +512/256 MiB limits are comfortably within actual safe bounds. No environment, +package, interpreter, native build, JVM, Fiji or science execution is requested. + +Focused parser proofs are not a complete NRA scan, public-package import test, +installed MCP acceptance or biological validation. No heavyweight pending scan +is treated as a blocker or as a pass. Original debt guard runs without changes, +waivers, exemptions or metric gaming. Results and archived cleanup follow below. + +Executed source proofs at the working checkpoint +----------------------------------------------- + +The unchanged original XML fails on the pinned base with its original exception +and succeeds after the patch with grid (1, 1), mapping {1: 1}, fresh-parser +reopen and unchanged SHA256. Negative traceback and negative regression log +are retained. Eleven unittest methods pass, including table-driven singleton, +colocated channels/planes, 1xN, Nx1, rectangular/square geometry, raster mapping, +scope separation, partial-group precedence, external numeric padding, +quantization, invalid/nonfinite coordinates and absent reference fields. +Peak combined RSS: 38.86 MiB for tests, 40.91 MiB for original XML proof; +each shard finishes in less than 0.4 seconds. No limit is hit. + +Command from this worktree, with single-thread math and PYTHONDONTWRITEBYTECODE, +under the resource supervisor:: + + /home/ts/code/projects/openhcs/.venv/bin/python tests/source_contracts/test_opera_phenix_xml_geometry.py + +Only parser source, this test and this receipt are in the source diff. Generator, +filename parser, submodule pins and installed files remain untouched. The parser +change deletes 160 lines and adds 48; deletion is the duplicate coordinate +decoder, admission chains, singleton rejection and unreachable ID guessing, +not a relocation or ratchet exemption. + +Separate tracked inventory witness, retained under issue 172 +---------------------------------------------------------- + +Owner correction at terminal handoff: parent owns this separate A01/R01C01 +generator-versus-filename discrepancy and its closure, unless the user explicitly +reassigns it. This source implementation does not claim that follow-up. + +Parent observed persisted Opera A01 metadata versus filename-parsed R01C01: +sample succeeds, combined inventory reports two wells. Source witness: +SyntheticMicroscopyGenerator.generate_openhcs_metadata builds +``wells = {well: well for well in self.wells}`` (demo/synthetic_data.py:1125); +OperaPhenixFilenameParser.parse_filename emits RxxCxx (opera_phenix.py:488). +This is generator/catalog versus filename identity, not the stage-coordinate +parser owner. Neither is changed by this fix. Parent retains installed evidence; +issue 172 stays open for normalization through the existing filename declaration +and persisted producer plus installed combined-inventory acceptance. Do not +silently convert the fixture, accept two aliases or fabricate a fallback reader. + +Guard evidence, provenance and stack integration +------------------------------------------------ + +Draft PR 329 targets main: +https://github.com/OpenHCSDev/openhcs/pull/329 . Issue 172 remains open. +Working source qualified at ``1002437c4965c4c60bc56ed3fe19b6609dace53e``. + +The unchanged CI structural ratchet from agent-comms +``3b03785f45df2ef5dc62ba6aed99294192ecbb01`` passes all three production roots, +against base 5ce9e308 and working head 1002437c4. Tool SHA256: +``e323c94d49c2b72d9524a5169f123e64b4a6e46a41035ca9fb4497e49b6ca562``. +No exceptions or detector edits. Nonzero deltas: StringSubscript -4, +LongBooleanChain -2, BooleanChainTerms -14, parser GodClassExcess -112. +All other deltas zero. Root shard observations: + +* openhcs: exit 0, 17.71 seconds, 83.55 MiB peak combined RSS. +* scripts: exit 0, 2.55 seconds, 51.47 MiB peak combined RSS. +* benchmark: exit 0, 2.95 seconds, 50.46 MiB peak combined RSS. + +Focused current-archive debt census of the parser has zero unparsed files, +string_key_subscript -4, long_boolean_chain -2, boolean_chain_terms -14, +literal_key_get -2, none_identity -8, long_function -1, code_lines -77; +all type/string dispatch measures remain zero delta. The local census script +is byte-identical to the archive script, SHA256 +``fbe4651372d4d79963075d7fb6ba6dedf90d5e88e14eee07f845c5d836974e35``. +Focused AST checks parse the XML parser, Opera handler, generator, +SourceTileLayout and SpatialGridAxis source; record source hashes; verify both +position readers call the shared decoder, grid raw string-subscript reads and +grid sqrt guessing are absent, and the unchanged metadata consumer returns +``(grid_size[1], grid_size[0])``. This is static evidence, not consumer execution +or an NRA schema/descent certificate. The complete NRA dependency scan and +changed-file global R1 comparison are not executed here; no pass or completeness +claim is made for them. No guard is patched or waived. + +Instruction provenance: nra-refactoring SKILL.md SHA256 +``9f2f8b28bc82256eefa3e9d63248c50722dc3ffe7d77adba5793296df196b47e``; +current refactor-audit.skill ZIP SHA256 +``100fbe8ef89664b866777e87b2c8640a3432e8a10e9188dff81c97942d551bf6``. + +Parent merged PolyStore PR 16 to 84f322e46871de5ed47e7fd20976ad03e533c1c4, +then OpenHCS PR 206 to main e690c3bfc0f2042dcc2c75e6205d03fffe8aa603. +This source branch normally merged that main at +``2e2602e550a2f3e9fac45944a519e2187943db47`` without conflicts; no parent +worktree was edited. The inherited PolyStore pin now matches main. New worktree +submodules remain deliberately uninitialized: standalone source-file tests do +not require discovery or initialize foreign dependencies. The post-merge hook +reports those uninitialized gitlinks; it changes none of them. + +Git tree identity proves the qualified production roots and tests are unchanged +by that merge. Main's openhcs/scripts/benchmark trees are byte-identical to the +original audited base; merged-head roots are identical to the qualified head. +Consequently the guard comparison is the same source comparison, not an +extrapolated behavioral claim. Parser SHA256 remains +``b80af276e29671a29fd4c710a12cd2727046a7164e4f1a1d22094d14d734f74b``. +PR now contains only the owned parser, test, receipt and evidence archive. + +Archive and exact unresolved live boundary +----------------------------------------- + +``opera_singleton_172_source_evidence.tgz`` retains the original installed XML +and MCP error excerpt, source negative traceback, nine-method negative +regression run (3 failures, 9 subtest errors), eleven-method passing run, +original XML positive/reopen proof, whole-root unchanged ratchet JSON, focused +census/AST, resource command summaries and the disposable supervisor/checker. +The archive is verified by fresh extraction and byte comparison before cleanup. +Owned scratch and the clean detached audit-tool worktree are removed only after +all processes terminate. No original fixture, source worktree, submodule master, +installed package or parent's process is removed. + +Source done: the original failure is reproduced and fixed without input changes; +the regression relation and original structural ratchet pass. Installed status +for this parser patch is NOT verified. Parent must integrate/install PR 329 and +repeat the original raw Opera generation plus complete empty-document +artifact-plan through the actual installed entrypoint, confirm warning/error +absence, and retain fresh acceptance receipts. No science/native/MCP slot is +claimed here. Parent schedules installed empty-step raw Opera initialization +qualification after Pauli H003d releases the scientific slot/runtime on frozen +installed e690c3bf. This implementation takes no MCP, native, JVM or scientific +lock. The separate persisted A01/R01C01 combined-inventory disagreement +remains under issue 172; it is not concealed by this parser fix. + +Cleanup completed: fresh extraction and complete byte comparison passed; owned +scratch (1.7 MiB before verification) and clean detached audit worktree (3.1 MiB) +were removed after every worker was terminal. Evidence is recoverable from the +158 KiB archive, SHA256 +``f46c0a64917011026493d4c60e9435995534fb0e9574e0e6e42aa906d21d9b9b``. diff --git a/docs/validation/opera_singleton_172_source_evidence.tgz b/docs/validation/opera_singleton_172_source_evidence.tgz new file mode 100644 index 0000000000..8508a1c9f5 Binary files /dev/null and b/docs/validation/opera_singleton_172_source_evidence.tgz differ diff --git a/openhcs/microscopes/opera_phenix_xml_parser.py b/openhcs/microscopes/opera_phenix_xml_parser.py index a2148049b2..e8c7221974 100644 --- a/openhcs/microscopes/opera_phenix_xml_parser.py +++ b/openhcs/microscopes/opera_phenix_xml_parser.py @@ -185,143 +185,35 @@ def get_grid_size(self) -> Tuple[int, int]: if not image_elements: raise OperaPhenixXmlContentError("No Image elements found in XML") - # Group images by well (Row+Col), channel, and plane - # We'll use the first group with multiple fields to determine grid size - image_groups = {} + # Keep coordinate populations separate, including colocated channels. + image_groups: dict[tuple[int, ...], list[tuple[float, float]]] = {} for image in image_elements: - # Extract well, channel, and plane information - row_elem = image.find(f"{self.namespace}Row") - col_elem = image.find(f"{self.namespace}Col") - channel_elem = image.find(f"{self.namespace}ChannelID") - plane_elem = image.find(f"{self.namespace}PlaneID") - - if ( - row_elem is not None - and row_elem.text - and col_elem is not None - and col_elem.text - and channel_elem is not None - and channel_elem.text - and plane_elem is not None - and plane_elem.text - ): - - # Create a key for grouping - group_key = f"R{row_elem.text}C{col_elem.text}_CH{channel_elem.text}_P{plane_elem.text}" - - # Extract position information - pos_x_elem = image.find(f"{self.namespace}PositionX") - pos_y_elem = image.find(f"{self.namespace}PositionY") - field_elem = image.find(f"{self.namespace}FieldID") - - if ( - pos_x_elem is not None - and pos_x_elem.text - and pos_y_elem is not None - and pos_y_elem.text - and field_elem is not None - and field_elem.text - ): - - try: - # Parse position values - x_value = float(pos_x_elem.text) - y_value = float(pos_y_elem.text) - field_id = int(field_elem.text) - - # Add to group - if group_key not in image_groups: - image_groups[group_key] = [] - - image_groups[group_key].append( - { - "field_id": field_id, - "pos_x": x_value, - "pos_y": y_value, - "pos_x_unit": pos_x_elem.get("Unit", ""), - "pos_y_unit": pos_y_elem.get("Unit", ""), - } - ) - except ValueError as e: - logger.warning( - "Could not parse position values (invalid number format) for image in group %s: %s", - group_key, - e, - ) - except TypeError as e: - logger.warning( - "Could not parse position values (wrong type) for image in group %s: %s", - group_key, - e, - ) - - # Find the first group with multiple fields - for group_key, images in image_groups.items(): - if len(images) > 1: - logger.debug( - "Using image group %s with %d fields to determine grid size", - group_key, - len(images), - ) - - # Extract unique X and Y positions - # Use a small epsilon for floating point comparison - epsilon = 1e-10 - x_positions = [img["pos_x"] for img in images] - y_positions = [img["pos_y"] for img in images] - - # Use numpy to find unique positions - unique_x = np.unique( - np.round(np.array(x_positions) / epsilon) * epsilon - ) - unique_y = np.unique( - np.round(np.array(y_positions) / epsilon) * epsilon + try: + group_key = tuple( + int(self._get_element_text(image, tag)) + for tag in ("Row", "Col", "ChannelID", "PlaneID") ) - - # Count unique positions - num_x_positions = len(unique_x) - num_y_positions = len(unique_y) - - # If we have a reasonable number of positions, use them as grid dimensions - if num_x_positions > 0 and num_y_positions > 0: - logger.info( - "Determined grid size from positions: %dx%d", - num_x_positions, - num_y_positions, - ) - return (num_x_positions, num_y_positions) - - # Alternative approach: try to infer grid size from field IDs - if len(images) > 1: - # Sort images by field ID - sorted_images = sorted(images, key=lambda x: x["field_id"]) - max_field_id = sorted_images[-1]["field_id"] - - # Try to determine if it's a square grid - grid_size = int( - np.sqrt(max_field_id) + 0.5 - ) # Round to nearest integer - - if grid_size**2 == max_field_id: - logger.info( - "Determined square grid size from field IDs: %dx%d", - grid_size, - grid_size, - ) - return (grid_size, grid_size) - - # If not a perfect square, try to find factors - for i in range(1, int(np.sqrt(max_field_id)) + 1): - if max_field_id % i == 0: - j = max_field_id // i - logger.info( - "Determined grid size from field IDs: %dx%d", i, j - ) - return (i, j) - - # If we couldn't determine grid size, raise an error - raise OperaPhenixXmlContentError("Could not determine grid size from XML data") + except (ValueError, TypeError): + continue + field_position = self._get_field_position(image) + if field_position is not None: + _, position = field_position + image_groups.setdefault(group_key, []).append(position) + + if not image_groups: + raise OperaPhenixXmlContentError("Could not determine grid size from XML data") + + # Preserve the first multi-image group's precedence on partial exports; + # an acquisition consisting solely of singleton groups is also geometry. + groups = tuple(image_groups.values()) + positions = next((group for group in groups if len(group) > 1), groups[0]) + x_positions, y_positions = zip(*positions) + epsilon = 1e-10 + columns = len(np.unique(np.round(np.array(x_positions) / epsilon) * epsilon)) + rows = len(np.unique(np.round(np.array(y_positions) / epsilon) * epsilon)) + logger.info("Determined grid size from positions: %dx%d", columns, rows) + return (columns, rows) def get_pixel_size(self) -> float: """ @@ -508,6 +400,24 @@ def _get_element_attribute( elem = parent_elem.find(f"{self.namespace}{tag_name}") return elem.get(attr_name) if elem is not None else None + def _get_field_position( + self, image: ET.Element + ) -> Optional[Tuple[int, Tuple[float, float]]]: + """Decode one finite Harmony stage position for grid and remapping readers.""" + try: + field_id = int(self._get_element_text(image, "FieldID")) + position = ( + float(self._get_element_text(image, "PositionX")), + float(self._get_element_text(image, "PositionY")), + ) + except (ValueError, TypeError) as error: + logger.debug("Skipping field with invalid position data: %s", error) + return None + if not np.isfinite(position).all(): + logger.debug("Skipping field %d with nonfinite position: %s", field_id, position) + return None + return field_id, position + def detect_orphan_fields( self, positions: Dict[int, Tuple[float, float]], @@ -596,32 +506,10 @@ def get_field_positions( image_elems = self.root.findall(f".//{self.namespace}Image") for image in image_elems: - # Check if this element has FieldID, PositionX, and PositionY children - field_id_elem = image.find(f"{self.namespace}FieldID") - pos_x_elem = image.find(f"{self.namespace}PositionX") - pos_y_elem = image.find(f"{self.namespace}PositionY") - - if ( - field_id_elem is not None - and pos_x_elem is not None - and pos_y_elem is not None - ): - try: - field_id = int(field_id_elem.text) - pos_x = float(pos_x_elem.text) - pos_y = float(pos_y_elem.text) - - # Only add if we don't already have this field ID - if field_id not in field_positions: - field_positions[field_id] = (pos_x, pos_y) - except ValueError as e: - # Skip entries with invalid number format - logger.debug("Skipping field with invalid number format: %s", e) - continue - except TypeError as e: - # Skip entries with wrong type - logger.debug("Skipping field with wrong type: %s", e) - continue + field_position = self._get_field_position(image) + if field_position is not None: + field_id, position = field_position + field_positions.setdefault(field_id, position) # Detect and exclude orphan fields if requested if exclude_orphans and len(field_positions) > 2: diff --git a/tests/source_contracts/test_opera_phenix_xml_geometry.py b/tests/source_contracts/test_opera_phenix_xml_geometry.py new file mode 100644 index 0000000000..f307bff0a7 --- /dev/null +++ b/tests/source_contracts/test_opera_phenix_xml_geometry.py @@ -0,0 +1,159 @@ +"""Pure XML contracts, runnable without microscope discovery or science runtimes. + +Load the actual parser source file, not a replacement package or patched parser. +Normal public-package discovery/installed acceptance belongs to integration. +""" + +import importlib.util +from pathlib import Path +import tempfile +import unittest +import xml.etree.ElementTree as ET + + +SOURCE = Path(__file__).resolve().parents[2] / "openhcs/microscopes/opera_phenix_xml_parser.py" +SPEC = importlib.util.spec_from_file_location("opera_xml_geometry_source", SOURCE) +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) +Parser = MODULE.OperaPhenixXmlParser +ContentError = MODULE.OperaPhenixXmlContentError +NAMESPACE = "http://www.perkinelmer.com/PEHH/HarmonyV6" + + +def image(parent, *, well=(1, 1), channel=1, plane=1, field=1, position=(0.0, 0.0)): + element = ET.SubElement(parent, "Image", Version="1") + for tag, value in ( + ("Row", well[0]), ("Col", well[1]), ("ChannelID", channel), + ("PlaneID", plane), ("FieldID", field), + ("PositionX", position[0]), ("PositionY", position[1]), + ): + ET.SubElement(element, tag).text = str(value) + return element + + +class OperaXmlGeometryContract(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory(prefix="opera-xml-172-") + self.addCleanup(self.directory.cleanup) + + def parser(self, root): + path = Path(self.directory.name) / "Index.xml" + ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True) + return Parser(path) + + def test_source_identity(self): + self.assertEqual(Path(MODULE.__file__).resolve(), SOURCE.resolve()) + self.assertEqual(Parser.__module__, SPEC.name) + + def test_singleton_and_colocated_channel_plane_fields(self): + for namespace in ("", NAMESPACE): + for channels, planes in ((1, 1), (3, 1), (1, 3), (3, 3)): + with self.subTest(namespace=namespace, channels=channels, planes=planes): + root = ET.Element("EvaluationInputData", xmlns=namespace) + for channel in range(1, channels + 1): + for plane in range(1, planes + 1): + image(root, channel=channel, plane=plane, + field=17, position=(0.000576762, 0.000576762)) + parser = self.parser(root) + self.assertEqual(parser.get_grid_size(), (1, 1)) + self.assertEqual(parser.get_field_id_mapping(), {17: 1}) + + def test_rectangular_stage_geometry_and_raster_mapping(self): + for rows, cols in ((1, 4), (4, 1), (2, 3), (3, 2), (3, 3)): + with self.subTest(rows=rows, cols=cols): + root = ET.Element("EvaluationInputData", xmlns=NAMESPACE) + # Non-contiguous, reversed field IDs: IDs do not define geometry. + positions = {} + expected_mapping = {} + for row in range(rows): + for col in range(cols): + field = 17 + 2 * (rows * cols - (row * cols + col)) + position = (0.000576762 + col * 0.001, + 0.000576762 + (rows - row - 1) * 0.001) + positions[field] = position + expected_mapping[field] = row * cols + col + 1 + for channel in (1, 2): + for plane in (1, 2): + image(root, field=field, position=position, + channel=channel, plane=plane) + parser = self.parser(root) + self.assertEqual(parser.get_grid_size(), (cols, rows)) + self.assertEqual(parser.get_field_positions(), positions) + self.assertEqual(parser.get_field_id_mapping(), expected_mapping) + + def test_positions_are_scoped_to_one_well_channel_plane(self): + root = ET.Element("EvaluationInputData") + for well, channel, plane, offset in ( + ((1, 1), 1, 1, 0), ((1, 2), 1, 1, 10), + ((1, 1), 2, 1, 20), ((1, 1), 1, 2, 30), + ): + for field in range(1, 4): + image(root, well=well, channel=channel, plane=plane, + field=field, position=(offset + field * 0.001, offset)) + self.assertEqual(self.parser(root).get_grid_size(), (3, 1)) + + def test_coordinate_quantization_is_preserved(self): + root = ET.Element("EvaluationInputData") + image(root, field=1, position=(0, 0)) + image(root, field=2, position=(0.001, 1e-12)) + image(root, field=3, position=(1e-12, 0.001)) + image(root, field=4, position=(0.001, 0.001)) + self.assertEqual(self.parser(root).get_grid_size(), (2, 2)) + + def test_partial_singleton_does_not_displace_first_multifield_group(self): + root = ET.Element("EvaluationInputData") + image(root, well=(1, 1)) + for field in range(1, 4): + image(root, well=(1, 2), field=field, position=(field * 0.001, 0)) + for field in range(1, 5): + image(root, well=(1, 3), field=field, position=(0, field * 0.001)) + self.assertEqual(self.parser(root).get_grid_size(), (3, 1)) + + def test_numeric_group_identity_merges_external_padding_not_channels(self): + root = ET.Element("EvaluationInputData") + first = image(root, field=17, position=(0, 0)) + first.find("Row").text = "01" + first.find("Col").text = "01" + image(root, field=19, position=(0.001, 0)) + image(root, channel=2, field=17, position=(10, 10)) + self.assertEqual(self.parser(root).get_grid_size(), (2, 1)) + + def test_invalid_coordinates_do_not_become_a_guessed_grid(self): + for bad in ("not-a-number", "nan", "inf", "-inf", ""): + with self.subTest(bad=bad): + root = ET.Element("EvaluationInputData") + image(root, position=(bad, 0), field=16) + parser = self.parser(root) + with self.assertRaisesRegex(ContentError, "Could not determine grid size from XML data"): + parser.get_grid_size() + self.assertEqual(parser.get_field_positions(), {}) + + def test_missing_coordinate_does_not_mask_later_valid_group(self): + root = ET.Element("EvaluationInputData") + invalid = image(root, channel=1) + invalid.remove(invalid.find("PositionX")) + image(root, channel=2, field=17) + self.assertEqual(self.parser(root).get_grid_size(), (1, 1)) + + def test_empty_or_reference_only_images_preserve_content_errors(self): + for references in (False, True): + with self.subTest(references=references): + root = ET.Element("EvaluationInputData") + if references: + ET.SubElement(root, "Image", id="0101K1F1P1R1") + with self.assertRaises(ContentError): + self.parser(root).get_grid_size() + + def test_remapping_position_reader_does_not_require_grid_group_tags(self): + root = ET.Element("EvaluationInputData") + field = image(root, field=17) + for tag in ("Row", "Col", "ChannelID", "PlaneID"): + field.remove(field.find(tag)) + parser = self.parser(root) + self.assertEqual(parser.get_field_positions(), {17: (0, 0)}) + with self.assertRaises(ContentError): + parser.get_grid_size() + + +if __name__ == "__main__": + unittest.main(verbosity=2)