Source code for tidy3d.plugins.klayout.drc.results

"""Methods for loading and parsing KLayout DRC results."""

from __future__ import annotations

import re
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Optional, Union

import pydantic.v1 as pd

from tidy3d.components.base import Tidy3dBaseModel, cached_property
from tidy3d.components.types import Coordinate2D
from tidy3d.exceptions import FileError
from tidy3d.log import log

# Types for DRC markers
DRCEdge = tuple[Coordinate2D, Coordinate2D]
DRCEdgePair = tuple[DRCEdge, DRCEdge]
DRCPolygon = tuple[Coordinate2D, ...]
DRCMultiPolygon = tuple[DRCPolygon, ...]

UNLIMITED_VIOLATION_WARNING_COUNT = 100_000


def parse_edge(value: str, *, cell: str) -> EdgeMarker:
    """
    Extract coordinates from edge format: ``(x1,y1;x2,y2)``.

    Parameters
    ----------
    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 in ``cell``.

    Raises
    ------
    ValueError
        If the edge format is invalid.
    """
    # Extract coordinates from edge format: (x1,y1;x2,y2)
    pattern = r"\(([\d.-]+),([\d.-]+);([\d.-]+),([\d.-]+)\)"
    match = re.match(pattern, value)
    if match:
        coords = [float(x) for x in match.groups()]
        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, *, cell: str) -> EdgePairMarker:
    """
    Extract coordinates from edge-pair format: ``(x1,y1;x2,y2)|(x3,y3;x4,y4)``.

    Parameters
    ----------
    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 in ``cell``.

    Raises
    ------
    ValueError
        If the edge-pair format is invalid.
    """
    # Extract coordinates from edge-pair format: (x1,y1;x2,y2)|(x3,y3;x4,y4)
    pattern = (
        r"\(([\d.-]+),([\d.-]+);([\d.-]+),([\d.-]+)\)\|\(([\d.-]+),([\d.-]+);([\d.-]+),([\d.-]+)\)"
    )
    match = re.match(pattern, value)
    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}'.")


def parse_polygon_coordinates(coords_str: str) -> DRCPolygon:
    """Parse coordinates for a single polygon into a tuple of (x, y) pairs.

    Parameters
    ----------
    coords_str : str
        The string of coordinates for a single polygon, with format ``x1,y1;x2,y2;...``.

    Returns
    -------
    DRCPolygon
        A tuple of (x, y) pairs for the polygon.

    Raises
    ------
    ValueError
        If the coordinate format is invalid.
    """
    coords_pattern = r"([\d.-]+),([\d.-]+)"  # match the coordinates

    # Split by semicolon to validate each coordinate pair
    coord_pairs = coords_str.split(";")

    # Each coordinate pair should match the pattern exactly
    coords = []
    for pair in coord_pairs:
        match = re.match(coords_pattern, pair)
        if not match:
            raise ValueError(f"Invalid coordinate pair in polygon: '{pair}'.")
        coords.append((float(match.group(1)), float(match.group(2))))

    return tuple(coords)


def parse_polygons(value: str, *, cell: str) -> MultiPolygonMarker:
    """
    Extract coordinates from polygon format: ``(x1,y1;x2,y2;...)`` including multiple polygons separated by ``/``.

    Parameters
    ----------
    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 in ``cell``.

    Raises
    ------
    ValueError
        If the polygon format is invalid or contains incomplete coordinate pairs.
    """
    # Extract the full content inside outer parentheses
    outer_pattern = r"\((.*)\)"
    match = re.match(outer_pattern, value)
    if not match:
        raise ValueError(f"Invalid polygon format: '{value}'.")

    coords_content = match.group(1)

    # Parse multiple polygons separated by '/'
    polygon_parts = coords_content.split("/")
    polygons = []
    for part in polygon_parts:
        polygons.append(parse_polygon_coordinates(part.strip()))

    return MultiPolygonMarker(cell=cell, polygons=tuple(polygons))


def parse_violation_value(value: str, *, cell: str) -> DRCMarker:
    """
    Parse a violation value based on its type (edge, edge-pair, or polygon).

    Parameters
    ----------
    value : str
        The value string from DRC result database.
    cell : str
        Cell name associated with the violation marker.

    Returns
    -------
    :class:`.DRCMarker`
        The parsed violation marker, annotated with the originating cell name.

    Raises
    ------
    ValueError
        If the violation marker type is invalid.
    """
    if value.startswith("edge: "):
        return parse_edge(value=value.replace("edge: ", ""), cell=cell)
    elif value.startswith("edge-pair: "):
        return parse_edge_pair(value=value.replace("edge-pair: ", ""), cell=cell)
    elif value.startswith("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 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(
        title="DRC Edge Marker",
        description="The edge marker of the DRC violation. The format is ((x1, y1), (x2, y2)).",
    )


class EdgePairMarker(DRCMarker):
    """A class for storing KLayout DRC edge pair marker results."""

    edge_pair: DRCEdgePair = pd.Field(
        title="DRC Edge Pair Marker",
        description="The edge pair marker of the DRC violation. The format is (edge1, edge2), where an edge has format ((x1, y1), (x2, y2)).",
    )


