Skip to content

Workflow Engine

openlithohub.workflow.parsing

Layout file parsing via KLayout Python API.

parse_layout(path, *, lef_files=None)

Parse an OASIS / GDSII / DEF / LEF layout file.

Returns dictionary with 'cells', 'layers', 'bounding_box', and '_layout' handle.

DEF (Design Exchange Format, IEEE 1481) is the standard placed-and-routed layout dump produced by Innovus / ICC2 / OpenROAD. DEF carries placement + routing geometry but not cell internals — those live in companion LEF files. Pass lef_files=[...] to feed cell abstracts so DEF components resolve to real polygons rather than empty placeholders.

LEF-only inputs (.lef) are also accepted, for tools that want to inspect cell abstracts without a placed design.

Source code in src/openlithohub/workflow/parsing.py
def parse_layout(
    path: str | Path,
    *,
    lef_files: list[str | Path] | None = None,
) -> dict[str, Any]:
    """Parse an OASIS / GDSII / DEF / LEF layout file.

    Returns dictionary with 'cells', 'layers', 'bounding_box', and '_layout' handle.

    DEF (Design Exchange Format, IEEE 1481) is the standard placed-and-routed
    layout dump produced by Innovus / ICC2 / OpenROAD. DEF carries placement
    + routing geometry but **not** cell internals — those live in companion
    LEF files. Pass ``lef_files=[...]`` to feed cell abstracts so DEF
    components resolve to real polygons rather than empty placeholders.

    LEF-only inputs (``.lef``) are also accepted, for tools that want to
    inspect cell abstracts without a placed design.
    """
    path = Path(path)
    if not path.exists():
        raise FileNotFoundError(f"Layout file not found: {path}")

    suffix = path.suffix.lower()
    if suffix not in (".oas", ".gds", ".gds2", ".oasis", ".def", ".lef"):
        raise ValueError(f"Unsupported layout format: {suffix}. Use .oas / .gds / .def / .lef")

    try:
        import klayout.db as db
    except ImportError:
        raise ImportError(
            "klayout is required for layout parsing. "
            "Install with: pip install openlithohub[workflow]"
        ) from None

    layout = db.Layout()
    if suffix in (".def", ".lef"):
        # DEF/LEF reader: stream LEF files in via the reader options so
        # cell abstracts resolve when we read the DEF. Without LEF, a DEF
        # parse yields component placements but no internal geometry —
        # which is rarely what callers want from `parse_layout`.
        opts = db.LoadLayoutOptions()
        if lef_files:
            opts.lefdef_config.lef_files = [str(Path(p)) for p in lef_files]
            # When the caller hands us explicit LEF files, suppress
            # KLayout's auto-rescan of the DEF directory for *.lef —
            # otherwise the same LEF gets parsed twice and KLayout
            # raises "Duplicate MACRO" before we ever see the geometry.
            opts.lefdef_config.read_lef_with_def = False
        layout.read(str(path), opts)
    else:
        layout.read(str(path))

    cells: list[dict[str, Any]] = []
    for cell_idx in range(layout.cells()):
        cell = layout.cell(cell_idx)
        cells.append(
            {
                "name": cell.name,
                "index": cell_idx,
                "instance_count": cell.child_instances(),
            }
        )

    layers: list[dict[str, Any]] = []
    for layer_idx in layout.layer_indices():
        info = layout.get_info(layer_idx)
        layers.append(
            {
                "layer": info.layer,
                "datatype": info.datatype,
                "name": info.name if info.name else f"{info.layer}/{info.datatype}",
            }
        )

    top_cells = list(layout.top_cells())
    if not top_cells:
        raise ValueError(f"Layout {path.name} has no top cells.")
    top_cell = top_cells[0]
    bbox = top_cell.bbox()
    bounding_box = {
        "x_min": bbox.left,
        "y_min": bbox.bottom,
        "x_max": bbox.right,
        "y_max": bbox.top,
        "dbu": layout.dbu,
    }

    return {
        "cells": cells,
        "layers": layers,
        "bounding_box": bounding_box,
        "_layout": layout,
    }

openlithohub.workflow.tiling

Full-chip tiling strategy for distributed processing.

Tile dataclass

A single tile from a layout partition.

Source code in src/openlithohub/workflow/tiling.py
@dataclass
class Tile:
    """A single tile from a layout partition."""

    tensor: torch.Tensor
    origin_x: int
    origin_y: int
    width: int
    height: int
    overlap: int

tile_layout(layout_tensor, tile_size=2048, overlap=128)

Partition a full-chip layout tensor into overlapping tiles.

Uses a sliding window with configurable overlap. Boundary tiles are anchored to the layout edge (origin pulled back so the tile fits entirely inside the layout) so the model sees real layout context instead of zero-padding artefacts. The resulting boundary tiles overlap further with their neighbours, which stitch_tiles blends correctly via its weight-map normalization.

Parameters:

Name Type Description Default
layout_tensor Tensor

Full layout as tensor (H, W).

required
tile_size int

Size of each square tile in pixels.

2048
overlap int

Overlap between adjacent tiles for seamless stitching.

128

Returns:

Type Description
list[Tile]

List of Tile objects covering the full layout.

Raises:

Type Description
ValueError

If overlap >= tile_size or tile_size <= 0.

Source code in src/openlithohub/workflow/tiling.py
def tile_layout(
    layout_tensor: torch.Tensor,
    tile_size: int = 2048,
    overlap: int = 128,
) -> list[Tile]:
    """Partition a full-chip layout tensor into overlapping tiles.

    Uses a sliding window with configurable overlap. Boundary tiles are
    *anchored* to the layout edge (origin pulled back so the tile fits
    entirely inside the layout) so the model sees real layout context
    instead of zero-padding artefacts. The resulting boundary tiles overlap
    further with their neighbours, which ``stitch_tiles`` blends correctly
    via its weight-map normalization.

    Args:
        layout_tensor: Full layout as tensor (H, W).
        tile_size: Size of each square tile in pixels.
        overlap: Overlap between adjacent tiles for seamless stitching.

    Returns:
        List of Tile objects covering the full layout.

    Raises:
        ValueError: If overlap >= tile_size or tile_size <= 0.
    """
    if tile_size <= 0:
        raise ValueError(f"tile_size must be positive, got {tile_size}")
    if overlap < 0:
        raise ValueError(f"overlap must be non-negative, got {overlap}")
    if overlap >= tile_size:
        raise ValueError(f"overlap ({overlap}) must be less than tile_size ({tile_size})")

    h, w = layout_tensor.shape[-2], layout_tensor.shape[-1]
    step = tile_size - overlap
    tiles: list[Tile] = []
    seen_origins: set[tuple[int, int]] = set()

    # Layout smaller than tile_size in an axis ⇒ a single tile in that axis
    # is sufficient. Without this guard, the sliding window still emits one
    # tile per `step` along the axis, all zero-padded duplicates of the
    # entire layout, multiplying forward-model work for no coverage gain.
    h_iter_cap = 1 if h < tile_size else h
    w_iter_cap = 1 if w < tile_size else w

    y = 0
    while y < h_iter_cap:
        x = 0
        while x < w_iter_cap:
            y_end = min(y + tile_size, h)
            x_end = min(x + tile_size, w)
            actual_h = y_end - y
            actual_w = x_end - x

            if actual_h < tile_size and h >= tile_size:
                # Anchor the tile to the bottom edge so its full extent is
                # filled with real layout, not zero pad. This pulls the
                # origin back; the extra coverage overlaps the previous row
                # and is handled by stitch_tiles' weight-map blending.
                y_origin = h - tile_size
                tile_h_real = tile_size
            else:
                y_origin = y
                tile_h_real = actual_h

            if actual_w < tile_size and w >= tile_size:
                x_origin = w - tile_size
                tile_w_real = tile_size
            else:
                x_origin = x
                tile_w_real = actual_w

            y_real_end = y_origin + tile_h_real
            x_real_end = x_origin + tile_w_real
            tile_data = layout_tensor[..., y_origin:y_real_end, x_origin:x_real_end]

            if tile_h_real < tile_size or tile_w_real < tile_size:
                # Layout smaller than tile_size in some axis — must zero-pad,
                # there is no real layout to anchor to.
                pad_bottom = tile_size - tile_h_real
                pad_right = tile_size - tile_w_real
                tile_data = functional.pad(tile_data, (0, pad_right, 0, pad_bottom), value=0.0)

            # When the sliding window's last position past the layout edge
            # gets anchored back to the same origin as a previous tile, skip
            # it — emitting two tiles with identical origins doubles forward-
            # model work and does not change the stitched output.
            origin_key = (x_origin, y_origin)
            if origin_key not in seen_origins:
                seen_origins.add(origin_key)
                tiles.append(
                    Tile(
                        tensor=tile_data,
                        origin_x=x_origin,
                        origin_y=y_origin,
                        width=tile_w_real,
                        height=tile_h_real,
                        overlap=overlap,
                    )
                )

            x += step
        y += step

    return tiles

