From c24ee38623a5e73a6db5404f495bde3a5bfa7245 Mon Sep 17 00:00:00 2001 From: marcorudolphflex Date: Thu, 4 Dec 2025 10:26:43 +0100 Subject: [PATCH] feat(tidy3d): FXC-4413-get-cell-name-from-violation-marker-in-klayout-plugin --- CHANGELOG.md | 1 + tests/test_plugins/klayout/drc/test_drc.py | 94 ++++++++++++++++++--- tidy3d/plugins/klayout/drc/results.py | 95 +++++++++++++++++----- 3 files changed, 156 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d10cd5983a..5f9d1146c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added `symmetrize_mirror`, `symmetrize_rotation`, `symmetrize_diagonal` functions to the autograd plugin. They can be used for enforcing symmetries in topology optimization. +- Get cell-related information from violation markers in `DRCResults` and `DRCViolation` to the klayout plugin: Use for example `DRCResults.violations_by_cell` to group them. ### Changed - Removed validator that would warn if `PerturbationMedium` values could become numerically unstable, since an error will anyway be raised if this actually happens when the medium is converted using actual perturbation data. diff --git a/tests/test_plugins/klayout/drc/test_drc.py b/tests/test_plugins/klayout/drc/test_drc.py index 4d65737ae4..6f05860a70 100644 --- a/tests/test_plugins/klayout/drc/test_drc.py +++ b/tests/test_plugins/klayout/drc/test_drc.py @@ -12,7 +12,12 @@ import tidy3d as td from tidy3d.exceptions import FileError from tidy3d.plugins.klayout.drc.drc import DRCConfig, DRCRunner, run_drc_on_gds -from tidy3d.plugins.klayout.drc.results import DRCResults, parse_violation_value +from tidy3d.plugins.klayout.drc.results import ( + DRCResults, + DRCViolation, + EdgeMarker, + parse_violation_value, +) from tidy3d.plugins.klayout.util import check_installation filepath = Path(os.path.dirname(os.path.abspath(__file__))) @@ -41,6 +46,7 @@ def _write_results_file( category: str = "min_width", num_items: int = 1, filename: str = "many_results.lyrdb", + cells: tuple[str, ...] | None = None, ) -> Path: """Write a simple DRC results file with the requested number of items.""" @@ -65,7 +71,7 @@ def _write_results_file( {category} - TOP + {cell} false 1 @@ -76,7 +82,11 @@ def _write_results_file( """ contents = [template_header] - contents.extend(item.format(category=category) for _ in range(num_items)) + for idx in range(num_items): + cell = "TOP" + if cells is not None and idx < len(cells): + cell = cells[idx] + contents.append(item.format(category=category, cell=cell)) contents.append(template_footer) path = tmp_path / filename path.write_text("".join(contents)) @@ -574,6 +584,62 @@ def test_drc_result_markers(self, drc_results): (-0.206, 0.342), (-0.206, 0.24), ) + for violation in drc_results.violations_by_category.values(): + for marker in violation.markers: + assert marker.cell == "TOP" + + def test_drc_violation_cell_helpers(self): + """DRCViolation provides cell-aware helpers.""" + violation = DRCViolation( + category="cat_a", + markers=( + EdgeMarker(cell="CELL_A", edge=((0.0, 0.0), (1.0, 1.0))), + EdgeMarker(cell="CELL_B", edge=((1.0, 1.0), (2.0, 2.0))), + EdgeMarker(cell="CELL_A", edge=((0.5, 0.5), (1.5, 1.5))), + ), + ) + assert violation.violated_cells == ("CELL_A", "CELL_B") + + by_cell = violation.violations_by_cell + assert set(by_cell) == {"CELL_A", "CELL_B"} + assert by_cell["CELL_B"].count == 1 + + markers_cell_a = by_cell["CELL_A"].markers + assert all(marker.cell == "CELL_A" for marker in markers_cell_a) + + def test_drc_results_cell_helpers(self): + """DRCResults aggregates violations across cells.""" + violation_a = DRCViolation( + category="cat_a", + markers=( + EdgeMarker(cell="CELL_A", edge=((0.0, 0.0), (1.0, 1.0))), + EdgeMarker(cell="CELL_B", edge=((1.0, 1.0), (2.0, 2.0))), + ), + ) + violation_b = DRCViolation( + category="cat_b", + markers=( + EdgeMarker(cell="CELL_B", edge=((2.0, 2.0), (3.0, 3.0))), + EdgeMarker(cell="CELL_C", edge=((3.0, 3.0), (4.0, 4.0))), + ), + ) + results = DRCResults( + violations_by_category={ + "cat_a": violation_a, + "cat_b": violation_b, + } + ) + assert results.violated_cells == ("CELL_A", "CELL_B", "CELL_C") + + violations_by_cell = results.violations_by_cell + assert set(violations_by_cell) == {"CELL_A", "CELL_B", "CELL_C"} + assert len(violations_by_cell["CELL_A"]) == 1 + assert len(violations_by_cell["CELL_C"]) == 1 + + cell_b_violations = violations_by_cell["CELL_B"] + assert {violation.category for violation in cell_b_violations} == {"cat_a", "cat_b"} + for violation in cell_b_violations: + assert all(marker.cell == "CELL_B" for marker in violation.markers) @pytest.mark.parametrize( "edge_value, expected_edge", @@ -584,30 +650,34 @@ def test_drc_result_markers(self, drc_results): ) def test_parse_edge(self, edge_value, expected_edge): """Test parsing edge violation values.""" - edge_result = parse_violation_value(edge_value) + edge_result = parse_violation_value(edge_value, cell="TEST_CELL") assert edge_result.edge == expected_edge + assert edge_result.cell == "TEST_CELL" def test_parse_edge_pair(self): """Test parsing edge-pair violation values.""" edge_pair_value = "edge-pair: (1.0,2.0;3.0,4.0)|(5.0,6.0;7.0,8.0)" - edge_pair_result = parse_violation_value(edge_pair_value) + edge_pair_result = parse_violation_value(edge_pair_value, cell="TEST_CELL") assert edge_pair_result.edge_pair[0] == ((1.0, 2.0), (3.0, 4.0)) assert edge_pair_result.edge_pair[1] == ((5.0, 6.0), (7.0, 8.0)) + assert edge_pair_result.cell == "TEST_CELL" def test_parse_polygon(self): """Test parsing a single polygon violation string.""" polygon_value = "polygon: (1.0,2.0;3.0,4.0;5.0,6.0;1.0,2.0)" - polygon_result = parse_violation_value(polygon_value) + polygon_result = parse_violation_value(polygon_value, cell="TEST_CELL") assert polygon_result.polygons[0] == ((1.0, 2.0), (3.0, 4.0), (5.0, 6.0), (1.0, 2.0)) + assert polygon_result.cell == "TEST_CELL" def test_parse_multiple_polygons(self): """Test parsing multiple polygons violation string.""" polygon_value = ( "polygon: (1.0,2.0;3.0,4.0;5.0,6.0;1.0,2.0/7.0,8.0;9.0,10.0;11.0,12.0;7.0,8.0)" ) - polygon_result = parse_violation_value(polygon_value) + polygon_result = parse_violation_value(polygon_value, cell="TEST_CELL") assert polygon_result.polygons[0] == ((1.0, 2.0), (3.0, 4.0), (5.0, 6.0), (1.0, 2.0)) assert polygon_result.polygons[1] == ((7.0, 8.0), (9.0, 10.0), (11.0, 12.0), (7.0, 8.0)) + assert polygon_result.cell == "TEST_CELL" @pytest.mark.parametrize( "invalid_edge", @@ -623,7 +693,7 @@ def test_parse_multiple_polygons(self): def test_parse_invalid_edge_format(self, invalid_edge): """Test parsing invalid violation format.""" with pytest.raises(ValueError): - parse_violation_value(invalid_edge) + parse_violation_value(invalid_edge, cell="TEST_CELL") @pytest.mark.parametrize( "invalid_edge_pair", @@ -636,7 +706,7 @@ def test_parse_invalid_edge_format(self, invalid_edge): def test_parse_invalid_edge_pair_format(self, invalid_edge_pair): """Test parsing invalid edge-pair violation format.""" with pytest.raises(ValueError): - parse_violation_value(invalid_edge_pair) + parse_violation_value(invalid_edge_pair, cell="TEST_CELL") @pytest.mark.parametrize( "invalid_polygon", @@ -649,7 +719,7 @@ def test_parse_invalid_edge_pair_format(self, invalid_edge_pair): def test_parse_invalid_polygon_format(self, invalid_polygon): """Test parsing invalid polygon violation format.""" with pytest.raises(ValueError): - parse_violation_value(invalid_polygon) + parse_violation_value(invalid_polygon, cell="TEST_CELL") @pytest.mark.parametrize( "invalid_polygons", @@ -662,12 +732,12 @@ def test_parse_invalid_polygon_format(self, invalid_polygon): def test_parse_invalid_polygon_format_multiple_polygons(self, invalid_polygons): """Test parsing invalid polygon violation format with multiple polygons.""" with pytest.raises(ValueError) as e: - parse_violation_value(invalid_polygons) + parse_violation_value(invalid_polygons, cell="TEST_CELL") def test_parse_violation_value_unknown_type(self): """Test parsing unknown violation type.""" with pytest.raises(ValueError): - parse_violation_value("unknown: (1.0,2.0)") + parse_violation_value("unknown: (1.0,2.0)", cell="TEST_CELL") def test_results_warn_without_limit(self, monkeypatch, tmp_path): """Warn when no limit is set and a category exceeds the threshold.""" diff --git a/tidy3d/plugins/klayout/drc/results.py b/tidy3d/plugins/klayout/drc/results.py index 3c55a299b3..5093524b50 100644 --- a/tidy3d/plugins/klayout/drc/results.py +++ b/tidy3d/plugins/klayout/drc/results.py @@ -23,7 +23,7 @@ UNLIMITED_VIOLATION_WARNING_COUNT = 100_000 -def parse_edge(value: str) -> EdgeMarker: +def parse_edge(value: str, *, cell: str) -> EdgeMarker: """ Extract coordinates from edge format: ``(x1,y1;x2,y2)``. @@ -31,11 +31,13 @@ def parse_edge(value: str) -> EdgeMarker: ---------- value : str The edge value string from DRC result database, with format ``(x1,y1;x2,y2)``. + cell : str + Cell name associated with the violation marker. Returns ------- :class:`.EdgeMarker` - :class:`.EdgeMarker` containing start and end points of the edge. + :class:`.EdgeMarker` containing start and end points of the edge in ``cell``. Raises ------ @@ -47,11 +49,11 @@ def parse_edge(value: str) -> EdgeMarker: match = re.match(pattern, value) if match: coords = [float(x) for x in match.groups()] - return EdgeMarker(edge=((coords[0], coords[1]), (coords[2], coords[3]))) + return EdgeMarker(cell=cell, edge=((coords[0], coords[1]), (coords[2], coords[3]))) raise ValueError(f"Invalid edge format: '{value}'.") -def parse_edge_pair(value: str) -> EdgePairMarker: +def parse_edge_pair(value: str, *, cell: str) -> EdgePairMarker: """ Extract coordinates from edge-pair format: ``(x1,y1;x2,y2)|(x3,y3;x4,y4)``. @@ -59,11 +61,13 @@ def parse_edge_pair(value: str) -> EdgePairMarker: ---------- value : str The edge-pair value string from DRC result database, with format ``(x1,y1;x2,y2)|(x3,y3;x4,y4)``. + cell : str + Cell name associated with the violation marker. Returns ------- :class:`.EdgePairMarker` - :class:`.EdgePairMarker` containing both edges' coordinates. + :class:`.EdgePairMarker` containing both edges' coordinates in ``cell``. Raises ------ @@ -78,10 +82,11 @@ def parse_edge_pair(value: str) -> EdgePairMarker: if match: coords = [float(x) for x in match.groups()] return EdgePairMarker( + cell=cell, edge_pair=( ((coords[0], coords[1]), (coords[2], coords[3])), ((coords[4], coords[5]), (coords[6], coords[7])), - ) + ), ) raise ValueError(f"Invalid edge-pair format: '{value}'.") @@ -120,7 +125,7 @@ def parse_polygon_coordinates(coords_str: str) -> DRCPolygon: return tuple(coords) -def parse_polygons(value: str) -> MultiPolygonMarker: +def parse_polygons(value: str, *, cell: str) -> MultiPolygonMarker: """ Extract coordinates from polygon format: ``(x1,y1;x2,y2;...)`` including multiple polygons separated by ``/``. @@ -129,11 +134,13 @@ def parse_polygons(value: str) -> MultiPolygonMarker: value : str The polygon value string from DRC result database, with format ``(x1,y1;x2,y2;...)`` or multiple polygons separated by ``/`` like ``(x1,y1;.../x3,y3;...)``. + cell : str + Cell name associated with the violation marker. Returns ------- :class:`.MultiPolygonMarker` - :class:`.MultiPolygonMarker` containing one or more polygon shapes. + :class:`.MultiPolygonMarker` containing one or more polygon shapes in ``cell``. Raises ------ @@ -154,10 +161,10 @@ def parse_polygons(value: str) -> MultiPolygonMarker: for part in polygon_parts: polygons.append(parse_polygon_coordinates(part.strip())) - return MultiPolygonMarker(polygons=tuple(polygons)) + return MultiPolygonMarker(cell=cell, polygons=tuple(polygons)) -def parse_violation_value(value: str) -> Union[EdgeMarker, EdgePairMarker, MultiPolygonMarker]: +def parse_violation_value(value: str, *, cell: str) -> DRCMarker: """ Parse a violation value based on its type (edge, edge-pair, or polygon). @@ -165,11 +172,13 @@ def parse_violation_value(value: str) -> Union[EdgeMarker, EdgePairMarker, Multi ---------- value : str The value string from DRC result database. + cell : str + Cell name associated with the violation marker. Returns ------- - Union[:class:`.EdgeMarker`, :class:`.EdgePairMarker`, :class:`.MultiPolygonMarker`] - The parsed violation marker. + :class:`.DRCMarker` + The parsed violation marker, annotated with the originating cell name. Raises ------ @@ -177,17 +186,23 @@ def parse_violation_value(value: str) -> Union[EdgeMarker, EdgePairMarker, Multi If the violation marker type is invalid. """ if value.startswith("edge: "): - return parse_edge(value=value.replace("edge: ", "")) + return parse_edge(value=value.replace("edge: ", ""), cell=cell) elif value.startswith("edge-pair: "): - return parse_edge_pair(value=value.replace("edge-pair: ", "")) + return parse_edge_pair(value=value.replace("edge-pair: ", ""), cell=cell) elif value.startswith("polygon: "): - return parse_polygons(value=value.replace("polygon: ", "")) + return parse_polygons(value=value.replace("polygon: ", ""), cell=cell) raise ValueError( f"Invalid marker type (should start with 'edge:', 'edge-pair:', or 'polygon:'): '{value}'." ) -class EdgeMarker(Tidy3dBaseModel): +class DRCMarker(Tidy3dBaseModel): + """Base marker storing the cell in which the violation was detected.""" + + cell: str = pd.Field(title="Cell", description="Cell name where the violation occurred.") + + +class EdgeMarker(DRCMarker): """A class for storing KLayout DRC edge marker results.""" edge: DRCEdge = pd.Field( @@ -196,7 +211,7 @@ class EdgeMarker(Tidy3dBaseModel): ) -class EdgePairMarker(Tidy3dBaseModel): +class EdgePairMarker(DRCMarker): """A class for storing KLayout DRC edge pair marker results.""" edge_pair: DRCEdgePair = pd.Field( @@ -205,7 +220,7 @@ class EdgePairMarker(Tidy3dBaseModel): ) -class MultiPolygonMarker(Tidy3dBaseModel): +class MultiPolygonMarker(DRCMarker): """A class for storing KLayout DRC multi-polygon marker results.""" polygons: DRCMultiPolygon = pd.Field( @@ -214,9 +229,6 @@ class MultiPolygonMarker(Tidy3dBaseModel): ) -DRCMarker = Union[EdgeMarker, EdgePairMarker, MultiPolygonMarker] - - class DRCViolation(Tidy3dBaseModel): """A class for storing KLayout DRC violation results for a single category.""" @@ -232,6 +244,26 @@ def count(self) -> int: """The number of DRC markers in this category.""" return len(self.markers) + @cached_property + def violated_cells(self) -> tuple[str, ...]: + """Tuple of cells containing markers for this violation.""" + seen = [] + for marker in self.markers: + if marker.cell not in seen: + seen.append(marker.cell) + return tuple(seen) + + @cached_property + def violations_by_cell(self) -> dict[str, DRCViolation]: + """Return a violation per cell scoped to those markers.""" + by_cell: dict[str, list[DRCMarker]] = {} + for marker in self.markers: + by_cell.setdefault(marker.cell, []).append(marker) + return { + cell: DRCViolation(category=self.category, markers=tuple(cell_markers)) + for cell, cell_markers in by_cell.items() + } + def __str__(self) -> str: """Get a nice string summary of the number of markers in this category.""" return f"{self.category}: {self.count}" @@ -262,6 +294,21 @@ def violation_counts(self) -> dict[str, int]: category: violation.count for category, violation in self.violations_by_category.items() } + @cached_property + def violations_by_cell(self) -> dict[str, list[DRCViolation]]: + """Aggregate violations grouped by cell across all categories.""" + by_cell: dict[str, list[DRCViolation]] = {} + for violation in self.violations_by_category.values(): + for cell, cell_violation in violation.violations_by_cell.items(): + cell_violations = by_cell.setdefault(cell, []) + cell_violations.append(cell_violation) + return by_cell + + @cached_property + def violated_cells(self) -> tuple[str, ...]: + """Tuple of cells that contain at least one violation.""" + return tuple(self.violations_by_cell.keys()) + @cached_property def categories(self) -> tuple[str, ...]: """A tuple of all DRC categories.""" @@ -402,8 +449,12 @@ def violations_from_file( if category_el is None or category_el.text is None: raise FileError("Encountered DRC item without a category in results file.") category = category_el.text.strip().strip("'\"") + cell_el = item.find("cell") + if cell_el is None or cell_el.text is None: + raise FileError("Encountered DRC item without a cell in results file.") + cell = cell_el.text.strip().strip("'\"") value = item.find("values/value").text - marker = parse_violation_value(value) + marker = parse_violation_value(value, cell=cell) markers = violations.setdefault(category, []) markers.append(marker)