class MultiPolygonMarker(DRCMarker):
    """A class for storing KLayout DRC multi-polygon marker results."""

    polygons: DRCMultiPolygon = pd.Field(
        title="DRC Multi-Polygon Marker",
        description="The multi-polygon marker of the DRC violation. The format is (polygon1, polygon2, ...), where each polygon has format ((x1, y1), (x2, y2), ...).",
    )


class DRCViolation(Tidy3dBaseModel):
    """A class for storing KLayout DRC violation results for a single category."""

    category: str = pd.Field(
        title="DRC Violation Category", description="The category of the DRC violation."
    )
    markers: tuple[DRCMarker, ...] = pd.Field(
        title="DRC Markers", description="Tuple of DRC markers in this category."
    )

    @cached_property
    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}"


[docs] class DRCResults(Tidy3dBaseModel): """A class for loading and storing KLayout DRC results.""" violations_by_category: dict[str, DRCViolation] = pd.Field( title="DRC Violations", description="Dictionary of DRC violations by category." ) @cached_property def is_clean(self) -> bool: """Whether the DRC is clean (no violations).""" return all(v.count == 0 for v in self.violations_by_category.values()) @cached_property def violation_counts(self) -> dict[str, int]: """Counts violations by category. Returns ------- dict[str, int] A dictionary of violation counts for each category. """ return { 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.""" return tuple(self.violations_by_category.keys())
[docs] def __getitem__(self, category: str) -> DRCViolation: """Get DRC violation result by category. Parameters ---------- category : str The category of the DRC violation. Returns ------- :class:`.DRCViolation` The DRC violation result for the given category. """ return self.violations_by_category[category]
[docs] def __str__(self) -> str: """Get a nice string representation of the DRC results.""" summary = "DRC results summary\n" summary += "--------------------------------\n" summary += f"Total violations: {sum(violation.count for violation in self.violations_by_category.values())}\n\n" summary += "Violations by category:\n" for violation in self.violations_by_category.values(): summary += violation.__str__() + "\n" return summary
[docs] @classmethod def load( cls, resultsfile: Union[str, Path], max_results: Optional[int] = None, ) -> DRCResults: """Create a :class:`.DRCResults` instance from a results file. Parameters ---------- resultsfile : Union[str, Path] Path to the KLayout DRC results file. max_results : Optional[int] Maximum number of markers to load from the file. If ``None`` (default), all markers are loaded. Returns ------- :class:`.DRCResults` A :class:`.DRCResults` object containing the DRC results. Raises ------ FileNotFoundError If the DRC result file is not found. ET.ParseError If the DRC result file is not a valid XML file. Example ------- >>> from tidy3d.plugins.klayout.drc import DRCResults >>> results = DRCResults.load(resultsfile="drc_results.lyrdb") # doctest: +SKIP >>> print(results) # doctest: +SKIP """ violations = violations_from_file( resultsfile=resultsfile, max_results=max_results, ) return cls.construct(violations_by_category=violations)
def violations_from_file( resultsfile: Union[str, Path], max_results: Optional[int] = None, ) -> dict[str, DRCViolation]: """Loads a KLayout DRC results file and returns the results as a dictionary of :class:`.DRCViolation` objects. Parameters ---------- resultsfile : Union[str, Path] Path to the KLayout DRC results file. max_results : Optional[int] Maximum number of markers to load from the file. If ``None`` (default), all markers are loaded. Returns ------- dict[str, :class:`.DRCViolation`] A dictionary of :class:`.DRCViolation` objects for each category. Raises ------ FileNotFoundError If the DRC result file is not found. ET.ParseError If the DRC result file is not a valid XML file. """ if max_results is not None and max_results <= 0: raise ValueError("'max_results' must be a positive integer.") # Parse the results file try: xmltree = ET.parse(resultsfile) except FileNotFoundError as err: raise FileError(f"DRC result file not found: '{resultsfile}'.") from err except ET.ParseError as err: raise ET.ParseError(f"Invalid XML format in DRC result file: '{resultsfile}'.") from err # Initialize violations dict with all the categories violations = {} for category in xmltree.getroot().findall(".//categories/category/name"): category_name = category.text if category_name is None: raise FileError("Encountered DRC category without a name in results file.") category_name = category_name.strip().strip("'\"") violations[category_name] = [] # Prepare the items and warn if necessary items = list(xmltree.getroot().findall(".//item")) total_markers = len(items) if max_results is None and total_markers > UNLIMITED_VIOLATION_WARNING_COUNT: log.warning( f"DRC result file contains many markers ({total_markers}), " "which can affect loading performance. " "Pass 'max_results' to limit loaded results." ) elif max_results is not None and total_markers > max_results: log.warning( f"DRC result file contains {total_markers} markers; " f"only the first {max_results} were loaded due to 'max_results'." ) # Parse markers for idx, item in enumerate(items): if max_results is not None and idx >= max_results: break category_el = item.find("category") 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, cell=cell) markers = violations.setdefault(category, []) markers.append(marker) return { category: DRCViolation(category=category, markers=tuple(markers)) for category, markers in violations.items() }