stitch_tiles(tiles, output_shape)

Reassemble optimized tiles into a full-chip tensor.

Uses linear blending in overlap regions to avoid seam artifacts.

Parameters:

Name Type Description Default
tiles list[tuple[Tile, Tensor]]

List of (original_tile, optimized_tensor) pairs.

required
output_shape tuple[int, int]

(H, W) of the full output tensor.

required

Returns:

Type Description
Tensor

Stitched tensor of shape output_shape.

Source code in src/openlithohub/workflow/tiling.py
def stitch_tiles(
    tiles: list[tuple[Tile, torch.Tensor]],
    output_shape: tuple[int, int],
) -> torch.Tensor:
    """Reassemble optimized tiles into a full-chip tensor.

    Uses linear blending in overlap regions to avoid seam artifacts.

    Args:
        tiles: List of (original_tile, optimized_tensor) pairs.
        output_shape: (H, W) of the full output tensor.

    Returns:
        Stitched tensor of shape output_shape.
    """
    h, w = output_shape
    device = tiles[0][1].device if tiles else torch.device("cpu")
    output = torch.zeros(h, w, device=device)
    weight_map = torch.zeros(h, w, device=device)

    for tile, result in tiles:
        tile_h = tile.height
        tile_w = tile.width
        result_2d = result[..., :tile_h, :tile_w]
        if result_2d.ndim > 2:
            result_2d = result_2d.squeeze()

        blend = torch.ones(tile_h, tile_w, device=device)

        if tile.overlap > 0:
            ramp = torch.linspace(0.0, 1.0, tile.overlap, device=device)

            if tile.origin_x > 0:
                left_ramp = ramp.unsqueeze(0).expand(tile_h, -1)
                ol = min(tile.overlap, tile_w)
                blend[:, :ol] *= left_ramp[:, :ol]

            if tile.origin_y > 0:
                top_ramp = ramp.unsqueeze(1).expand(-1, tile_w)
                ol = min(tile.overlap, tile_h)
                blend[:ol, :] *= top_ramp[:ol, :]

            if tile.origin_x + tile_w < w:
                right_ramp = ramp.flip(0).unsqueeze(0).expand(tile_h, -1)
                ol = min(tile.overlap, tile_w)
                blend[:, -ol:] *= right_ramp[:, -ol:]

            if tile.origin_y + tile_h < h:
                bottom_ramp = ramp.flip(0).unsqueeze(1).expand(-1, tile_w)
                ol = min(tile.overlap, tile_h)
                blend[-ol:, :] *= bottom_ramp[-ol:, :]

        y_end = tile.origin_y + tile_h
        x_end = tile.origin_x + tile_w
        output[tile.origin_y : y_end, tile.origin_x : x_end] += result_2d * blend
        weight_map[tile.origin_y : y_end, tile.origin_x : x_end] += blend

    nonzero = weight_map > 0
    output[nonzero] /= weight_map[nonzero]
    return output

openlithohub.workflow.halo

Process-node-aware tile halo sizing (RFC 0005).

The halo is the per-tile guard band of overlapping pixels that lets the forward lithography model see real layout context at tile boundaries instead of zero-padded artefacts. Two physical phenomena set the lower bound:

  1. Optical interaction radius (OIR) — light at a tile boundary sees neighbours through the imaging kernel; carried by ProcessNodeConfig.optical_radius_nm.
  2. Model receptive field — convolutional models also propagate information across pixels; carried by LithographyModel.RECEPTIVE_FIELD_PX.

This module centralises the math that picks max(OIR_px, RF_px), rounds up to a stride-friendly multiple, and clamps to fit inside the caller's tile size.

DEFAULT_HALO_PX = 128 module-attribute

Pre-RFC-0005 fixed halo. Used when neither node nor model is provided.

compute_halo_px(node, model, pixel_nm, tile_size)

Pick a tile halo big enough for the optical kernel and the model RF.

Parameters:

Name Type Description Default
node ProcessNodeConfig | None

Process node carrying optical_radius_nm. None means "no physical radius known" and contributes 0 to the max.

required
model LithographyModel | None

Model carrying RECEPTIVE_FIELD_PX. None contributes 0.

required
pixel_nm float

Pixel pitch in nanometers (used to convert OIR → pixels).

required
tile_size int

Tile width in pixels. The returned halo is clamped so that halo < tile_size (tile_layout rejects otherwise).

required

Returns:

Type Description
int

Halo size in pixels. DEFAULT_HALO_PX when both node and

int

model are None (preserves pre-RFC-0005 behaviour).

Source code in src/openlithohub/workflow/halo.py
def compute_halo_px(
    node: ProcessNodeConfig | None,
    model: LithographyModel | None,
    pixel_nm: float,
    tile_size: int,
) -> int:
    """Pick a tile halo big enough for the optical kernel and the model RF.

    Args:
        node: Process node carrying ``optical_radius_nm``. ``None`` means
            "no physical radius known" and contributes 0 to the max.
        model: Model carrying ``RECEPTIVE_FIELD_PX``. ``None`` contributes 0.
        pixel_nm: Pixel pitch in nanometers (used to convert OIR → pixels).
        tile_size: Tile width in pixels. The returned halo is clamped so
            that ``halo < tile_size`` (``tile_layout`` rejects otherwise).

    Returns:
        Halo size in pixels. ``DEFAULT_HALO_PX`` when both ``node`` and
        ``model`` are ``None`` (preserves pre-RFC-0005 behaviour).
    """
    if pixel_nm <= 0:
        raise ValueError(f"pixel_nm must be positive, got {pixel_nm}")
    if tile_size <= 1:
        raise ValueError(f"tile_size must be > 1, got {tile_size}")

    if node is None and model is None:
        return min(DEFAULT_HALO_PX, tile_size - 1)

    oir_px = math.ceil(node.optical_radius_nm / pixel_nm) if node is not None else 0
    rf_px = model.receptive_field_px if model is not None else 0

    raw = max(oir_px, rf_px)
    rounded = _round_up(raw, _HALO_ROUND_PX)
    return max(0, min(rounded, tile_size - 1))

describe_halo(halo_px, node, model, pixel_nm)

One-line provenance string for the resolved halo, for CLI logging.

Source code in src/openlithohub/workflow/halo.py
def describe_halo(
    halo_px: int,
    node: ProcessNodeConfig | None,
    model: LithographyModel | None,
    pixel_nm: float,
) -> str:
    """One-line provenance string for the resolved halo, for CLI logging."""
    halo_nm = halo_px * pixel_nm
    parts: list[str] = []
    if node is not None:
        parts.append(f"{node.name} (OIR={node.optical_radius_nm:.0f} nm)")
    if model is not None:
        parts.append(f"{model.name} (RF={model.receptive_field_px} px)")
    if not parts:
        return f"{halo_px} px (≈{halo_nm:.0f} nm at {pixel_nm} nm/px) — fixed default"
    src = " + ".join(parts)
    return f"{halo_px} px (≈{halo_nm:.0f} nm at {pixel_nm} nm/px) — auto from {src}"

