From 56f5f43ec2e919e232d6b49035a2cf38e01247f1 Mon Sep 17 00:00:00 2001 From: Sebastien Besson Date: Tue, 18 Jan 2022 08:50:29 +0000 Subject: [PATCH 01/14] Migrate well generation and validation logic under Format class --- ome_zarr/format.py | 24 +++++++++++++++++++++++- ome_zarr/writer.py | 12 ++---------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/ome_zarr/format.py b/ome_zarr/format.py index ca1ec9ea..101d593b 100644 --- a/ome_zarr/format.py +++ b/ome_zarr/format.py @@ -72,12 +72,22 @@ def __repr__(self) -> str: def __eq__(self, other: object) -> bool: return self.__class__ == other.__class__ + @abstractmethod + def generate_well_dict(self, well: str) -> dict: + raise NotImplementedError() + + @abstractmethod + def validate_well_dict(self, well: dict) -> None: + raise NotImplementedError() + class FormatV01(Format): """ Initial format. (2020) """ + REQUIRED_PLATE_WELL_KEYS = {"path": str} + @property def version(self) -> str: return "0.1" @@ -92,8 +102,20 @@ def init_store(self, path: str, mode: str = "r") -> FSStore: LOGGER.debug(f"Created legacy flat FSStore({path}, {mode})") return store + def generate_well_dict(self, well: str) -> dict: + return {"path": str(well)} + + def validate_well_dict(self, well: dict) -> None: + if any(e not in self.REQUIRED_PLATE_WELL_KEYS for e in well.keys()): + LOGGER.debug("f{well} contains unspecified keys") + for key, key_type in self.REQUIRED_PLATE_WELL_KEYS.items(): + if key not in well: + raise ValueError(f"{well} must contain a {key} key of type {key_type}") + if not isinstance(well[key], key_type): + raise ValueError(f"{well} path must be of {key_type} type") + -class FormatV02(Format): +class FormatV02(FormatV01): """ Changelog: move to nested storage (April 2021) """ diff --git a/ome_zarr/writer.py b/ome_zarr/writer.py index 0d1a357c..db7b7220 100644 --- a/ome_zarr/writer.py +++ b/ome_zarr/writer.py @@ -111,22 +111,14 @@ def _validate_plate_wells( wells: List[Union[str, dict]], fmt: Format = CurrentFormat() ) -> List[dict]: - VALID_KEYS = [ - "path", - ] validated_wells = [] if wells is None or len(wells) == 0: raise ValueError("Empty wells list") for well in wells: if isinstance(well, str): - validated_wells.append({"path": str(well)}) + validated_wells.append(fmt.generate_well_dict(well)) elif isinstance(well, dict): - if any(e not in VALID_KEYS for e in well.keys()): - LOGGER.debug("f{well} contains unspecified keys") - if "path" not in well: - raise ValueError(f"{well} must contain a path key") - if not isinstance(well["path"], str): - raise ValueError(f"{well} path must be of str type") + fmt.validate_well_dict(well) validated_wells.append(well) else: raise ValueError(f"Unrecognized type for {well}") From ef255aedba8c198012d09b03373c445994f717a7 Mon Sep 17 00:00:00 2001 From: Sebastien Besson Date: Tue, 18 Jan 2022 09:38:33 +0000 Subject: [PATCH 02/14] Add support for 0.4 HCS specification - rowIndex/colIndex are now mandatory keys of the well element - add validation logic in the format API - add logic for generating row/column index if wells are passed as strings --- ome_zarr/format.py | 26 ++++++++-- ome_zarr/writer.py | 11 ++-- tests/test_writer.py | 117 ++++++++++++++++++++++++++++++++++++------- 3 files changed, 128 insertions(+), 26 deletions(-) diff --git a/ome_zarr/format.py b/ome_zarr/format.py index 101d593b..bf411a65 100644 --- a/ome_zarr/format.py +++ b/ome_zarr/format.py @@ -2,7 +2,7 @@ import logging from abc import ABC, abstractmethod -from typing import Iterator, Optional +from typing import Dict, Iterator, List, Optional from zarr.storage import FSStore @@ -73,7 +73,9 @@ def __eq__(self, other: object) -> bool: return self.__class__ == other.__class__ @abstractmethod - def generate_well_dict(self, well: str) -> dict: + def generate_well_dict( + self, well: str, rows: List[str], columns: List[str] + ) -> dict: raise NotImplementedError() @abstractmethod @@ -86,7 +88,7 @@ class FormatV01(Format): Initial format. (2020) """ - REQUIRED_PLATE_WELL_KEYS = {"path": str} + REQUIRED_PLATE_WELL_KEYS: Dict[str, type] = {"path": str} @property def version(self) -> str: @@ -102,7 +104,9 @@ def init_store(self, path: str, mode: str = "r") -> FSStore: LOGGER.debug(f"Created legacy flat FSStore({path}, {mode})") return store - def generate_well_dict(self, well: str) -> dict: + def generate_well_dict( + self, well: str, rows: List[str], columns: List[str] + ) -> dict: return {"path": str(well)} def validate_well_dict(self, well: dict) -> None: @@ -173,9 +177,23 @@ class FormatV04(FormatV03): introduce transformations in multiscales (Nov 2021) """ + REQUIRED_PLATE_WELL_KEYS = {"path": str, "rowIndex": int, "colIndex": int} + @property def version(self) -> str: return "0.4" + def generate_well_dict( + self, well: str, rows: List[str], columns: List[str] + ) -> dict: + row, column = well.split("/") + if row not in rows: + raise ValueError(f"{row} is not defined in the list of rows") + rowIndex = rows.index(row) + if column not in columns: + raise ValueError(f"{row} is not defined in the list of rows") + colIndex = columns.index(column) + return {"path": str(well), "rowIndex": rowIndex, "colIndex": colIndex} + CurrentFormat = FormatV04 diff --git a/ome_zarr/writer.py b/ome_zarr/writer.py index db7b7220..051421a2 100644 --- a/ome_zarr/writer.py +++ b/ome_zarr/writer.py @@ -108,7 +108,10 @@ def _validate_plate_acquisitions( def _validate_plate_wells( - wells: List[Union[str, dict]], fmt: Format = CurrentFormat() + wells: List[Union[str, dict]], + rows: List[str], + columns: List[str], + fmt: Format = CurrentFormat(), ) -> List[dict]: validated_wells = [] @@ -116,7 +119,9 @@ def _validate_plate_wells( raise ValueError("Empty wells list") for well in wells: if isinstance(well, str): - validated_wells.append(fmt.generate_well_dict(well)) + well_dict = fmt.generate_well_dict(well, rows, columns) + fmt.validate_well_dict(well_dict) + validated_wells.append(well_dict) elif isinstance(well, dict): fmt.validate_well_dict(well) validated_wells.append(well) @@ -253,7 +258,7 @@ def write_plate_metadata( plate: Dict[str, Union[str, int, List[Dict]]] = { "columns": [{"name": str(c)} for c in columns], "rows": [{"name": str(r)} for r in rows], - "wells": _validate_plate_wells(wells), + "wells": _validate_plate_wells(wells, rows, columns, fmt=fmt), "version": fmt.version, } if name is not None: diff --git a/tests/test_writer.py b/tests/test_writer.py index 0e0e11ed..189b9fad 100644 --- a/tests/test_writer.py +++ b/tests/test_writer.py @@ -298,7 +298,9 @@ def test_minimal_plate(self): assert self.root.attrs["plate"]["columns"] == [{"name": "1"}] assert self.root.attrs["plate"]["rows"] == [{"name": "A"}] assert self.root.attrs["plate"]["version"] == CurrentFormat().version - assert self.root.attrs["plate"]["wells"] == [{"path": "A/1"}] + assert self.root.attrs["plate"]["wells"] == [ + {"path": "A/1", "rowIndex": 0, "colIndex": 0} + ] assert "name" not in self.root.attrs["plate"] assert "field_count" not in self.root.attrs["plate"] assert "acquisitions" not in self.root.attrs["plate"] @@ -335,25 +337,57 @@ def test_12wells_plate(self): ] assert self.root.attrs["plate"]["version"] == CurrentFormat().version assert self.root.attrs["plate"]["wells"] == [ - {"path": "A/1"}, - {"path": "A/2"}, - {"path": "A/3"}, - {"path": "B/1"}, - {"path": "B/2"}, - {"path": "B/3"}, - {"path": "C/1"}, - {"path": "C/2"}, - {"path": "C/3"}, - {"path": "D/1"}, - {"path": "D/2"}, - {"path": "D/3"}, + {"path": "A/1", "rowIndex": 0, "colIndex": 0}, + {"path": "A/2", "rowIndex": 0, "colIndex": 1}, + {"path": "A/3", "rowIndex": 0, "colIndex": 2}, + {"path": "B/1", "rowIndex": 1, "colIndex": 0}, + {"path": "B/2", "rowIndex": 1, "colIndex": 1}, + {"path": "B/3", "rowIndex": 1, "colIndex": 2}, + {"path": "C/1", "rowIndex": 2, "colIndex": 0}, + {"path": "C/2", "rowIndex": 2, "colIndex": 1}, + {"path": "C/3", "rowIndex": 2, "colIndex": 2}, + {"path": "D/1", "rowIndex": 3, "colIndex": 0}, + {"path": "D/2", "rowIndex": 3, "colIndex": 1}, + {"path": "D/3", "rowIndex": 3, "colIndex": 2}, + ] + assert "name" not in self.root.attrs["plate"] + assert "field_count" not in self.root.attrs["plate"] + assert "acquisitions" not in self.root.attrs["plate"] + + def test_sparse_plate(self): + rows = ["A", "B", "C", "D", "E"] + cols = ["1", "2", "3", "4", "5"] + wells = [ + "B/2", + "E/5", + ] + write_plate_metadata(self.root, rows, cols, wells) + assert "plate" in self.root.attrs + assert self.root.attrs["plate"]["columns"] == [ + {"name": "1"}, + {"name": "2"}, + {"name": "3"}, + {"name": "4"}, + {"name": "5"}, + ] + assert self.root.attrs["plate"]["rows"] == [ + {"name": "A"}, + {"name": "B"}, + {"name": "C"}, + {"name": "D"}, + {"name": "E"}, + ] + assert self.root.attrs["plate"]["version"] == CurrentFormat().version + assert self.root.attrs["plate"]["wells"] == [ + {"path": "B/2", "rowIndex": 1, "colIndex": 1}, + {"path": "E/5", "rowIndex": 4, "colIndex": 4}, ] assert "name" not in self.root.attrs["plate"] assert "field_count" not in self.root.attrs["plate"] assert "acquisitions" not in self.root.attrs["plate"] @pytest.mark.parametrize("fmt", (FormatV01(), FormatV02(), FormatV03())) - def test_plate_version(self, fmt): + def test_legacy_wells(self, fmt): write_plate_metadata(self.root, ["A"], ["1"], ["A/1"], fmt=fmt) assert "plate" in self.root.attrs assert self.root.attrs["plate"]["columns"] == [{"name": "1"}] @@ -371,7 +405,9 @@ def test_plate_name(self): assert self.root.attrs["plate"]["name"] == "test" assert self.root.attrs["plate"]["rows"] == [{"name": "A"}] assert self.root.attrs["plate"]["version"] == CurrentFormat().version - assert self.root.attrs["plate"]["wells"] == [{"path": "A/1"}] + assert self.root.attrs["plate"]["wells"] == [ + {"path": "A/1", "rowIndex": 0, "colIndex": 0} + ] assert "field_count" not in self.root.attrs["plate"] assert "acquisitions" not in self.root.attrs["plate"] @@ -382,7 +418,9 @@ def test_field_count(self): assert self.root.attrs["plate"]["field_count"] == 10 assert self.root.attrs["plate"]["rows"] == [{"name": "A"}] assert self.root.attrs["plate"]["version"] == CurrentFormat().version - assert self.root.attrs["plate"]["wells"] == [{"path": "A/1"}] + assert self.root.attrs["plate"]["wells"] == [ + {"path": "A/1", "rowIndex": 0, "colIndex": 0} + ] assert "name" not in self.root.attrs["plate"] assert "acquisitions" not in self.root.attrs["plate"] @@ -394,7 +432,9 @@ def test_acquisitions_minimal(self): assert self.root.attrs["plate"]["columns"] == [{"name": "1"}] assert self.root.attrs["plate"]["rows"] == [{"name": "A"}] assert self.root.attrs["plate"]["version"] == CurrentFormat().version - assert self.root.attrs["plate"]["wells"] == [{"path": "A/1"}] + assert self.root.attrs["plate"]["wells"] == [ + {"path": "A/1", "rowIndex": 0, "colIndex": 0} + ] assert "name" not in self.root.attrs["plate"] assert "field_count" not in self.root.attrs["plate"] @@ -415,7 +455,9 @@ def test_acquisitions_maximal(self): assert self.root.attrs["plate"]["columns"] == [{"name": "1"}] assert self.root.attrs["plate"]["rows"] == [{"name": "A"}] assert self.root.attrs["plate"]["version"] == CurrentFormat().version - assert self.root.attrs["plate"]["wells"] == [{"path": "A/1"}] + assert self.root.attrs["plate"]["wells"] == [ + {"path": "A/1", "rowIndex": 0, "colIndex": 0} + ] assert "name" not in self.root.attrs["plate"] assert "field_count" not in self.root.attrs["plate"] @@ -453,18 +495,36 @@ def test_invalid_well_list(self, wells): [{"path": 0}], [{"id": "test"}], [{"path": "A/1"}, {"path": None}], + [{"path": "A/1", "rowIndex": 0}], + [{"path": "A/1", "colIndex": 0}], + [{"path": "A/1", "rowIndex": "0", "colIndex": 0}], + [{"path": "A/1", "rowIndex": 0, "colIndex": "0"}], ), ) def test_invalid_well_keys(self, wells): with pytest.raises(ValueError): write_plate_metadata(self.root, ["A"], ["1"], wells) - def test_unspecified_well_keys(self): + @pytest.mark.parametrize("fmt", (FormatV01(), FormatV02(), FormatV03())) + def test_legacy_unspecified_well_keys(self, fmt): wells = [ {"path": "A/1", "unspecified_key": "alpha"}, {"path": "A/2", "unspecified_key": "beta"}, {"path": "B/1", "unspecified_key": "gamma"}, ] + write_plate_metadata(self.root, ["A", "B"], ["1", "2"], wells, fmt=fmt) + assert "plate" in self.root.attrs + assert self.root.attrs["plate"]["columns"] == [{"name": "1"}, {"name": "2"}] + assert self.root.attrs["plate"]["rows"] == [{"name": "A"}, {"name": "B"}] + assert self.root.attrs["plate"]["version"] == fmt.version + assert self.root.attrs["plate"]["wells"] == wells + + def test_unspecified_well_keys(self): + wells = [ + {"path": "A/1", "rowIndex": 0, "colIndex": 0, "unspecified_key": "alpha"}, + {"path": "A/2", "rowIndex": 0, "colIndex": 1, "unspecified_key": "beta"}, + {"path": "B/1", "rowIndex": 1, "colIndex": 0, "unspecified_key": "gamma"}, + ] write_plate_metadata(self.root, ["A", "B"], ["1", "2"], wells) assert "plate" in self.root.attrs assert self.root.attrs["plate"]["columns"] == [{"name": "1"}, {"name": "2"}] @@ -472,6 +532,25 @@ def test_unspecified_well_keys(self): assert self.root.attrs["plate"]["version"] == CurrentFormat().version assert self.root.attrs["plate"]["wells"] == wells + def test_missing_well_keys(self): + wells = [ + {"path": "A/1"}, + {"path": "A/2"}, + {"path": "B/1"}, + ] + with pytest.raises(ValueError): + write_plate_metadata(self.root, ["A", "B"], ["1", "2"], wells) + + def test_well_not_in_rows(self): + wells = ["A/1", "B/1", "C/1"] + with pytest.raises(ValueError): + write_plate_metadata(self.root, ["A", "B"], ["1", "2"], wells) + + def test_well_not_in_columns(self): + wells = ["A/1", "A/2", "A/3"] + with pytest.raises(ValueError): + write_plate_metadata(self.root, ["A", "B"], ["1", "2"], wells) + class TestWellMetadata: @pytest.fixture(autouse=True) From 91e9a682da6a2678077f57627b94144fa88d943a Mon Sep 17 00:00:00 2001 From: Sebastien Besson Date: Tue, 18 Jan 2022 09:59:57 +0000 Subject: [PATCH 03/14] Add internal logic for validating rows/columns --- ome_zarr/writer.py | 19 +++++++++++++++++-- tests/test_writer.py | 10 ++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/ome_zarr/writer.py b/ome_zarr/writer.py index 051421a2..01b9a80b 100644 --- a/ome_zarr/writer.py +++ b/ome_zarr/writer.py @@ -107,6 +107,21 @@ def _validate_plate_acquisitions( return acquisitions +def _validate_plate_rows_columns( + rows_or_columns: List[str], + fmt: Format = CurrentFormat(), +) -> List[dict]: + + if len(set(rows_or_columns)) != len(rows_or_columns): + raise ValueError(f"{rows_or_columns} must contain unique elements") + validated_list = [] + for element in rows_or_columns: + if not element.isalnum(): + raise ValueError(f"{element} must contain alphanumeric characters") + validated_list.append({"name": str(element)}) + return validated_list + + def _validate_plate_wells( wells: List[Union[str, dict]], rows: List[str], @@ -256,8 +271,8 @@ def write_plate_metadata( """ plate: Dict[str, Union[str, int, List[Dict]]] = { - "columns": [{"name": str(c)} for c in columns], - "rows": [{"name": str(r)} for r in rows], + "columns": _validate_plate_rows_columns(columns), + "rows": _validate_plate_rows_columns(rows), "wells": _validate_plate_wells(wells, rows, columns, fmt=fmt), "version": fmt.version, } diff --git a/tests/test_writer.py b/tests/test_writer.py index 189b9fad..a7a6fbf8 100644 --- a/tests/test_writer.py +++ b/tests/test_writer.py @@ -551,6 +551,16 @@ def test_well_not_in_columns(self): with pytest.raises(ValueError): write_plate_metadata(self.root, ["A", "B"], ["1", "2"], wells) + @pytest.mark.parametrize("rows", (["A", "B", "B"], ["A", "&"])) + def test_invalid_rows(self, rows): + with pytest.raises(ValueError): + write_plate_metadata(self.root, rows, ["1"], ["A/1"]) + + @pytest.mark.parametrize("columns", (["1", "2", "2"], ["1", "&"])) + def test_invalid_columns(self, columns): + with pytest.raises(ValueError): + write_plate_metadata(self.root, ["A"], columns, ["A/1"]) + class TestWellMetadata: @pytest.fixture(autouse=True) From ef8abe89d297358820f6946777e5b2f2aa911d43 Mon Sep 17 00:00:00 2001 From: Sebastien Besson Date: Tue, 18 Jan 2022 10:23:52 +0000 Subject: [PATCH 04/14] Also validate the fact each well path must have exactly one separator --- ome_zarr/format.py | 2 ++ tests/test_writer.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/ome_zarr/format.py b/ome_zarr/format.py index bf411a65..7b36f189 100644 --- a/ome_zarr/format.py +++ b/ome_zarr/format.py @@ -117,6 +117,8 @@ def validate_well_dict(self, well: dict) -> None: raise ValueError(f"{well} must contain a {key} key of type {key_type}") if not isinstance(well[key], key_type): raise ValueError(f"{well} path must be of {key_type} type") + if len(well["path"].split("/")) != 2: + raise ValueError(f"{well} path must exactly be composed of 2 groups") class FormatV02(FormatV01): diff --git a/tests/test_writer.py b/tests/test_writer.py index a7a6fbf8..ba5301f0 100644 --- a/tests/test_writer.py +++ b/tests/test_writer.py @@ -499,6 +499,8 @@ def test_invalid_well_list(self, wells): [{"path": "A/1", "colIndex": 0}], [{"path": "A/1", "rowIndex": "0", "colIndex": 0}], [{"path": "A/1", "rowIndex": 0, "colIndex": "0"}], + [{"path": "A1", "rowIndex": 0, "colIndex": "0"}], + [{"path": "plate/A/1", "rowIndex": 0, "colIndex": "0"}], ), ) def test_invalid_well_keys(self, wells): From 54918fc86fd6975ee013bc3b59aa2fb67f7583a8 Mon Sep 17 00:00:00 2001 From: Sebastien Besson Date: Tue, 18 Jan 2022 10:41:24 +0000 Subject: [PATCH 05/14] Specification key is columnIndex --- ome_zarr/format.py | 8 +++--- tests/test_writer.py | 64 +++++++++++++++++++++++++------------------- 2 files changed, 41 insertions(+), 31 deletions(-) diff --git a/ome_zarr/format.py b/ome_zarr/format.py index 7b36f189..3a749174 100644 --- a/ome_zarr/format.py +++ b/ome_zarr/format.py @@ -179,7 +179,7 @@ class FormatV04(FormatV03): introduce transformations in multiscales (Nov 2021) """ - REQUIRED_PLATE_WELL_KEYS = {"path": str, "rowIndex": int, "colIndex": int} + REQUIRED_PLATE_WELL_KEYS = {"path": str, "rowIndex": int, "columnIndex": int} @property def version(self) -> str: @@ -193,9 +193,9 @@ def generate_well_dict( raise ValueError(f"{row} is not defined in the list of rows") rowIndex = rows.index(row) if column not in columns: - raise ValueError(f"{row} is not defined in the list of rows") - colIndex = columns.index(column) - return {"path": str(well), "rowIndex": rowIndex, "colIndex": colIndex} + raise ValueError(f"{column} is not defined in the list of columns") + columnIndex = columns.index(column) + return {"path": str(well), "rowIndex": rowIndex, "columnIndex": columnIndex} CurrentFormat = FormatV04 diff --git a/tests/test_writer.py b/tests/test_writer.py index ba5301f0..89012af4 100644 --- a/tests/test_writer.py +++ b/tests/test_writer.py @@ -299,7 +299,7 @@ def test_minimal_plate(self): assert self.root.attrs["plate"]["rows"] == [{"name": "A"}] assert self.root.attrs["plate"]["version"] == CurrentFormat().version assert self.root.attrs["plate"]["wells"] == [ - {"path": "A/1", "rowIndex": 0, "colIndex": 0} + {"path": "A/1", "rowIndex": 0, "columnIndex": 0} ] assert "name" not in self.root.attrs["plate"] assert "field_count" not in self.root.attrs["plate"] @@ -337,18 +337,18 @@ def test_12wells_plate(self): ] assert self.root.attrs["plate"]["version"] == CurrentFormat().version assert self.root.attrs["plate"]["wells"] == [ - {"path": "A/1", "rowIndex": 0, "colIndex": 0}, - {"path": "A/2", "rowIndex": 0, "colIndex": 1}, - {"path": "A/3", "rowIndex": 0, "colIndex": 2}, - {"path": "B/1", "rowIndex": 1, "colIndex": 0}, - {"path": "B/2", "rowIndex": 1, "colIndex": 1}, - {"path": "B/3", "rowIndex": 1, "colIndex": 2}, - {"path": "C/1", "rowIndex": 2, "colIndex": 0}, - {"path": "C/2", "rowIndex": 2, "colIndex": 1}, - {"path": "C/3", "rowIndex": 2, "colIndex": 2}, - {"path": "D/1", "rowIndex": 3, "colIndex": 0}, - {"path": "D/2", "rowIndex": 3, "colIndex": 1}, - {"path": "D/3", "rowIndex": 3, "colIndex": 2}, + {"path": "A/1", "rowIndex": 0, "columnIndex": 0}, + {"path": "A/2", "rowIndex": 0, "columnIndex": 1}, + {"path": "A/3", "rowIndex": 0, "columnIndex": 2}, + {"path": "B/1", "rowIndex": 1, "columnIndex": 0}, + {"path": "B/2", "rowIndex": 1, "columnIndex": 1}, + {"path": "B/3", "rowIndex": 1, "columnIndex": 2}, + {"path": "C/1", "rowIndex": 2, "columnIndex": 0}, + {"path": "C/2", "rowIndex": 2, "columnIndex": 1}, + {"path": "C/3", "rowIndex": 2, "columnIndex": 2}, + {"path": "D/1", "rowIndex": 3, "columnIndex": 0}, + {"path": "D/2", "rowIndex": 3, "columnIndex": 1}, + {"path": "D/3", "rowIndex": 3, "columnIndex": 2}, ] assert "name" not in self.root.attrs["plate"] assert "field_count" not in self.root.attrs["plate"] @@ -379,8 +379,8 @@ def test_sparse_plate(self): ] assert self.root.attrs["plate"]["version"] == CurrentFormat().version assert self.root.attrs["plate"]["wells"] == [ - {"path": "B/2", "rowIndex": 1, "colIndex": 1}, - {"path": "E/5", "rowIndex": 4, "colIndex": 4}, + {"path": "B/2", "rowIndex": 1, "columnIndex": 1}, + {"path": "E/5", "rowIndex": 4, "columnIndex": 4}, ] assert "name" not in self.root.attrs["plate"] assert "field_count" not in self.root.attrs["plate"] @@ -406,7 +406,7 @@ def test_plate_name(self): assert self.root.attrs["plate"]["rows"] == [{"name": "A"}] assert self.root.attrs["plate"]["version"] == CurrentFormat().version assert self.root.attrs["plate"]["wells"] == [ - {"path": "A/1", "rowIndex": 0, "colIndex": 0} + {"path": "A/1", "rowIndex": 0, "columnIndex": 0} ] assert "field_count" not in self.root.attrs["plate"] assert "acquisitions" not in self.root.attrs["plate"] @@ -419,7 +419,7 @@ def test_field_count(self): assert self.root.attrs["plate"]["rows"] == [{"name": "A"}] assert self.root.attrs["plate"]["version"] == CurrentFormat().version assert self.root.attrs["plate"]["wells"] == [ - {"path": "A/1", "rowIndex": 0, "colIndex": 0} + {"path": "A/1", "rowIndex": 0, "columnIndex": 0} ] assert "name" not in self.root.attrs["plate"] assert "acquisitions" not in self.root.attrs["plate"] @@ -433,7 +433,7 @@ def test_acquisitions_minimal(self): assert self.root.attrs["plate"]["rows"] == [{"name": "A"}] assert self.root.attrs["plate"]["version"] == CurrentFormat().version assert self.root.attrs["plate"]["wells"] == [ - {"path": "A/1", "rowIndex": 0, "colIndex": 0} + {"path": "A/1", "rowIndex": 0, "columnIndex": 0} ] assert "name" not in self.root.attrs["plate"] assert "field_count" not in self.root.attrs["plate"] @@ -456,7 +456,7 @@ def test_acquisitions_maximal(self): assert self.root.attrs["plate"]["rows"] == [{"name": "A"}] assert self.root.attrs["plate"]["version"] == CurrentFormat().version assert self.root.attrs["plate"]["wells"] == [ - {"path": "A/1", "rowIndex": 0, "colIndex": 0} + {"path": "A/1", "rowIndex": 0, "columnIndex": 0} ] assert "name" not in self.root.attrs["plate"] assert "field_count" not in self.root.attrs["plate"] @@ -496,11 +496,11 @@ def test_invalid_well_list(self, wells): [{"id": "test"}], [{"path": "A/1"}, {"path": None}], [{"path": "A/1", "rowIndex": 0}], - [{"path": "A/1", "colIndex": 0}], - [{"path": "A/1", "rowIndex": "0", "colIndex": 0}], - [{"path": "A/1", "rowIndex": 0, "colIndex": "0"}], - [{"path": "A1", "rowIndex": 0, "colIndex": "0"}], - [{"path": "plate/A/1", "rowIndex": 0, "colIndex": "0"}], + [{"path": "A/1", "columnIndex": 0}], + [{"path": "A/1", "rowIndex": "0", "columnIndex": 0}], + [{"path": "A/1", "rowIndex": 0, "columnIndex": "0"}], + [{"path": "A1", "rowIndex": 0, "columnIndex": "0"}], + [{"path": "plate/A/1", "rowIndex": 0, "columnIndex": "0"}], ), ) def test_invalid_well_keys(self, wells): @@ -523,9 +523,19 @@ def test_legacy_unspecified_well_keys(self, fmt): def test_unspecified_well_keys(self): wells = [ - {"path": "A/1", "rowIndex": 0, "colIndex": 0, "unspecified_key": "alpha"}, - {"path": "A/2", "rowIndex": 0, "colIndex": 1, "unspecified_key": "beta"}, - {"path": "B/1", "rowIndex": 1, "colIndex": 0, "unspecified_key": "gamma"}, + { + "path": "A/1", + "rowIndex": 0, + "columnIndex": 0, + "unspecified_key": "alpha", + }, + {"path": "A/2", "rowIndex": 0, "columnIndex": 1, "unspecified_key": "beta"}, + { + "path": "B/1", + "rowIndex": 1, + "columnIndex": 0, + "unspecified_key": "gamma", + }, ] write_plate_metadata(self.root, ["A", "B"], ["1", "2"], wells) assert "plate" in self.root.attrs From 16986d04cc7c3099d3404abfc86af702b5470117 Mon Sep 17 00:00:00 2001 From: Sebastien Besson Date: Tue, 18 Jan 2022 10:58:23 +0000 Subject: [PATCH 06/14] Add unit tests for the case where Zarr group names do not match row/columns --- tests/test_node.py | 30 ++++++++++++++++++++++++++++++ tests/test_writer.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/tests/test_node.py b/tests/test_node.py index aede5b99..95f804b4 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -1,3 +1,5 @@ +import uuid + import pytest import zarr from numpy import zeros @@ -141,3 +143,31 @@ def test_plate_2D5D(self, axes, dims): assert node.metadata assert len(node.specs) == 1 assert isinstance(node.specs[0], Well) + + def test_uuid_plate(self): + group1 = uuid.uuid4() + group2 = uuid.uuid4() + well = {"path": f"{group1}/{group2}", "rowIndex": 0, "columnIndex": 0} + write_plate_metadata(self.root, ["A"], ["1"], [well]) + row_group = self.root.require_group(group1) + well = row_group.require_group(group2) + write_well_metadata(well, ["0"]) + image = well.require_group("0") + write_image(zeros((1, 1, 1, 256, 256)), image) + + node = Node(parse_url(str(self.path)), list()) + assert node.data + assert node.metadata + assert len(node.specs) == 1 + assert isinstance(node.specs[0], Plate) + assert node.specs[0].row_names == ["A"] + assert node.specs[0].col_names == ["1"] + assert node.specs[0].well_paths == [f"{group1}/{group2}"] + assert node.specs[0].row_count == 1 + assert node.specs[0].column_count == 1 + + node = Node(parse_url(str(self.path / f"{group1}/{group2}")), list()) + assert node.data + assert node.metadata + assert len(node.specs) == 1 + assert isinstance(node.specs[0], Well) diff --git a/tests/test_writer.py b/tests/test_writer.py index 89012af4..d45a34cd 100644 --- a/tests/test_writer.py +++ b/tests/test_writer.py @@ -1,4 +1,5 @@ import pathlib +import uuid import numpy as np import pytest @@ -573,6 +574,37 @@ def test_invalid_columns(self, columns): with pytest.raises(ValueError): write_plate_metadata(self.root, ["A"], columns, ["A/1"]) + def test_uuid_plate(self): + wells = [ + { + "path": str(uuid.uuid4()) + "/" + str(uuid.uuid4()), + "rowIndex": 0, + "columnIndex": 0, + }, + { + "path": str(uuid.uuid4()) + "/" + str(uuid.uuid4()), + "rowIndex": 0, + "columnIndex": 1, + }, + { + "path": str(uuid.uuid4()) + "/" + str(uuid.uuid4()), + "rowIndex": 1, + "columnIndex": 0, + }, + { + "path": str(uuid.uuid4()) + "/" + str(uuid.uuid4()), + "rowIndex": 0, + "columnIndex": 1, + }, + ] + write_plate_metadata(self.root, ["A", "B"], ["1", "2"], wells) + + assert "plate" in self.root.attrs + assert self.root.attrs["plate"]["columns"] == [{"name": "1"}, {"name": "2"}] + assert self.root.attrs["plate"]["rows"] == [{"name": "A"}, {"name": "B"}] + assert self.root.attrs["plate"]["version"] == CurrentFormat().version + assert self.root.attrs["plate"]["wells"] == wells + class TestWellMetadata: @pytest.fixture(autouse=True) From 0c4ffdb87f0e151c69476adcef62a234050a5ded Mon Sep 17 00:00:00 2001 From: Sebastien Besson Date: Tue, 18 Jan 2022 16:52:03 +0000 Subject: [PATCH 07/14] Migrate well.path validation to FormatV04 --- ome_zarr/format.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ome_zarr/format.py b/ome_zarr/format.py index 3a749174..4782b946 100644 --- a/ome_zarr/format.py +++ b/ome_zarr/format.py @@ -117,8 +117,6 @@ def validate_well_dict(self, well: dict) -> None: raise ValueError(f"{well} must contain a {key} key of type {key_type}") if not isinstance(well[key], key_type): raise ValueError(f"{well} path must be of {key_type} type") - if len(well["path"].split("/")) != 2: - raise ValueError(f"{well} path must exactly be composed of 2 groups") class FormatV02(FormatV01): @@ -197,5 +195,10 @@ def generate_well_dict( columnIndex = columns.index(column) return {"path": str(well), "rowIndex": rowIndex, "columnIndex": columnIndex} + def validate_well_dict(self, well: dict) -> None: + super().validate_well_dict(well) + if len(well["path"].split("/")) != 2: + raise ValueError(f"{well} path must exactly be composed of 2 groups") + CurrentFormat = FormatV04 From 0f11d7e61884c505378ba9c357154503b6dee12a Mon Sep 17 00:00:00 2001 From: Sebastien Besson Date: Tue, 18 Jan 2022 16:56:07 +0000 Subject: [PATCH 08/14] Update Format.validate_well_dict to support rows/columns input --- ome_zarr/format.py | 14 ++++++++++---- ome_zarr/writer.py | 4 ++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/ome_zarr/format.py b/ome_zarr/format.py index 4782b946..f8aa3558 100644 --- a/ome_zarr/format.py +++ b/ome_zarr/format.py @@ -79,7 +79,9 @@ def generate_well_dict( raise NotImplementedError() @abstractmethod - def validate_well_dict(self, well: dict) -> None: + def validate_well_dict( + self, well: dict, rows: List[str], columns: List[str] + ) -> None: raise NotImplementedError() @@ -109,7 +111,9 @@ def generate_well_dict( ) -> dict: return {"path": str(well)} - def validate_well_dict(self, well: dict) -> None: + def validate_well_dict( + self, well: dict, rows: List[str], columns: List[str] + ) -> None: if any(e not in self.REQUIRED_PLATE_WELL_KEYS for e in well.keys()): LOGGER.debug("f{well} contains unspecified keys") for key, key_type in self.REQUIRED_PLATE_WELL_KEYS.items(): @@ -195,8 +199,10 @@ def generate_well_dict( columnIndex = columns.index(column) return {"path": str(well), "rowIndex": rowIndex, "columnIndex": columnIndex} - def validate_well_dict(self, well: dict) -> None: - super().validate_well_dict(well) + def validate_well_dict( + self, well: dict, rows: List[str], columns: List[str] + ) -> None: + super().validate_well_dict(well, rows, columns) if len(well["path"].split("/")) != 2: raise ValueError(f"{well} path must exactly be composed of 2 groups") diff --git a/ome_zarr/writer.py b/ome_zarr/writer.py index 01b9a80b..17d796fd 100644 --- a/ome_zarr/writer.py +++ b/ome_zarr/writer.py @@ -135,10 +135,10 @@ def _validate_plate_wells( for well in wells: if isinstance(well, str): well_dict = fmt.generate_well_dict(well, rows, columns) - fmt.validate_well_dict(well_dict) + fmt.validate_well_dict(well_dict, rows, columns) validated_wells.append(well_dict) elif isinstance(well, dict): - fmt.validate_well_dict(well) + fmt.validate_well_dict(well, rows, columns) validated_wells.append(well) else: raise ValueError(f"Unrecognized type for {well}") From 17402600b8b7f547e2647a35f70c82336ae99d61 Mon Sep 17 00:00:00 2001 From: Sebastien Besson Date: Tue, 18 Jan 2022 17:09:02 +0000 Subject: [PATCH 09/14] Add check that the well path can be derived from the row/column names --- ome_zarr/format.py | 5 +++++ tests/test_node.py | 30 ------------------------------ tests/test_writer.py | 34 ++-------------------------------- 3 files changed, 7 insertions(+), 62 deletions(-) diff --git a/ome_zarr/format.py b/ome_zarr/format.py index f8aa3558..4fc07c1c 100644 --- a/ome_zarr/format.py +++ b/ome_zarr/format.py @@ -205,6 +205,11 @@ def validate_well_dict( super().validate_well_dict(well, rows, columns) if len(well["path"].split("/")) != 2: raise ValueError(f"{well} path must exactly be composed of 2 groups") + row, column = well["path"].split("/") + if row not in rows: + raise ValueError(f"{row} is not defined in the plate rows") + if column not in columns: + raise ValueError(f"{column} is not defined in the plate columns") CurrentFormat = FormatV04 diff --git a/tests/test_node.py b/tests/test_node.py index 95f804b4..aede5b99 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -1,5 +1,3 @@ -import uuid - import pytest import zarr from numpy import zeros @@ -143,31 +141,3 @@ def test_plate_2D5D(self, axes, dims): assert node.metadata assert len(node.specs) == 1 assert isinstance(node.specs[0], Well) - - def test_uuid_plate(self): - group1 = uuid.uuid4() - group2 = uuid.uuid4() - well = {"path": f"{group1}/{group2}", "rowIndex": 0, "columnIndex": 0} - write_plate_metadata(self.root, ["A"], ["1"], [well]) - row_group = self.root.require_group(group1) - well = row_group.require_group(group2) - write_well_metadata(well, ["0"]) - image = well.require_group("0") - write_image(zeros((1, 1, 1, 256, 256)), image) - - node = Node(parse_url(str(self.path)), list()) - assert node.data - assert node.metadata - assert len(node.specs) == 1 - assert isinstance(node.specs[0], Plate) - assert node.specs[0].row_names == ["A"] - assert node.specs[0].col_names == ["1"] - assert node.specs[0].well_paths == [f"{group1}/{group2}"] - assert node.specs[0].row_count == 1 - assert node.specs[0].column_count == 1 - - node = Node(parse_url(str(self.path / f"{group1}/{group2}")), list()) - assert node.data - assert node.metadata - assert len(node.specs) == 1 - assert isinstance(node.specs[0], Well) diff --git a/tests/test_writer.py b/tests/test_writer.py index d45a34cd..620b110d 100644 --- a/tests/test_writer.py +++ b/tests/test_writer.py @@ -1,5 +1,4 @@ import pathlib -import uuid import numpy as np import pytest @@ -502,6 +501,8 @@ def test_invalid_well_list(self, wells): [{"path": "A/1", "rowIndex": 0, "columnIndex": "0"}], [{"path": "A1", "rowIndex": 0, "columnIndex": "0"}], [{"path": "plate/A/1", "rowIndex": 0, "columnIndex": "0"}], + [{"path": "C/1", "rowIndex": 2, "columnIndex": 0}], + [{"path": "A/3", "rowIndex": 0, "columnIndex": 2}], ), ) def test_invalid_well_keys(self, wells): @@ -574,37 +575,6 @@ def test_invalid_columns(self, columns): with pytest.raises(ValueError): write_plate_metadata(self.root, ["A"], columns, ["A/1"]) - def test_uuid_plate(self): - wells = [ - { - "path": str(uuid.uuid4()) + "/" + str(uuid.uuid4()), - "rowIndex": 0, - "columnIndex": 0, - }, - { - "path": str(uuid.uuid4()) + "/" + str(uuid.uuid4()), - "rowIndex": 0, - "columnIndex": 1, - }, - { - "path": str(uuid.uuid4()) + "/" + str(uuid.uuid4()), - "rowIndex": 1, - "columnIndex": 0, - }, - { - "path": str(uuid.uuid4()) + "/" + str(uuid.uuid4()), - "rowIndex": 0, - "columnIndex": 1, - }, - ] - write_plate_metadata(self.root, ["A", "B"], ["1", "2"], wells) - - assert "plate" in self.root.attrs - assert self.root.attrs["plate"]["columns"] == [{"name": "1"}, {"name": "2"}] - assert self.root.attrs["plate"]["rows"] == [{"name": "A"}, {"name": "B"}] - assert self.root.attrs["plate"]["version"] == CurrentFormat().version - assert self.root.attrs["plate"]["wells"] == wells - class TestWellMetadata: @pytest.fixture(autouse=True) From e08a7967ca74eaad8db8796149bd624c884c330c Mon Sep 17 00:00:00 2001 From: Sebastien Besson Date: Tue, 18 Jan 2022 17:14:32 +0000 Subject: [PATCH 10/14] Add additional checks for row and column index to validate_well_dict --- ome_zarr/format.py | 4 ++++ tests/test_writer.py | 2 ++ 2 files changed, 6 insertions(+) diff --git a/ome_zarr/format.py b/ome_zarr/format.py index 4fc07c1c..06553006 100644 --- a/ome_zarr/format.py +++ b/ome_zarr/format.py @@ -208,8 +208,12 @@ def validate_well_dict( row, column = well["path"].split("/") if row not in rows: raise ValueError(f"{row} is not defined in the plate rows") + if well["rowIndex"] != rows.index(row): + raise ValueError(f"Mismatching row index for {well}") if column not in columns: raise ValueError(f"{column} is not defined in the plate columns") + if well["columnIndex"] != columns.index(column): + raise ValueError(f"Mismatching column index for {well}") CurrentFormat = FormatV04 diff --git a/tests/test_writer.py b/tests/test_writer.py index 620b110d..acf6966d 100644 --- a/tests/test_writer.py +++ b/tests/test_writer.py @@ -503,6 +503,8 @@ def test_invalid_well_list(self, wells): [{"path": "plate/A/1", "rowIndex": 0, "columnIndex": "0"}], [{"path": "C/1", "rowIndex": 2, "columnIndex": 0}], [{"path": "A/3", "rowIndex": 0, "columnIndex": 2}], + [{"path": "A/1", "rowIndex": 0, "columnIndex": 1}], + [{"path": "A/1", "rowIndex": 1, "columnIndex": 0}], ), ) def test_invalid_well_keys(self, wells): From 726861d8828b4407759b48c20591e462394652ea Mon Sep 17 00:00:00 2001 From: Sebastien Besson Date: Tue, 18 Jan 2022 17:24:01 +0000 Subject: [PATCH 11/14] Do not include abstractmethod in code coverage report --- ome_zarr/format.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ome_zarr/format.py b/ome_zarr/format.py index 06553006..866c6c7c 100644 --- a/ome_zarr/format.py +++ b/ome_zarr/format.py @@ -44,11 +44,11 @@ def detect_format(metadata: dict) -> "Format": class Format(ABC): @property @abstractmethod - def version(self) -> str: + def version(self) -> str: # pragma: no cover raise NotImplementedError() @abstractmethod - def matches(self, metadata: dict) -> bool: + def matches(self, metadata: dict) -> bool: # pragma: no cover raise NotImplementedError() @abstractmethod @@ -56,7 +56,7 @@ def init_store(self, path: str, mode: str = "r") -> FSStore: raise NotImplementedError() # @abstractmethod - def init_channels(self) -> None: + def init_channels(self) -> None: # pragma: no cover raise NotImplementedError() def _get_multiscale_version(self, metadata: dict) -> Optional[str]: @@ -75,13 +75,13 @@ def __eq__(self, other: object) -> bool: @abstractmethod def generate_well_dict( self, well: str, rows: List[str], columns: List[str] - ) -> dict: + ) -> dict: # pragma: no cover raise NotImplementedError() @abstractmethod def validate_well_dict( self, well: dict, rows: List[str], columns: List[str] - ) -> None: + ) -> None: # pragma: no cover raise NotImplementedError() From 8156a88334189d252a38668ef1e6205b06e6e92c Mon Sep 17 00:00:00 2001 From: Sebastien Besson Date: Tue, 18 Jan 2022 17:25:59 +0000 Subject: [PATCH 12/14] Fix tests for well paths with invalid number of separators --- tests/test_writer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_writer.py b/tests/test_writer.py index acf6966d..7c4bfcfa 100644 --- a/tests/test_writer.py +++ b/tests/test_writer.py @@ -500,7 +500,7 @@ def test_invalid_well_list(self, wells): [{"path": "A/1", "rowIndex": "0", "columnIndex": 0}], [{"path": "A/1", "rowIndex": 0, "columnIndex": "0"}], [{"path": "A1", "rowIndex": 0, "columnIndex": "0"}], - [{"path": "plate/A/1", "rowIndex": 0, "columnIndex": "0"}], + [{"path": "plate/A/1", "rowIndex": 0, "columnIndex": 0}], [{"path": "C/1", "rowIndex": 2, "columnIndex": 0}], [{"path": "A/3", "rowIndex": 0, "columnIndex": 2}], [{"path": "A/1", "rowIndex": 0, "columnIndex": 1}], From 90a7c9d94f48fe388a5ce94a2dcc2125dc2edd2d Mon Sep 17 00:00:00 2001 From: Sebastien Besson Date: Wed, 19 Jan 2022 12:59:34 +0000 Subject: [PATCH 13/14] Increase coverage and verbosity of invalid wells --- tests/test_writer.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/test_writer.py b/tests/test_writer.py index 7c4bfcfa..06f913ab 100644 --- a/tests/test_writer.py +++ b/tests/test_writer.py @@ -492,15 +492,23 @@ def test_invalid_well_list(self, wells): @pytest.mark.parametrize( "wells", ( - [{"path": 0}], + # Missing required keys [{"id": "test"}], - [{"path": "A/1"}, {"path": None}], + [{"path": "A/1"}], [{"path": "A/1", "rowIndex": 0}], [{"path": "A/1", "columnIndex": 0}], + [{"rowIndex": 0, "columnIndex": 0}], + # Invalid paths + [{"path": 0, "rowIndex": 0, "columnIndex": 0}], + [{"path": None, "rowIndex": 0, "columnIndex": 0}], + [{"path": "plate/A/1", "rowIndex": 0, "columnIndex": 0}], + [{"path": "plate/A1", "rowIndex": 0, "columnIndex": 0}], + [{"path": "A/1/0", "rowIndex": 0, "columnIndex": 0}], + # Invalid indices [{"path": "A/1", "rowIndex": "0", "columnIndex": 0}], [{"path": "A/1", "rowIndex": 0, "columnIndex": "0"}], - [{"path": "A1", "rowIndex": 0, "columnIndex": "0"}], - [{"path": "plate/A/1", "rowIndex": 0, "columnIndex": 0}], + [{"path": "A1", "rowIndex": 0, "columnIndex": 0}], + # Mismatching indices [{"path": "C/1", "rowIndex": 2, "columnIndex": 0}], [{"path": "A/3", "rowIndex": 0, "columnIndex": 2}], [{"path": "A/1", "rowIndex": 0, "columnIndex": 1}], From c5163399a2403bd8bb4e0dc631170371ea0ae850 Mon Sep 17 00:00:00 2001 From: Sebastien Besson Date: Wed, 19 Jan 2022 13:03:41 +0000 Subject: [PATCH 14/14] Fix failing wells comments --- tests/test_writer.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/test_writer.py b/tests/test_writer.py index 06f913ab..a952dd44 100644 --- a/tests/test_writer.py +++ b/tests/test_writer.py @@ -504,13 +504,15 @@ def test_invalid_well_list(self, wells): [{"path": "plate/A/1", "rowIndex": 0, "columnIndex": 0}], [{"path": "plate/A1", "rowIndex": 0, "columnIndex": 0}], [{"path": "A/1/0", "rowIndex": 0, "columnIndex": 0}], - # Invalid indices + [{"path": "A1", "rowIndex": 0, "columnIndex": 0}], + [{"path": "0", "rowIndex": 0, "columnIndex": 0}], + # Invalid row/column indices [{"path": "A/1", "rowIndex": "0", "columnIndex": 0}], [{"path": "A/1", "rowIndex": 0, "columnIndex": "0"}], - [{"path": "A1", "rowIndex": 0, "columnIndex": 0}], - # Mismatching indices + # Undefined rows/columns [{"path": "C/1", "rowIndex": 2, "columnIndex": 0}], [{"path": "A/3", "rowIndex": 0, "columnIndex": 2}], + # Mismatching indices [{"path": "A/1", "rowIndex": 0, "columnIndex": 1}], [{"path": "A/1", "rowIndex": 1, "columnIndex": 0}], ),