openlithohub.workflow.parallel

Multi-process tile inference for openlithohub optimize run.

RFC 0004 picks torch.multiprocessing.spawn over tile shards as the v0.3 direction. The model layer stays untouched so ONNX/TorchScript export keeps returning a bare nn.Module; parallelism wraps the tile loop, not the model.

v0.7 (WS-E): shared-weight dispatch. The parent process loads model weights into CPU shared memory before spawning workers. Workers memory-map the weights instead of independently re-instantiating and reloading, reducing peak memory from O(N × model_size) to O(model_size + N × activation_size).

Compile-cache: when --compile is active, the Inductor cache directory is set in the parent so workers reuse compiled kernels without per-worker recompilation.

parallel_tile_inference(model_name, model_kwargs, tiles, *, num_gpus, base_perf_kwargs, progress_cb=None)

Shard tiles round-robin across num_gpus worker processes.

The parent loads model weights into CPU shared memory before spawning. Workers memory-map these weights, reducing peak memory from O(N x model_size) to approximately O(model_size + N x activation_size).

Falls back to CPU dispatch when fewer than num_gpus CUDA devices are visible — this is what makes CPU-only CI exercise the dispatch logic.

Source code in src/openlithohub/workflow/parallel.py
def parallel_tile_inference(
    model_name: str,
    model_kwargs: dict[str, Any],
    tiles: list[Tile],
    *,
    num_gpus: int,
    base_perf_kwargs: dict[str, Any],
    progress_cb: Callable[[], None] | None = None,
) -> list[tuple[Tile, torch.Tensor]]:
    """Shard tiles round-robin across ``num_gpus`` worker processes.

    The parent loads model weights into CPU shared memory before spawning.
    Workers memory-map these weights, reducing peak memory from
    O(N x model_size) to approximately O(model_size + N x activation_size).

    Falls back to CPU dispatch when fewer than ``num_gpus`` CUDA devices
    are visible — this is what makes CPU-only CI exercise the dispatch
    logic.
    """
    if num_gpus < 1:
        raise ValueError(f"num_gpus must be >= 1, got {num_gpus}")
    if not tiles:
        return []

    effective = min(num_gpus, len(tiles))
    shards = _round_robin_shards(len(tiles), effective)

    # Set up compile cache if torch.compile is active
    compile_cache_dir = _setup_compile_cache()

    # Load weights into CPU shared memory
    weight_state = _share_weights(model_name, model_kwargs)

    ctx = mp.get_context("spawn")
    queue: mp.Queue[Any] = ctx.Queue()
    processes: list[Any] = []

    for rank, indices in enumerate(shards):
        payload = [(idx, tiles[idx].tensor.detach().cpu().numpy()) for idx in indices]
        p = ctx.Process(
            target=_worker,
            args=(
                rank,
                effective,
                model_name,
                model_kwargs,
                base_perf_kwargs,
                payload,
                queue,
                weight_state,
                compile_cache_dir,
            ),
            daemon=False,
        )
        p.start()
        processes.append(p)

    results: dict[int, torch.Tensor] = {}
    expected = len(tiles)
    try:
        while len(results) < expected:
            try:
                item = queue.get(timeout=_DEFAULT_TIMEOUT_SECONDS)
            except queue_mod.Empty:
                if any(not p.is_alive() for p in processes) and queue.empty():
                    dead = [p for p in processes if not p.is_alive()]
                    if dead:
                        codes = ", ".join(
                            f"rank={i} exit={p.exitcode}"
                            for i, p in enumerate(processes)
                            if not p.is_alive()
                        )
                        raise RuntimeError(
                            f"parallel_tile_inference: worker(s) exited ({codes}), "
                            f"received {len(results)}/{expected} results"
                        ) from None
                continue

            if isinstance(item, tuple) and item and item[0] == "error":
                _, rank, exc_repr, tb = item
                _terminate(processes)
                raise RuntimeError(
                    f"parallel_tile_inference: worker rank={rank} failed: {exc_repr}\n{tb}"
                )

            idx, mask_arr = item
            results[idx] = torch.from_numpy(mask_arr)
            if progress_cb is not None:
                progress_cb()
    except KeyboardInterrupt:
        _terminate(processes)
        raise
    finally:
        for p in processes:
            p.join(timeout=5.0)
            if p.is_alive():
                p.terminate()
                p.join(timeout=5.0)

    return [(tiles[idx], results[idx]) for idx in range(expected)]

openlithohub.workflow.contour.manhattan

Manhattan (staircase) contour extraction for traditional VSB writers.

extract_manhattan_contour(mask, pixel_size_nm=1.0)

Extract Manhattan (rectilinear) polygon contours from a binary mask.

Traces pixel-edge boundaries between foreground and background regions, producing axis-aligned polygons suitable for VSB mask writers.

The algorithm: 1. Find horizontal and vertical boundary edges between 0/1 pixels 2. Build an adjacency graph of edges sharing vertices 3. Trace closed loops through the edge graph

Vertices are at pixel corners (not pixel centers), scaled by pixel_size_nm.

Parameters:

Name Type Description Default
mask Tensor

Binary mask tensor (H, W).

required
pixel_size_nm float

Physical pixel size for coordinate scaling.

1.0

Returns:

Type Description
list[list[tuple[float, float]]]

List of polygons, each as a list of (x_nm, y_nm) vertices in order.

list[list[tuple[float, float]]]

Outer boundaries are clockwise, holes are counter-clockwise.

Source code in src/openlithohub/workflow/contour/manhattan.py
def extract_manhattan_contour(
    mask: torch.Tensor,
    pixel_size_nm: float = 1.0,
) -> list[list[tuple[float, float]]]:
    """Extract Manhattan (rectilinear) polygon contours from a binary mask.

    Traces pixel-edge boundaries between foreground and background regions,
    producing axis-aligned polygons suitable for VSB mask writers.

    The algorithm:
    1. Find horizontal and vertical boundary edges between 0/1 pixels
    2. Build an adjacency graph of edges sharing vertices
    3. Trace closed loops through the edge graph

    Vertices are at pixel corners (not pixel centers), scaled by pixel_size_nm.

    Args:
        mask: Binary mask tensor (H, W).
        pixel_size_nm: Physical pixel size for coordinate scaling.

    Returns:
        List of polygons, each as a list of (x_nm, y_nm) vertices in order.
        Outer boundaries are clockwise, holes are counter-clockwise.
    """
    m = ensure_2d(mask)
    arr = (m > 0.5).detach().cpu().numpy().astype(np.int8)
    h, w = arr.shape

    # Pad with zeros to ensure boundaries are closed at image edges
    padded = np.pad(arr, 1, mode="constant", constant_values=0)

    # Find horizontal edges: between row i and row i+1
    # An edge exists where padded[i, j] != padded[i+1, j]
    h_edges: set[tuple[int, int, int, int]] = set()
    v_edges: set[tuple[int, int, int, int]] = set()

    ph, pw = padded.shape

    # Horizontal edges: edge from (j, i) to (j+1, i) in vertex space
    # A horizontal edge at grid row i exists between pixel rows i-1 and i
    for i in range(ph - 1):
        for j in range(pw - 1):
            if padded[i, j] != padded[i + 1, j]:
                h_edges.add((j, i + 1, j + 1, i + 1))  # (x1, y1, x2, y2) left to right

    # Vertical edges: edge from (j, i) to (j, i+1) in vertex space
    for i in range(ph - 1):
        for j in range(pw - 1):
            if padded[i, j] != padded[i, j + 1]:
                v_edges.add((j + 1, i, j + 1, i + 1))  # top to bottom

    all_edges = h_edges | v_edges
    if not all_edges:
        return []

    # Build adjacency: vertex -> list of connected edges
    vertex_to_edges: dict[tuple[int, int], list[tuple[int, int, int, int]]] = {}
    for edge in all_edges:
        x1, y1, x2, y2 = edge
        vertex_to_edges.setdefault((x1, y1), []).append(edge)
        vertex_to_edges.setdefault((x2, y2), []).append(edge)

    # Trace closed loops
    remaining = set(all_edges)
    polygons: list[list[tuple[float, float]]] = []
    max_total_iters = 2 * len(all_edges)

    while remaining:
        start_edge = next(iter(remaining))
        polygon_vertices: list[tuple[int, int]] = []

        x1, y1, x2, y2 = start_edge
        polygon_vertices.append((x1, y1))
        current_vertex = (x2, y2)
        in_dir = (x2 - x1, y2 - y1)
        remaining.discard(start_edge)
        inner_iters = 0

        while current_vertex != (x1, y1):
            inner_iters += 1
            if inner_iters > max_total_iters:
                break
            polygon_vertices.append(current_vertex)
            candidates = vertex_to_edges.get(current_vertex, [])
            next_edge = _pick_next_edge(current_vertex, in_dir, candidates, remaining)

            if next_edge is None:
                break

            remaining.discard(next_edge)
            ex1, ey1, ex2, ey2 = next_edge
            next_vertex = (ex2, ey2) if (ex1, ey1) == current_vertex else (ex1, ey1)
            in_dir = (next_vertex[0] - current_vertex[0], next_vertex[1] - current_vertex[1])
            current_vertex = next_vertex

        # Convert to physical coordinates (subtract 1 for padding offset)
        scaled: list[tuple[float, float]] = []
        for vx, vy in polygon_vertices:
            scaled.append(((vx - 1) * pixel_size_nm, (vy - 1) * pixel_size_nm))

        if len(scaled) >= 3:
            polygons.append(_simplify_collinear(scaled))

    return polygons

openlithohub.workflow.contour.curvilinear

Curvilinear contour extraction and B-spline fitting for OASIS export.

Curvilinear masks (post-ILT, EUV, MBMW writers) are emitted as high-resolution sampled polygons on a designated layer of a real OASIS file. Native SEMI P44 curve primitives are not yet emitted — klayout.db does not surface them in its public Python API at the time of writing — but the file produced here is a valid OASIS file that any vendor tool can read.

BSplineCurve dataclass

Representation of a fitted B-spline curve.

Source code in src/openlithohub/workflow/contour/curvilinear.py
@dataclass
class BSplineCurve:
    """Representation of a fitted B-spline curve."""

    control_points: torch.Tensor
    knots: torch.Tensor
    degree: int = 3

fit_bspline(contour_pixels, tolerance_nm=0.5, pixel_size_nm=1.0, *, warn_on_skip=True)

Fit B-spline curves to pixel-level contour data.

If input is a 2D binary mask, extracts boundary contours first. If input is an (N, 2) tensor, treats it as a single ordered point loop.

Loops with fewer than 5 points (the minimum for a cubic periodic spline) and loops where splprep fails to converge are skipped. Both used to be silently dropped — small features (SRAFs, sharp points) would disappear from the OASIS export with no signal. Now a UserWarning is emitted per skipped loop unless warn_on_skip=False; callers that intentionally feed mixed-size geometry can opt out.

The smoothing factor passed to splprep is tolerance_nm**2 * n_points (matches scipy's "sum of squared residuals" semantics) so long contours are not over-smoothed relative to short ones.

Source code in src/openlithohub/workflow/contour/curvilinear.py
def fit_bspline(
    contour_pixels: torch.Tensor,
    tolerance_nm: float = 0.5,
    pixel_size_nm: float = 1.0,
    *,
    warn_on_skip: bool = True,
) -> list[BSplineCurve]:
    """Fit B-spline curves to pixel-level contour data.

    If input is a 2D binary mask, extracts boundary contours first.
    If input is an (N, 2) tensor, treats it as a single ordered point loop.

    Loops with fewer than 5 points (the minimum for a cubic periodic
    spline) and loops where ``splprep`` fails to converge are skipped.
    Both used to be silently dropped — small features (SRAFs, sharp
    points) would disappear from the OASIS export with no signal. Now a
    ``UserWarning`` is emitted per skipped loop unless
    ``warn_on_skip=False``; callers that intentionally feed mixed-size
    geometry can opt out.

    The smoothing factor passed to ``splprep`` is ``tolerance_nm**2 *
    n_points`` (matches scipy's "sum of squared residuals" semantics) so
    long contours are not over-smoothed relative to short ones.
    """
    try:
        from scipy.interpolate import splprep
    except ImportError:
        raise ImportError(
            "scipy is required for B-spline fitting. "
            "Install with: pip install openlithohub[workflow]"
        ) from None

    if contour_pixels.ndim == 2 and contour_pixels.shape[1] == 2:
        loops = [contour_pixels.detach().cpu().numpy()]
    else:
        m = ensure_2d(contour_pixels)
        arr = (m > 0.5).detach().cpu().numpy().astype(np.int8)
        loops = trace_contour(arr)

    curves: list[BSplineCurve] = []

    for idx, loop in enumerate(loops):
        n = len(loop)
        if n < 5:
            if warn_on_skip:
                warnings.warn(
                    f"fit_bspline: loop {idx} has {n} points (< 5 needed for cubic "
                    f"periodic spline); skipping. Small features may be lost.",
                    UserWarning,
                    stacklevel=2,
                )
            continue

        # Drop a duplicated closing vertex: ``splprep(per=True)`` builds
        # the periodic boundary itself; passing the closure point twice
        # makes scipy see a zero-length segment and return a degenerate
        # spline with seam oscillation.
        if np.allclose(loop[0], loop[-1]):
            loop = loop[:-1]
            n = len(loop)
            if n < 5:
                if warn_on_skip:
                    warnings.warn(
                        f"fit_bspline: loop {idx} reduced to {n} points after "
                        f"closure dedup; skipping.",
                        UserWarning,
                        stacklevel=2,
                    )
                continue

        # Drop consecutive duplicate points anywhere in the loop. Moore
        # neighborhood tracing in ``trace_contour`` can revisit the same
        # boundary edge, producing zero-length segments that make
        # ``splprep`` raise "Invalid inputs" or singular-matrix errors.
        diffs = np.diff(loop, axis=0, append=loop[:1])
        keep = np.any(np.abs(diffs) > 1e-9, axis=1)
        if not np.all(keep):
            loop = loop[keep]
            n = len(loop)
            if n < 5:
                if warn_on_skip:
                    warnings.warn(
                        f"fit_bspline: loop {idx} reduced to {n} points after "
                        f"consecutive-duplicate dedup; skipping.",
                        UserWarning,
                        stacklevel=2,
                    )
                continue

        loop_scaled = loop * pixel_size_nm
        smoothing = (tolerance_nm**2) * n

        try:
            tck, _ = splprep(
                [loop_scaled[:, 1], loop_scaled[:, 0]],
                s=smoothing,
                per=True,
                k=3,
            )
        except (ValueError, TypeError) as e:
            if warn_on_skip:
                warnings.warn(
                    f"fit_bspline: splprep failed on loop {idx} (n={n}, "
                    f"tolerance_nm={tolerance_nm}): {e}; skipping.",
                    UserWarning,
                    stacklevel=2,
                )
            continue

        ctrl_x = np.array(tck[1][0], dtype=np.float32)
        ctrl_y = np.array(tck[1][1], dtype=np.float32)
        control_points = torch.tensor(np.stack([ctrl_x, ctrl_y], axis=1), dtype=torch.float32)
        knots = torch.tensor(tck[0], dtype=torch.float32)

        curves.append(BSplineCurve(control_points=control_points, knots=knots, degree=3))

    return curves

export_oasis_mbw(curves, output_path, *, samples_per_curve=64, pixel_size_nm=1.0, layer=1, datatype=0, cell_name='TOP', min_area_nm2=0.0, vertex_tolerance_nm=0.0)

Serialize B-spline curves to an OASIS file via klayout.db.

Curves are sampled to high-resolution polygons (samples_per_curve vertices per loop) and inserted into a single top cell on the requested (layer, datatype). The output is a real SEMI P39 OASIS file readable by KLayout, Calibre, and other industry tools — not a custom binary blob.

Native SEMI P44 multi-beam curve primitives are not yet emitted; the polygon approximation is the standard interim representation that all multi-beam writer flows accept.

min_area_nm2 is an opt-in filter for sub-resolution islands: any sampled polygon with absolute area below this threshold is dropped before insertion. Default 0.0 keeps every shape so academic / Hackathon evaluation stays bit-exact. A positive value is intended for fab-ready exports where MRC would otherwise reject the smallest SRAFs a curvilinear ILT can produce; the count of dropped shapes is logged at INFO level so the filter is auditable.

vertex_tolerance_nm is an opt-in Ramer-Douglas-Peucker simplification on each sampled polygon: any vertex within this perpendicular distance of the chord between its surviving neighbours is dropped. Default 0.0 keeps every sampled vertex (bit-exact academic behaviour). Positive values cut OASIS file size dramatically on smooth ILT contours — a multi-beam mask writer (MBMW) consumes shots and bytes, so 0.5 nm typically halves vertex count without measurable wafer-image change.

Source code in src/openlithohub/workflow/contour/curvilinear.py
def export_oasis_mbw(
    curves: list[BSplineCurve],
    output_path: str,
    *,
    samples_per_curve: int = 64,
    pixel_size_nm: float = 1.0,
    layer: int = 1,
    datatype: int = 0,
    cell_name: str = "TOP",
    min_area_nm2: float = 0.0,
    vertex_tolerance_nm: float = 0.0,
) -> None:
    """Serialize B-spline curves to an OASIS file via klayout.db.

    Curves are sampled to high-resolution polygons (``samples_per_curve``
    vertices per loop) and inserted into a single top cell on the requested
    ``(layer, datatype)``. The output is a real SEMI P39 OASIS file readable
    by KLayout, Calibre, and other industry tools — not a custom binary
    blob.

    Native SEMI P44 multi-beam curve primitives are not yet emitted; the
    polygon approximation is the standard interim representation that all
    multi-beam writer flows accept.

    ``min_area_nm2`` is an opt-in filter for sub-resolution islands: any
    sampled polygon with absolute area below this threshold is dropped
    before insertion. Default ``0.0`` keeps every shape so academic /
    Hackathon evaluation stays bit-exact. A positive value is intended for
    fab-ready exports where MRC would otherwise reject the smallest SRAFs
    a curvilinear ILT can produce; the count of dropped shapes is logged
    at INFO level so the filter is auditable.

    ``vertex_tolerance_nm`` is an opt-in Ramer-Douglas-Peucker simplification
    on each sampled polygon: any vertex within this perpendicular distance
    of the chord between its surviving neighbours is dropped. Default ``0.0``
    keeps every sampled vertex (bit-exact academic behaviour). Positive
    values cut OASIS file size dramatically on smooth ILT contours — a
    multi-beam mask writer (MBMW) consumes shots and bytes, so 0.5 nm
    typically halves vertex count without measurable wafer-image change.
    """
    if not curves:
        raise ValueError("Cannot export an empty curve list to OASIS.")
    if min_area_nm2 < 0.0:
        raise ValueError(f"min_area_nm2 must be >= 0, got {min_area_nm2}")
    if vertex_tolerance_nm < 0.0:
        raise ValueError(f"vertex_tolerance_nm must be >= 0, got {vertex_tolerance_nm}")

    try:
        from scipy.interpolate import splev
    except ImportError:
        raise ImportError(
            "scipy is required for OASIS export. Install with: pip install openlithohub[workflow]"
        ) from None

    try:
        import klayout.db as db
    except ImportError:
        raise ImportError(
            "klayout is required for OASIS export. Install with: pip install openlithohub[workflow]"
        ) from None

    output = Path(output_path)
    output.parent.mkdir(parents=True, exist_ok=True)

    layout = db.Layout()
    layout.dbu = pixel_size_nm / 1000.0
    top = layout.create_cell(cell_name)
    layer_idx = layout.layer(layer, datatype)

    # KLayout DB units: layout.dbu is in microns. A DB integer coord i represents
    # i * dbu microns = i * dbu * 1000 nm. xs/ys here are already in nm
    # (fit_bspline scaled control points by pixel_size_nm), so divide by
    # (dbu * 1000) — equivalently by pixel_size_nm.
    nm_per_dbu = layout.dbu * 1000.0

    n_filtered = 0
    n_vertices_before = 0
    n_vertices_after = 0
    for curve in curves:
        ctrl = curve.control_points.numpy()
        knot = curve.knots.numpy()
        tck = (knot, [ctrl[:, 0], ctrl[:, 1]], curve.degree)
        u_eval = np.linspace(0.0, 1.0, samples_per_curve, endpoint=False)
        xs, ys = splev(u_eval, tck)
        xs_arr = np.asarray(xs, dtype=np.float64)
        ys_arr = np.asarray(ys, dtype=np.float64)
        if min_area_nm2 > 0.0 and len(xs_arr) >= 3:
            # Shoelace on the sampled polygon (xs/ys are in nm because
            # ``fit_bspline`` scales control points by pixel_size_nm).
            area_nm2 = 0.5 * abs(
                float(np.dot(xs_arr, np.roll(ys_arr, -1)) - np.dot(ys_arr, np.roll(xs_arr, -1)))
            )
            if area_nm2 < min_area_nm2:
                n_filtered += 1
                continue
        if vertex_tolerance_nm > 0.0 and len(xs_arr) >= 4:
            n_vertices_before += len(xs_arr)
            keep_mask = _rdp_simplify(xs_arr, ys_arr, vertex_tolerance_nm)
            xs_arr = xs_arr[keep_mask]
            ys_arr = ys_arr[keep_mask]
            n_vertices_after += len(xs_arr)
        points = [
            db.Point(int(round(float(x) / nm_per_dbu)), int(round(float(y) / nm_per_dbu)))
            for x, y in zip(xs_arr, ys_arr, strict=False)
        ]
        if len(points) >= 3:
            top.shapes(layer_idx).insert(db.Polygon(points))

    if n_filtered > 0:
        logger.info(
            "export_oasis_mbw: filtered %d shape(s) below min_area_nm2=%g nm^2",
            n_filtered,
            min_area_nm2,
        )
    if vertex_tolerance_nm > 0.0 and n_vertices_before > 0:
        logger.info(
            "export_oasis_mbw: RDP simplified %d%d vertices (%.1f%% reduction) "
            "at tolerance_nm=%g",
            n_vertices_before,
            n_vertices_after,
            100.0 * (1.0 - n_vertices_after / n_vertices_before),
            vertex_tolerance_nm,
        )

    layout.write(str(output))

openlithohub.workflow.export

OASIS/GDSII export coordination.

export_oasis(mask, output_path, *, mode='curvilinear', pixel_size_nm=1.0, min_area_nm2=0.0)

Export an optimized mask tensor to OASIS format.

For manhattan mode, extracts rectilinear contours and writes via KLayout. For curvilinear mode, fits B-splines and writes a curvilinear OASIS file (sampled polygons on a designated layer; see contour.curvilinear). Native SEMI P39 (OASIS.MASK) curve primitives and SEMI P44 multi-beam mask-writer input are tracked separately and not yet emitted here.

min_area_nm2 (curvilinear only) drops sub-resolution islands below the given polygon area before writing. Default 0.0 keeps every shape so academic / Hackathon evaluation stays bit-exact.

Source code in src/openlithohub/workflow/export.py
def export_oasis(
    mask: torch.Tensor,
    output_path: str | Path,
    *,
    mode: str = "curvilinear",
    pixel_size_nm: float = 1.0,
    min_area_nm2: float = 0.0,
) -> None:
    """Export an optimized mask tensor to OASIS format.

    For manhattan mode, extracts rectilinear contours and writes via KLayout.
    For curvilinear mode, fits B-splines and writes a curvilinear OASIS file
    (sampled polygons on a designated layer; see ``contour.curvilinear``).
    Native SEMI P39 (OASIS.MASK) curve primitives and SEMI P44 multi-beam
    mask-writer input are tracked separately and not yet emitted here.

    ``min_area_nm2`` (curvilinear only) drops sub-resolution islands below
    the given polygon area before writing. Default ``0.0`` keeps every
    shape so academic / Hackathon evaluation stays bit-exact.
    """
    if mode not in ("manhattan", "curvilinear"):
        raise ValueError(f"mode must be 'manhattan' or 'curvilinear', got '{mode}'")

    m = ensure_2d(mask)
    output_path = Path(output_path)
    output_path.parent.mkdir(parents=True, exist_ok=True)

    if mode == "manhattan":
        _export_manhattan(m, output_path, pixel_size_nm)
    else:
        _export_curvilinear(m, output_path, pixel_size_nm, min_area_nm2=min_area_nm2)

export_gds(mask, output_path, *, mode='curvilinear', pixel_size_nm=1.0, samples_per_curve=64, min_area_nm2=0.0)

Export an optimized mask tensor to GDSII format.

GDSII is the academic / contest lingua franca (ICCAD, SPIE benchmarks and the cuLitho whitepaper all use .gds); OASIS is dominant in mask-shop flows. This function covers the academic path so users do not have to convert .oas.gds themselves before running our benchmark on contest-style inputs.

Same routing as :func:export_oasis: mode="manhattan" extracts rectilinear polygons; mode="curvilinear" fits B-splines, samples them to polygons (GDSII has no native curve primitive) and writes a polygon-only .gds via KLayout. The polygon density is controlled by samples_per_curve and matches the OASIS curvilinear writer's default — a curvilinear OASIS and a curvilinear GDS exported from the same mask are visually identical, but the GDS file is larger because every curve becomes an explicit vertex list.

Source code in src/openlithohub/workflow/export.py
def export_gds(
    mask: torch.Tensor,
    output_path: str | Path,
    *,
    mode: str = "curvilinear",
    pixel_size_nm: float = 1.0,
    samples_per_curve: int = 64,
    min_area_nm2: float = 0.0,
) -> None:
    """Export an optimized mask tensor to GDSII format.

    GDSII is the academic / contest lingua franca (ICCAD, SPIE benchmarks
    and the cuLitho whitepaper all use ``.gds``); OASIS is dominant in
    mask-shop flows. This function covers the academic path so users do
    not have to convert ``.oas`` → ``.gds`` themselves before running our
    benchmark on contest-style inputs.

    Same routing as :func:`export_oasis`: ``mode="manhattan"`` extracts
    rectilinear polygons; ``mode="curvilinear"`` fits B-splines, samples
    them to polygons (GDSII has no native curve primitive) and writes
    a polygon-only ``.gds`` via KLayout. The polygon density is controlled
    by ``samples_per_curve`` and matches the OASIS curvilinear writer's
    default — a curvilinear OASIS and a curvilinear GDS exported from the
    same mask are visually identical, but the GDS file is larger because
    every curve becomes an explicit vertex list.
    """
    if mode not in ("manhattan", "curvilinear"):
        raise ValueError(f"mode must be 'manhattan' or 'curvilinear', got '{mode}'")

    m = ensure_2d(mask)
    output_path = Path(output_path)
    output_path.parent.mkdir(parents=True, exist_ok=True)

    if mode == "manhattan":
        _export_manhattan(m, output_path, pixel_size_nm)
    else:
        _export_curvilinear(
            m,
            output_path,
            pixel_size_nm,
            samples_per_curve=samples_per_curve,
            min_area_nm2=min_area_nm2,
        )

openlithohub.workflow.process_node

DTCO process node configuration for lithography simulation parameters.

ProcessNodeConfig dataclass

Physical parameters for a specific semiconductor process node.

These parameters configure the forward lithography model, compliance checks, and optimization targets based on the target manufacturing technology.

Source code in src/openlithohub/workflow/process_node.py
@dataclass(frozen=True)
class ProcessNodeConfig:
    """Physical parameters for a specific semiconductor process node.

    These parameters configure the forward lithography model, compliance checks,
    and optimization targets based on the target manufacturing technology.
    """

    name: str
    wavelength_nm: float
    numerical_aperture: float
    sigma_inner: float
    sigma_outer: float
    pixel_size_nm: float
    min_feature_nm: float
    min_spacing_nm: float
    resist_threshold: float = 0.5
    defocus_budget_nm: float = 20.0
    optical_radius_nm: float = 1500.0
    """Optical interaction radius — how far light at a tile boundary
    'sees' through the imaging kernel. Used by ``workflow.halo`` to size
    tile halos so the forward model is fed real layout context, not
    zero-padded boundaries. Conservative default of 1.5 µm matches DUV
    rule-of-thumb (~10 × λ / (2 × NA)); EUV nodes can run much tighter.
    """
    demag_scan: float = 4.0
    demag_slit: float = 4.0
    """Reticle-to-wafer demagnification ratios along the scan and slit axes.

    Standard low-NA EUV (NA=0.33) and DUV scanners are isotropic 4×, so
    both default to 4.0. High-NA EUV (NA=0.55, ASML EXE:5000 class) is
    *anamorphic*: 8× along the scan axis and 4× along the slit axis,
    halving the field on-wafer to ~26 × 16.5 mm. The Hopkins forward
    currently assumes an isotropic mask grid and ignores these flags;
    they are recorded here so downstream tooling (anamorphic imaging,
    half-field stitching, mask-side pixel sizing) can branch on
    ``demag_scan != demag_slit``. See the High-NA tracking issue.
    """
    multi_patterning: str = "none"
    """Multi-patterning scheme assumed by ``min_feature_nm`` / ``min_spacing_nm``.

    The Rayleigh limit ``k1 × λ / NA`` caps the *half-pitch* a single
    exposure can resolve at k1 ≈ 0.25 (production manufacturable). When
    ``min_feature_nm`` falls below that limit, the layout assumes
    multiple exposures (LELE / LELELE / SADP / SAQP) — a single-shot
    forward simulation is then a coarse approximation, valid for
    *one* of the multi-patterning sub-layers but not the composite
    image.

    Values: ``"none"``, ``"lele"`` (litho-etch-litho-etch, 2-colour
    decomposition), ``"lelele"`` (3-colour), ``"sadp"`` (self-aligned
    double patterning), ``"saqp"`` (self-aligned quadruple). The flag
    is informational — the forward model does not yet decompose layouts
    by colour, so callers running below the single-exposure k1=0.25
    floor should split layouts manually before scoring.

    Worked examples:
        - 28nm at 193 nm DUV / NA 1.35: single-exposure k1 = 14 × 1.35 /
          193 ≈ 0.098, well below 0.25. Production 28nm is LELE
          immersion. Marked ``"lele"``.
        - 7nm at EUV NA 0.33: k1 = 14 × 0.33 / 13.5 ≈ 0.34. Single
          exposure, marked ``"none"``.
    """

    @property
    def is_anamorphic(self) -> bool:
        """Whether the projection optics are anamorphic (High-NA EUV)."""
        return self.demag_scan != self.demag_slit

    @property
    def sigma_px(self) -> float:
        """Compute Gaussian PSF sigma in pixels from optical parameters."""
        resolution_nm = 0.5 * self.wavelength_nm / self.numerical_aperture
        return resolution_nm / self.pixel_size_nm

    @property
    def k1_factor(self) -> float:
        """Rayleigh k1 factor for minimum half-pitch."""
        half_pitch_nm = self.min_feature_nm / 2.0
        return half_pitch_nm * self.numerical_aperture / self.wavelength_nm

optical_radius_nm = 1500.0 class-attribute instance-attribute

Optical interaction radius — how far light at a tile boundary 'sees' through the imaging kernel. Used by workflow.halo to size tile halos so the forward model is fed real layout context, not zero-padded boundaries. Conservative default of 1.5 µm matches DUV rule-of-thumb (~10 × λ / (2 × NA)); EUV nodes can run much tighter.

demag_slit = 4.0 class-attribute instance-attribute

Reticle-to-wafer demagnification ratios along the scan and slit axes.

Standard low-NA EUV (NA=0.33) and DUV scanners are isotropic 4×, so both default to 4.0. High-NA EUV (NA=0.55, ASML EXE:5000 class) is anamorphic: 8× along the scan axis and 4× along the slit axis, halving the field on-wafer to ~26 × 16.5 mm. The Hopkins forward currently assumes an isotropic mask grid and ignores these flags; they are recorded here so downstream tooling (anamorphic imaging, half-field stitching, mask-side pixel sizing) can branch on demag_scan != demag_slit. See the High-NA tracking issue.

multi_patterning = 'none' class-attribute instance-attribute

Multi-patterning scheme assumed by min_feature_nm / min_spacing_nm.

The Rayleigh limit k1 × λ / NA caps the half-pitch a single exposure can resolve at k1 ≈ 0.25 (production manufacturable). When min_feature_nm falls below that limit, the layout assumes multiple exposures (LELE / LELELE / SADP / SAQP) — a single-shot forward simulation is then a coarse approximation, valid for one of the multi-patterning sub-layers but not the composite image.

Values: "none", "lele" (litho-etch-litho-etch, 2-colour decomposition), "lelele" (3-colour), "sadp" (self-aligned double patterning), "saqp" (self-aligned quadruple). The flag is informational — the forward model does not yet decompose layouts by colour, so callers running below the single-exposure k1=0.25 floor should split layouts manually before scoring.

Worked examples
  • 28nm at 193 nm DUV / NA 1.35: single-exposure k1 = 14 × 1.35 / 193 ≈ 0.098, well below 0.25. Production 28nm is LELE immersion. Marked "lele".
  • 7nm at EUV NA 0.33: k1 = 14 × 0.33 / 13.5 ≈ 0.34. Single exposure, marked "none".

is_anamorphic property

Whether the projection optics are anamorphic (High-NA EUV).

sigma_px property

Compute Gaussian PSF sigma in pixels from optical parameters.

k1_factor property

Rayleigh k1 factor for minimum half-pitch.

get_node(name)

Get a process node configuration by name.

Raises:

Type Description
KeyError

If the node name is not found in presets.

Source code in src/openlithohub/workflow/process_node.py
def get_node(name: str) -> ProcessNodeConfig:
    """Get a process node configuration by name.

    Raises:
        KeyError: If the node name is not found in presets.
    """
    if name not in PROCESS_NODES:
        available = ", ".join(sorted(PROCESS_NODES.keys()))
        raise KeyError(f"Unknown process node '{name}'. Available: {available}")
    return PROCESS_NODES[name]

list_nodes()

Return sorted list of available process node names.

Source code in src/openlithohub/workflow/process_node.py
def list_nodes() -> list[str]:
    """Return sorted list of available process node names."""
    return sorted(PROCESS_NODES.keys())

openlithohub.workflow.process_window

Process-window-aware OPC ("PW-OPC") workflow.

Production OPC must hold up across a dose/focus process window, not just at nominal exposure. Optimising against the nominal corner alone tends to produce masks that look great in the lab and fail in the fab.

This module supplies the corner-sweep machinery: define a small set of dose + defocus corners, run the existing forward model at each, and aggregate the fidelity loss as a weighted mean. It is a drop-in replacement for the nominal F.mse_loss(resist, target) line that today lives inside every ILT inner loop — see models.levelset_ilt.LevelSetILTModel for the canonical integration site.

The corner enumeration mirrors the four-corner scheme used by benchmark.metrics.pvband.compute_pvband so that what we optimise and what we measure live in the same world.

Physical caveats (issue #27)

The corner sweep here is a fast inner-loop diagnostic, not rigorous PW simulation:

  • Defocus is modelled by widening the Gaussian PSF only. A real defocused pupil loses contrast (the MTF dips and re-rings — the textbook Bossung curves) — the energy is redistributed into the pupil's nulls, not merely smeared by a wider real-space kernel. A Gaussian preserves total energy, so this proxy under-estimates defocus-induced contrast loss; PW corners that are dim in reality look only blurry here. For headline PW numbers, drive the Hopkins path with measured-source / Zernike-pupil I/O (see optics.py) rather than this proxy.
  • Dose enters as a multiplicative scale on the aerial image. That is correct if the resist threshold is dose-pinned (the intensity the resist clears at scales linearly with dose). The HopkinsSimulator does this internally (issue #52), but this fast path uses a fixed threshold passed by the caller — so a ±5% dose corner cleanly shifts the resist contour rather than being silently cancelled. Pair it with threshold=0.225 (LithoBench-canonical) for headline alignment with the Hopkins benchmark numbers.

If you require physically-rigorous defocus, use a Hopkins SOCS forward sim with a defocus Zernike (Z4) and treat this module as the training-loop proxy.

ProcessWindowCorner dataclass

One dose/focus corner in the optimisation sweep.

sigma_px is the Gaussian-PSF width in pixels — defocus translates into sigma upstream of this dataclass (callers convert nm → px), keeping this structure agnostic to physical units.

Source code in src/openlithohub/workflow/process_window.py
@dataclass(frozen=True)
class ProcessWindowCorner:
    """One dose/focus corner in the optimisation sweep.

    ``sigma_px`` is the Gaussian-PSF width in pixels — defocus translates into
    sigma upstream of this dataclass (callers convert nm → px), keeping this
    structure agnostic to physical units.
    """

    dose: float
    sigma_px: float
    weight: float = 1.0

pw_aerial_images(mask, corners=DEFAULT_PW_CORNERS)

Simulate the aerial image at every corner. :stable:

Returned tensors share rank with mask ((H,W) in, (H,W) out). Autograd-connected — gradients flow back to mask.

Source code in src/openlithohub/workflow/process_window.py
def pw_aerial_images(
    mask: torch.Tensor,
    corners: Sequence[ProcessWindowCorner] = DEFAULT_PW_CORNERS,
) -> list[torch.Tensor]:
    """Simulate the aerial image at every corner.  :stable:

    Returned tensors share rank with ``mask`` (``(H,W)`` in, ``(H,W)`` out).
    Autograd-connected — gradients flow back to ``mask``.
    """
    return [simulate_aerial_image(mask, sigma_px=c.sigma_px, dose=c.dose) for c in corners]

pw_fidelity_loss(mask, target, *, corners=DEFAULT_PW_CORNERS, threshold=0.5, steepness=50.0, resist_diffusion_nm=0.0, pixel_size_nm=1.0, quencher=0.0)

Weighted-mean MSE between simulated resist and target across PW corners. :stable:

Each corner contributes weight * MSE(resist_corner, target); the result is divided by the sum of weights so the scalar magnitude stays comparable to the nominal-only baseline.

With corners=(ProcessWindowCorner(dose=1.0, sigma_px=σ, weight=1.0),) this reduces to the existing nominal-only loss, which is how the call-site in LevelSetILTModel keeps backward compatibility.

.. note:: Threshold default is 0.5 (legacy API — changing it would silently shift every training trajectory built on this module). Pass threshold=0.225 to align with the LithoBench / Yang2023 resist-clearing convention used by compute_l2_error / compute_wafer_epe.

Source code in src/openlithohub/workflow/process_window.py
def pw_fidelity_loss(
    mask: torch.Tensor,
    target: torch.Tensor,
    *,
    corners: Sequence[ProcessWindowCorner] = DEFAULT_PW_CORNERS,
    threshold: float = 0.5,
    steepness: float = 50.0,
    resist_diffusion_nm: float = 0.0,
    pixel_size_nm: float = 1.0,
    quencher: float = 0.0,
) -> torch.Tensor:
    """Weighted-mean MSE between simulated resist and target across PW corners.  :stable:

    Each corner contributes ``weight * MSE(resist_corner, target)``; the result
    is divided by the sum of weights so the scalar magnitude stays comparable
    to the nominal-only baseline.

    With ``corners=(ProcessWindowCorner(dose=1.0, sigma_px=σ, weight=1.0),)``
    this reduces to the existing nominal-only loss, which is how the call-site
    in ``LevelSetILTModel`` keeps backward compatibility.

    .. note::
       Threshold default is 0.5 (legacy API — changing it would silently
       shift every training trajectory built on this module). Pass
       ``threshold=0.225`` to align with the LithoBench / Yang2023
       resist-clearing convention used by ``compute_l2_error`` /
       ``compute_wafer_epe``.
    """
    if len(corners) == 0:
        raise ValueError("pw_fidelity_loss requires at least one corner")

    total = mask.new_zeros(())
    weight_sum = 0.0
    for corner in corners:
        aerial = simulate_aerial_image(mask, sigma_px=corner.sigma_px, dose=corner.dose)
        resist = apply_differentiable_resist(
            aerial,
            threshold=threshold,
            steepness=steepness,
            resist_diffusion_nm=resist_diffusion_nm,
            pixel_size_nm=pixel_size_nm,
            quencher=quencher,
        )
        total = total + corner.weight * functional.mse_loss(resist, target)
        weight_sum += corner.weight

    if weight_sum <= 0.0:
        raise ValueError("pw_fidelity_loss corner weights must sum to a positive value")

    return total / weight_sum

openlithohub.workflow.eda_bridge

Bridge scripts for commercial EDA tools (Calibre, IC Validator).

OpenLithoHub does not implement full DRC — these helpers emit minimal, human-editable rule decks that load an exported .oas and run the most basic checks (min width, min spacing). The intent is to remove the friction of "I have OASIS, now what?" for layout engineers, not to replace a real sign-off deck.

The emitted files are pure text. No EDA tool is invoked here.

Unit handling (issue #50)

The Calibre template uses PRECISION 1000 (1000 dbu/µm = 1 dbu/nm) and threshold literals are emitted in microns (min_width_nm / 1000). SVRF interprets bare numerics on INTERNAL ... < N / EXTERNAL ... < N directives as user units (microns by default), so emitting < 40 for a 40-nm rule would have meant "< 40 µm" — i.e. the rule check would be 1000× too lax and would never flag a real violation. The IC Validator template has the same convention.

Run any deck-emission test that pins literal text against this nm-vs-µm shift if you change the format strings.

Geometric scope (issue #51)

The Calibre INTERNAL mask < N ABUT < 90 SINGULAR REGION and the ICV internal1(mask, < N) directives are measured edge-to-edge, which is well-defined for Manhattan polygons but can produce direction-dependent results on curvilinear / concave geometry (the classic case: a notch on the inside of an arc, where the closest "internal" pair of edges is the arc-tangent rather than the notch wall). For Manhattan OPC output (the default of workflow.contour.manhattan) this template is correct. For curvilinear ILT output (workflow.contour.curvilinear), replace INTERNAL ... ABUT < 90 with INTERNAL ... PROJECTING < 90 (Calibre) or pair internal1 with a with_radius curvature filter (ICV) so arc-segment curvature is not mis-classified as a width violation. We ship the Manhattan-correct deck because it's the headline OPC path; the curvilinear deck is a roadmap item.

BridgeRules dataclass

Minimal DRC rules used by the emitted templates.

Source code in src/openlithohub/workflow/eda_bridge.py
@dataclass(frozen=True)
class BridgeRules:
    """Minimal DRC rules used by the emitted templates."""

    min_width_nm: float
    min_spacing_nm: float
    layer: int = 1
    datatype: int = 0

emit_calibre_svrf(oasis_path, rules, *, cell_name='TOP', output_path=None)

Emit a minimal Calibre nmDRC .svrf rule deck next to the OASIS file.

Source code in src/openlithohub/workflow/eda_bridge.py
def emit_calibre_svrf(
    oasis_path: str | Path,
    rules: BridgeRules,
    *,
    cell_name: str = "TOP",
    output_path: str | Path | None = None,
) -> Path:
    """Emit a minimal Calibre nmDRC ``.svrf`` rule deck next to the OASIS file."""
    _validate_cell_name(cell_name)
    oasis_path = Path(oasis_path)
    _validate_oasis_path(oasis_path)
    out = Path(output_path) if output_path else oasis_path.with_suffix(".svrf")
    out.parent.mkdir(parents=True, exist_ok=True)
    out.write_text(
        _CALIBRE_TEMPLATE.format(
            oasis_path=str(oasis_path),
            cell_name=cell_name,
            layer=rules.layer,
            datatype=rules.datatype,
            min_width_nm=rules.min_width_nm,
            min_spacing_nm=rules.min_spacing_nm,
            min_width_um=rules.min_width_nm / 1000.0,
            min_spacing_um=rules.min_spacing_nm / 1000.0,
        ),
        encoding="utf-8",
    )
    return out

emit_icv_runset(oasis_path, rules, *, cell_name='TOP', output_path=None)

Emit a minimal Synopsys IC Validator runset next to the OASIS file.

Source code in src/openlithohub/workflow/eda_bridge.py
def emit_icv_runset(
    oasis_path: str | Path,
    rules: BridgeRules,
    *,
    cell_name: str = "TOP",
    output_path: str | Path | None = None,
) -> Path:
    """Emit a minimal Synopsys IC Validator runset next to the OASIS file."""
    _validate_cell_name(cell_name)
    oasis_path = Path(oasis_path)
    _validate_oasis_path(oasis_path)
    out = Path(output_path) if output_path else oasis_path.with_suffix(".rs")
    out.parent.mkdir(parents=True, exist_ok=True)
    out.write_text(
        _ICV_TEMPLATE.format(
            oasis_path=str(oasis_path),
            cell_name=cell_name,
            layer=rules.layer,
            datatype=rules.datatype,
            min_width_um=rules.min_width_nm / 1000.0,
            min_spacing_um=rules.min_spacing_nm / 1000.0,
        ),
        encoding="utf-8",
    )
    return out

emit_bridge_bundle(oasis_path, rules, *, cell_name='TOP')

Emit Calibre + IC Validator templates and a short README for the bundle.

Source code in src/openlithohub/workflow/eda_bridge.py
def emit_bridge_bundle(
    oasis_path: str | Path,
    rules: BridgeRules,
    *,
    cell_name: str = "TOP",
) -> dict[str, Path]:
    """Emit Calibre + IC Validator templates and a short README for the bundle."""
    _validate_cell_name(cell_name)
    oasis_path = Path(oasis_path)
    _validate_oasis_path(oasis_path)
    svrf = emit_calibre_svrf(oasis_path, rules, cell_name=cell_name)
    runset = emit_icv_runset(oasis_path, rules, cell_name=cell_name)
    readme = oasis_path.with_suffix(".bridge.md")
    readme.write_text(
        _RUN_README.format(
            tool="Calibre / IC Validator",
            oasis_path=str(oasis_path),
            cell_name=cell_name,
            svrf_path=str(svrf),
            runset_path=str(runset),
        ),
        encoding="utf-8",
    )
    return {"svrf": svrf, "icv": runset, "readme": readme}