From c282198099bbba46894ec67a144f8708a7a68495 Mon Sep 17 00:00:00 2001 From: David Lurz Date: Mon, 21 Sep 2026 22:49:33 +0200 Subject: [PATCH 1/7] DRC checker stage --- examples/main.py | 1 + src/orca/__init__.py | 2 + src/orca/geometry/drc.py | 310 ++++++++++++++++++++++ src/orca/gui/utils.py | 3 +- src/orca/pipeline/context.py | 17 ++ src/orca/pipeline/drc_stage.py | 149 +++++++++++ src/orca/pipeline/export_onnx_stage.py | 12 + src/orca/pipeline/gds_conversion_stage.py | 14 +- src/orca/pipeline/gds_gen_stage.py | 26 +- src/orca/pipeline/simulation_stage.py | 2 +- 10 files changed, 530 insertions(+), 6 deletions(-) create mode 100644 src/orca/geometry/drc.py create mode 100644 src/orca/pipeline/drc_stage.py diff --git a/examples/main.py b/examples/main.py index 0fa1b32..77182b0 100644 --- a/examples/main.py +++ b/examples/main.py @@ -28,6 +28,7 @@ def main(): orca_instance = orca.ORCA( [ orca.GDSGenerator(num_samples=6000, seed=40), + orca.DRCChecker(), orca.GDSConverter(), #orca.PalaceSimulator(palace_executable="apptainer exec ~/Documents/git/palace/palace.sif palace"), #orca.ModelTrainer(model=orca.OrcaMLP, hyperparameters=hyperparameters, n_train_samples=1000), diff --git a/src/orca/__init__.py b/src/orca/__init__.py index 343a1f8..899508c 100644 --- a/src/orca/__init__.py +++ b/src/orca/__init__.py @@ -4,6 +4,7 @@ from .geometry.input_parameters import InputParameterIterator from .orca import ORCA from .pipeline.context import PipelineContext +from .pipeline.drc_stage import DRCChecker from .pipeline.gds_conversion_stage import GDSConverter from .pipeline.gds_gen_stage import GDSGenerator from .pipeline.pipeline_stage import PipelineStage @@ -15,6 +16,7 @@ __all__ = [ "ORCA", "BaseGeometry", + "DRCChecker", "FlatReImCodec", "FrequencyMode", "GDSConverter", diff --git a/src/orca/geometry/drc.py b/src/orca/geometry/drc.py new file mode 100644 index 0000000..9a1f92f --- /dev/null +++ b/src/orca/geometry/drc.py @@ -0,0 +1,310 @@ +""" +Design-rule checks for the IHP SG13G2 back end of line, run on GDS files with KLayout. + +The rules cover what a parametric passive can get wrong: vertices off the +manufacturing grid, edge angles other than 0/45/90 degrees, acute corners, +metal width and spacing, and via size, spacing and metal enclosure. The +values are those of the PDK's KLayout rule deck (``sg13g2_tech_default.json``) +and the rule names follow its report (``TM2.a``, ``TV2.c``, ...), so a finding +here can be looked up in the IHP design rule manual directly. + +Two entry points: :func:`snap_to_grid` moves every vertex of a layout onto +the grid, and :func:`check_layout` counts the violations per rule. Only the +*drawing* datatype of each layer is checked, as in the PDK deck; pin, text and +the gds2palace port layers are left alone. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Final + +import klayout.db as kdb + +from orca.geometry.layers import SG13G2 + +if TYPE_CHECKING: + from orca.geometry.layers import Layer + +#: SG13G2 manufacturing grid in nanometres (rule G.a; the PDK deck checks 5 nm). +GRID_NM: Final = 5 + +#: Corners with an interior angle below this are not allowed (rule 3.2, "Acute"). +ACUTE_LIMIT_DEG: Final = 87.0 + + +@dataclass(frozen=True) +class MetalRules: + """Width and spacing rules of one metal layer, in micrometres.""" + + name: str + """Rule prefix used in the PDK deck, e.g. ``"TM2"`` for TopMetal2.""" + layer: Layer + min_width: float + """Rule ``.a``: minimum width.""" + min_space: float + """Rule ``.b``: minimum space or notch.""" + + +@dataclass(frozen=True) +class ViaRules: + """Size, spacing and enclosure rules of one via layer, in micrometres.""" + + name: str + """Rule prefix used in the PDK deck, e.g. ``"TV2"`` for TopVia2.""" + layer: Layer + size: float + """Rule ``.a``: vias are squares of exactly this size.""" + min_space: float + """Rule ``.b``: minimum space between vias.""" + enclosures: tuple[tuple[str, Layer, float], ...] = () + """``(rule suffix, metal layer, minimum enclosure)`` for the metals above and below.""" + + +#: Metal rules of the SG13G2 stack: Metal1 (M1), Metal2-5 (Mn), TopMetal1/2 (TM1/TM2). +SG13G2_METAL_RULES: Final[tuple[MetalRules, ...]] = ( + MetalRules("M1", SG13G2.Metal1, min_width=0.16, min_space=0.18), + MetalRules("M2", SG13G2.Metal2, min_width=0.20, min_space=0.21), + MetalRules("M3", SG13G2.Metal3, min_width=0.20, min_space=0.21), + MetalRules("M4", SG13G2.Metal4, min_width=0.20, min_space=0.21), + MetalRules("M5", SG13G2.Metal5, min_width=0.20, min_space=0.21), + MetalRules("TM1", SG13G2.TopMetal1, min_width=1.64, min_space=1.64), + MetalRules("TM2", SG13G2.TopMetal2, min_width=2.00, min_space=2.00), +) + +#: Via rules of the SG13G2 stack: Via1 (V1), Via2-4 (Vn), TopVia1/2 (TV1/TV2). +SG13G2_VIA_RULES: Final[tuple[ViaRules, ...]] = ( + ViaRules("V1", SG13G2.Via1, size=0.19, min_space=0.22, enclosures=(("c", SG13G2.Metal1, 0.01),)), + ViaRules("V2", SG13G2.Via2, size=0.19, min_space=0.22, enclosures=(("c", SG13G2.Metal2, 0.005),)), + ViaRules("V3", SG13G2.Via3, size=0.19, min_space=0.22, enclosures=(("c", SG13G2.Metal3, 0.005),)), + ViaRules("V4", SG13G2.Via4, size=0.19, min_space=0.22, enclosures=(("c", SG13G2.Metal4, 0.005),)), + ViaRules( + "TV1", + SG13G2.TopVia1, + size=0.42, + min_space=0.42, + enclosures=(("c", SG13G2.Metal5, 0.10), ("d", SG13G2.TopMetal1, 0.42)), + ), + ViaRules( + "TV2", + SG13G2.TopVia2, + size=0.90, + min_space=1.06, + enclosures=(("c", SG13G2.TopMetal1, 0.50), ("d", SG13G2.TopMetal2, 0.50)), + ), +) + +#: SG13G2 layer names by ``(layer, datatype)``, for the grid/angle rule names. +_LAYER_NAMES: Final[dict[Layer, str]] = { + value: name + for name, value in vars(SG13G2).items() + if isinstance(value, tuple) and len(value) == 2 and not name.startswith("_") +} + + +@dataclass +class DRCResult: + """Outcome of checking one layout.""" + + snapped_vertices: int = 0 + """Vertices moved onto the grid before checking (0 when snapping was off).""" + violations: dict[str, int] = field(default_factory=dict) + """Violation count per rule name; rules without findings are absent.""" + + @property + def clean(self) -> bool: + return not self.violations + + @property + def total(self) -> int: + return sum(self.violations.values()) + + +def _snap(value: int, grid: int) -> int: + # Python's round-half-to-even keeps a layout mirrored about the origin + # symmetric after snapping; floor(x + 0.5) would not. + return round(value / grid) * grid + + +def snap_to_grid(layout: kdb.Layout, grid_nm: int = GRID_NM) -> int: + """Snap every vertex of every cell in *layout* onto the manufacturing grid, in place. + + Paths and polygons from ``gf.Path.extrude()`` or ``gdspy.FlexPath`` have + perpendicular offsets on angled segments that land off the grid; this fixes + them without flattening, so cell references and their offsets stay as they are. + Paths with a width are turned into polygons first (their outline is what the + mask sees); zero-width paths, which gds2palace reads as port sheets, keep + their path form. Text labels are untouched. + + Args: + layout: Layout to modify; its database unit must divide the grid. + grid_nm: Grid pitch in nanometres. + + Returns: + int: Number of vertices that were moved. + """ + grid = _grid_dbu(layout, grid_nm) + moved = 0 + + def snap_point(p: kdb.Point) -> kdb.Point: + nonlocal moved + q = kdb.Point(_snap(p.x, grid), _snap(p.y, grid)) + if q != p: + moved += 1 + return q + + for cell in layout.each_cell(): + for layer_index in layout.layer_indexes(): + for shape in cell.shapes(layer_index).each(): + if shape.is_text(): + continue + if shape.is_box(): + box = shape.box + shape.box = kdb.Box(snap_point(box.p1), snap_point(box.p2)) + elif shape.is_path() and shape.path.width == 0: + path = shape.path + shape.path = kdb.Path([snap_point(p) for p in path.each_point()], 0) + else: + # polygons, simple polygons and paths with a width + polygon = shape.polygon + snapped = kdb.Polygon([snap_point(p) for p in polygon.each_point_hull()]) + for hole in range(polygon.holes()): + snapped.insert_hole([snap_point(p) for p in polygon.each_point_hole(hole)]) + shape.polygon = snapped + return moved + + +def check_layout( + layout: kdb.Layout, + grid_nm: int = GRID_NM, + metal_rules: tuple[MetalRules, ...] = SG13G2_METAL_RULES, + via_rules: tuple[ViaRules, ...] = SG13G2_VIA_RULES, +) -> dict[str, int]: + """Count the design-rule violations of *layout*, per rule. + + Every layer in the rule tables is flattened into one region (merged, as the + PDK deck does) and checked for: + + - ``.offgrid``: vertices off the ``grid_nm`` grid; + - ``.angle``: edges that are not 0/45/90 degrees on metals, or not + 0/90 degrees on vias; + - ``.acute``: corners sharper than :data:`ACUTE_LIMIT_DEG`; + - ``.a``/``.b``: minimum width and space of metals, exact size and + minimum space of vias; + - ``.c``/``.d``: metal enclosure of vias. A via not covered by the + metal at all counts here too, which the edge-based PDK check would miss. + + Args: + layout: Layout to check. It is not modified. + grid_nm: Manufacturing grid in nanometres. + metal_rules: Metal layers to check; defaults to the SG13G2 stack. + via_rules: Via layers to check; defaults to the SG13G2 stack. + + Returns: + dict[str, int]: Violation counts keyed by rule name; only rules with + at least one finding are present, so an empty dict means clean. + """ + grid = _grid_dbu(layout, grid_nm) + dbu = layout.dbu + to_dbu = lambda um: round(um / dbu) # noqa: E731 - one-line unit helper + regions: dict[Layer, kdb.Region] = {} + + def region(layer: Layer) -> kdb.Region: + if layer not in regions: + index = layout.find_layer(*layer) + merged = kdb.Region() + if index is not None: + for top in layout.top_cells(): + merged += kdb.Region(top.begin_shapes_rec(index)) + regions[layer] = merged.merged() + return regions[layer] + + violations: dict[str, int] = {} + + def record(rule: str, count: int) -> None: + if count: + violations[rule] = violations.get(rule, 0) + count + + def geometry_checks(layer: Layer, diagonal: bool) -> None: + polygons = region(layer) + if polygons.is_empty(): + return + name = _LAYER_NAMES.get(layer, f"{layer[0]}/{layer[1]}") + record(f"{name}.offgrid", polygons.grid_check(grid, grid).count()) + edges = polygons.edges().with_abs_angle(0, True).with_abs_angle(90, True) + if diagonal: + edges = edges.with_abs_angle(45, True) + record(f"{name}.angle", edges.count()) + # KLayout reports the turning angle at a corner; an interior angle below + # the limit is a turn sharper than 180 - limit. 'absolute' folds concave + # corners in, so sharp notches are caught as well. + acute = polygons.corners(180.0 - ACUTE_LIMIT_DEG, 180.0, 2, False, True, False, True) + record(f"{name}.acute", acute.count()) + + for metal in metal_rules: + polygons = region(metal.layer) + if polygons.is_empty(): + continue + geometry_checks(metal.layer, diagonal=True) + record(f"{metal.name}.a", _check_count(polygons.width_check, to_dbu(metal.min_width))) + record(f"{metal.name}.b", _check_count(polygons.space_check, to_dbu(metal.min_space))) + + for via in via_rules: + vias = region(via.layer) + if vias.is_empty(): + continue + geometry_checks(via.layer, diagonal=False) + size = to_dbu(via.size) + wrong_min = vias.with_bbox_min(size, size + 1, True) + wrong_max = vias.with_bbox_min(size, size + 1, False).with_bbox_max(size, size + 1, True) + record(f"{via.name}.a", wrong_min.count() + wrong_max.count()) + record(f"{via.name}.b", _check_count(vias.space_check, to_dbu(via.min_space))) + for suffix, metal_layer, enclosure in via.enclosures: + metal = region(metal_layer) + uncovered = (vias - metal).count() + too_close = vias.enclosed_check( + metal, to_dbu(enclosure), False, kdb.Region.Euclidian + ).count() + record(f"{via.name}.{suffix}", uncovered + too_close) + + return violations + + +def check_gds_file(path: str, grid_nm: int = GRID_NM, snap: bool = True) -> DRCResult: + """Check one GDS file, optionally snapping it to the grid first. + + Args: + path: GDS file to check. + grid_nm: Manufacturing grid in nanometres. + snap: Snap all vertices to the grid and write the file back before + checking. Off-grid vertices are then repaired rather than reported. + + Returns: + DRCResult: Vertices moved and the violations found. + """ + layout = kdb.Layout() + layout.read(path) + moved = 0 + if snap: + moved = snap_to_grid(layout, grid_nm) + if moved: + layout.write(path) + return DRCResult(snapped_vertices=moved, violations=check_layout(layout, grid_nm)) + + +def _grid_dbu(layout: kdb.Layout, grid_nm: int) -> int: + """The grid in database units, checking that the layout can represent it.""" + if grid_nm <= 0: + raise ValueError(f"grid_nm must be positive, got {grid_nm}.") + grid = grid_nm * 1e-3 / layout.dbu + if abs(grid - round(grid)) > 1e-6 or round(grid) < 1: + raise ValueError( + f"A {grid_nm} nm grid is not a multiple of the layout's database unit " + f"({layout.dbu * 1e3:g} nm)." + ) + return round(grid) + + +def _check_count(check, distance: int) -> int: + """Number of edge pairs a KLayout width/space check reports for *distance* (Euclidean).""" + return check(distance, False, kdb.Region.Euclidian).count() diff --git a/src/orca/gui/utils.py b/src/orca/gui/utils.py index af8b6f0..3245e69 100644 --- a/src/orca/gui/utils.py +++ b/src/orca/gui/utils.py @@ -7,6 +7,7 @@ from orca.geometry.base_geometry import BaseGeometry from orca.geometry.presets.inductor_octa import InductorOcta from orca.logger import logger +from orca.pipeline.drc_stage import DRCChecker from orca.pipeline.gds_conversion_stage import GDSConverter # Import all pipeline stages @@ -66,7 +67,7 @@ def get_available_stages() -> list[type[PipelineStage]]: """ Returns a list of available PipelineStage subclasses. """ - return [GDSGenerator, GDSConverter, PalaceSimulator, *_TRAINING_STAGES] + return [GDSGenerator, DRCChecker, GDSConverter, PalaceSimulator, *_TRAINING_STAGES] def get_preset_geometries() -> list[type[BaseGeometry]]: """ diff --git a/src/orca/pipeline/context.py b/src/orca/pipeline/context.py index 02db491..f2181ab 100644 --- a/src/orca/pipeline/context.py +++ b/src/orca/pipeline/context.py @@ -82,6 +82,12 @@ class PipelineContext: gds_csv: str | None = None """Parameter table for the generated GDS files. ``None`` until the stage runs.""" + # --- Written by DRCChecker ---------------------------------------------- + drc_csv: str | None = None + """Parameter table of the layouts that passed DRC; GDSConverter reads it instead of the GDS table.""" + drc_summary: dict[str, int] = field(default_factory=dict) + """Violation counts per design rule over all checked layouts; empty when all were clean.""" + # --- Written by GDSConverter -------------------------------------------- palace_csv: str | None = None """Parameter table for the generated Palace models.""" @@ -121,6 +127,16 @@ def gds_csv_path(self) -> str: """Where the GDS generation stage writes its parameter table.""" return os.path.join(self.geometry_dir, f"{self.geometry.name}.csv") + @property + def drc_csv_path(self) -> str: + """Where the DRC stage writes the parameter table of the layouts that passed.""" + return os.path.join(self.geometry_dir, f"{self.geometry.name}_drc.csv") + + @property + def drc_report_path(self) -> str: + """Where the DRC stage writes the per-layout violation counts.""" + return os.path.join(self.geometry_dir, f"{self.geometry.name}_drc_report.csv") + @property def palace_sim_dir(self) -> str: return os.path.join(self.base_dir, "palace_sims") @@ -167,6 +183,7 @@ def to_json_dict(self) -> dict[str, Any]: record["geometry"] = self.geometry.name record["paths"] = { "geometry_dir": self.geometry_dir, + "drc_report": self.drc_report_path, "palace_sim_dir": self.palace_sim_dir, "result_dir": self.result_dir, "result_csv": self.result_csv, diff --git a/src/orca/pipeline/drc_stage.py b/src/orca/pipeline/drc_stage.py new file mode 100644 index 0000000..5483102 --- /dev/null +++ b/src/orca/pipeline/drc_stage.py @@ -0,0 +1,149 @@ +import os +from collections import Counter +from collections.abc import Callable +from concurrent.futures import ProcessPoolExecutor, as_completed +from typing import TYPE_CHECKING + +import pandas as pd +import tqdm + +from orca.geometry.drc import GRID_NM, DRCResult, check_gds_file +from orca.logger import logger +from orca.pipeline.pipeline_stage import PipelineStage + +if TYPE_CHECKING: + from orca.pipeline.context import PipelineContext + + +class DRCChecker(PipelineStage): + """ + Pipeline stage that snaps the generated GDS files to the manufacturing grid + and checks them against the IHP SG13G2 design rules. + + Layout code produces off-grid vertices (path extrusion on angled segments) + and, for some parameter combinations, geometry that no fab would accept: + traces narrower than the rules allow, vias clipped by a miter, folded + polygons. Simulating those wastes compute and teaches the model shapes that + cannot be built. This stage repairs what can be repaired in place (the grid) + and records the rest, so the conversion stage only picks up clean layouts. + + Outputs, in the geometry folder: ``_drc_report.csv`` with the + violation counts of every layout, and ``_drc.csv``, the parameter + table of the layouts that passed, in the same layout as the GDS table. + The rules are those of :mod:`orca.geometry.drc`. + """ + + def __init__( + self, grid_nm: int = GRID_NM, snap_to_grid: bool = True, drop_violations: bool = True + ): + """ + Args: + grid_nm (int): Manufacturing grid in nanometres. SG13G2 uses 5 nm. + snap_to_grid (bool): Move every vertex onto the grid and write the GDS file + back before checking, so off-grid vertices are repaired instead of reported. + drop_violations (bool): Leave layouts with violations out of the parameter + table the later stages use. Off, every layout goes on and the violations + are only reported. + """ + super().__init__(name="DRC Checker", index=1) + if grid_nm <= 0: + raise ValueError(f"grid_nm must be positive, got {grid_nm}.") + self.grid_nm = grid_nm + self.snap_to_grid = snap_to_grid + self.drop_violations = drop_violations + + def run( + self, + context: "PipelineContext", + progress_callback: Callable[[str, int, int, str], None] | None = None, + ) -> "PipelineContext": + gds_csv = context.gds_csv_path + if not os.path.exists(gds_csv): + logger.error( + f"No GDS parameter table found at {gds_csv}. The GDS Generator stage was " + "skipped or produced no layouts; nothing to check." + ) + return context + + gds_data = pd.read_csv(gds_csv) + gds_dir = os.path.dirname(gds_csv) + logger.info( + f"Starting DRC of {len(gds_data)} GDS files on a {self.grid_nm} nm grid " + f"using {context.num_processes} CPU cores." + ) + + results: dict[str, DRCResult] = {} + with ProcessPoolExecutor(max_workers=context.num_processes) as executor: + futures = { + executor.submit( + check_gds_file, os.path.join(gds_dir, name), self.grid_nm, self.snap_to_grid + ): name + for name in gds_data["name"] + } + for i, future in enumerate( + tqdm.tqdm(as_completed(futures), total=len(futures), desc="DRC Check") + ): + name = futures[future] + try: + results[name] = future.result() + except Exception as e: # noqa: BLE001 - one unreadable file must not abort the batch + logger.error(f"DRC of {name} failed with error: {e}") + results[name] = DRCResult(violations={"error": 1}) + finally: + if progress_callback: + progress_callback( + self.name, + i + 1, + len(futures), + f"Checked {i + 1} of {len(futures)} GDS files.", + ) + + report = pd.DataFrame( + [ + { + "name": name, + "snapped_vertices": results[name].snapped_vertices, + "violations": results[name].total, + "rules": ";".join( + f"{rule}:{count}" for rule, count in sorted(results[name].violations.items()) + ), + } + for name in gds_data["name"] + ] + ) + report.to_csv(context.drc_report_path, index=False) + + clean = gds_data["name"].map(lambda name: results[name].clean) + passed = gds_data if not self.drop_violations else gds_data[clean] + passed.to_csv(context.drc_csv_path, index=False) + + summary: Counter[str] = Counter() + for result in results.values(): + summary.update(result.violations) + self._log_summary( + len(gds_data), + int(clean.sum()), + summary, + int(report["snapped_vertices"].sum()), + context.drc_report_path, + ) + + context.drc_csv = context.drc_csv_path + context.drc_summary = dict(sorted(summary.items())) + return context + + def _log_summary( + self, total: int, n_clean: int, summary: Counter[str], snapped: int, report_path: str + ) -> None: + if self.snap_to_grid: + logger.info(f"Snapped {snapped} off-grid vertices to the {self.grid_nm} nm grid.") + if n_clean == total: + logger.info(f"DRC completed: all {total} layouts are clean.") + return + rules = ", ".join(f"{rule} x{count}" for rule, count in summary.most_common()) + verb = "dropped" if self.drop_violations else "kept, drop_violations is off" + logger.warning( + f"DRC completed: {n_clean} of {total} layouts are clean; " + f"{total - n_clean} with violations {verb}. Findings: {rules}. " + f"Per-layout counts are in {report_path}." + ) diff --git a/src/orca/pipeline/export_onnx_stage.py b/src/orca/pipeline/export_onnx_stage.py index bc3d16c..c876822 100644 --- a/src/orca/pipeline/export_onnx_stage.py +++ b/src/orca/pipeline/export_onnx_stage.py @@ -6,6 +6,7 @@ import onnx import torch +from orca.geometry.constraints import validate_constraint from orca.logger import logger from orca.pipeline.pipeline_stage import PipelineStage from orca.training.onnx_wrapper import ONNXWrapper @@ -83,6 +84,17 @@ def run( meta.key = "input_parameter_ranges" meta.value = json.dumps(ranges) + # The ranges are a box; the geometry may only be buildable in part of it, + # and the model was trained on that part alone. Record the constraints so + # a consumer can tell an unbuildable query from a bad prediction. + constraints = geometry.feasibility_constraints() + if constraints: + for expression in constraints: + validate_constraint(expression, list(ranges)) + meta = onnx_model.metadata_props.add() + meta.key = "input_constraints" + meta.value = json.dumps(constraints) + # Record which physical properties are guaranteed by construction, so consumers # (e.g. COBRA) know whether the predicted S-matrix is passive/reciprocal by # construction. The architecture and the output codec both contribute: an diff --git a/src/orca/pipeline/gds_conversion_stage.py b/src/orca/pipeline/gds_conversion_stage.py index 0fdd86c..c6b7e61 100644 --- a/src/orca/pipeline/gds_conversion_stage.py +++ b/src/orca/pipeline/gds_conversion_stage.py @@ -33,7 +33,7 @@ def __init__(self, timeout: float = 60.0): timeout (float): Maximum seconds a single GDS conversion may take before its worker is killed and the sample is skipped. """ - super().__init__(name="GDS Converter", index=1) + super().__init__(name="GDS Converter", index=2) self.timeout = timeout def run( @@ -44,7 +44,19 @@ def run( geometry: BaseGeometry = context.geometry cpu_cores: int = context.num_processes base_dir: str = context.base_dir + # The DRC stage leaves the table of the layouts that passed next to the GDS + # table; the GDS Generator wipes that folder, so a present table belongs to + # the current layouts even when the DRC stage ran in an earlier session. gds_csv = context.gds_csv_path + if os.path.exists(context.drc_csv_path): + gds_csv = context.drc_csv_path + logger.info(f"Converting the layouts that passed DRC, listed in {gds_csv}.") + elif not os.path.exists(gds_csv): + logger.error( + f"No GDS parameter table found at {gds_csv}. The GDS Generator stage was " + "skipped or produced no layouts; nothing to convert." + ) + return context output_dir = context.palace_sim_dir palace_csv = context.palace_csv_path diff --git a/src/orca/pipeline/gds_gen_stage.py b/src/orca/pipeline/gds_gen_stage.py index b5b054b..877660d 100644 --- a/src/orca/pipeline/gds_gen_stage.py +++ b/src/orca/pipeline/gds_gen_stage.py @@ -57,10 +57,14 @@ def run( os.makedirs(output_dir) - # Tell the input parameter iterator the number of samples to generate - geometry.input_parameter_iterator.set_sample_count(self.num_samples, seed=self.seed) + # Tell the input parameter iterator the number of samples to generate; draws + # the geometry cannot build are rejected there and, for the random + # strategy, redrawn, so the requested number of layouts is met. + iterator = geometry.input_parameter_iterator + iterator.set_sample_count(self.num_samples, seed=self.seed, feasible=geometry.is_feasible) futures = [] + failed = 0 with ProcessPoolExecutor(max_workers=cpu_cores) as executor: # Create cpu_cores processes to generate GDS files in parallel for i, input_params in enumerate(geometry.input_iterator): @@ -93,6 +97,7 @@ def run( # import); nothing else will finish, so abort instead of skipping raise except Exception as e: # noqa: BLE001 - one bad sample must not abort the batch + failed += 1 logger.debug(f"Worker task failed: {e}") finally: if progress_callback: @@ -103,7 +108,22 @@ def run( f"GDS Generation Progress: {i + 1}/{len(futures)}", ) - logger.info("GDS generation completed.") + drawn = len(futures) - failed + if iterator.n_rejected: + logger.info( + f"{iterator.n_rejected} infeasible parameter combinations were rejected by " + f"{type(geometry).__name__}.is_feasible and redrawn." + ) + if failed or drawn < self.num_samples: + # A geometry that raises for a draw its is_feasible accepted, or a grid + # strategy with infeasible points, yields fewer layouts than requested; + # that should not go unnoticed. + logger.warning( + f"GDS generation completed: {drawn} of {self.num_samples} requested layouts " + f"drawn; {failed} draws failed in create_gds_file (see the debug log)." + ) + else: + logger.info(f"GDS generation completed: {drawn} layouts drawn.") context.gds_csv = gds_csv return context diff --git a/src/orca/pipeline/simulation_stage.py b/src/orca/pipeline/simulation_stage.py index 7619d23..d8a3a04 100644 --- a/src/orca/pipeline/simulation_stage.py +++ b/src/orca/pipeline/simulation_stage.py @@ -65,7 +65,7 @@ def __init__( ranks then share one core's execution units, caches and bandwidth), so this is off by default; enable it to measure the difference on your machine. """ - super().__init__(name="Palace EM Simulator", index=2) + super().__init__(name="Palace EM Simulator", index=3) self.palace_executable = palace_executable self.touchstone_type = touchstone_type if launcher not in ("local", "slurm"): From e36b897e0169c2b6374071b9b6f48c70ddddc51d Mon Sep 17 00:00:00 2001 From: David Lurz Date: Mon, 21 Sep 2026 22:50:11 +0200 Subject: [PATCH 2/7] isFeasible check to redraw failed geometry parameter combinations and tell COBRA about it later --- src/orca/geometry/base_geometry.py | 36 ++++ src/orca/geometry/cells/transformer.py | 166 +++++++++++++------ src/orca/geometry/input_parameters.py | 52 ++++-- src/orca/geometry/presets/inductor_octa.py | 41 ++++- src/orca/geometry/presets/tf_octa_c_ports.py | 69 +++++--- 5 files changed, 276 insertions(+), 88 deletions(-) diff --git a/src/orca/geometry/base_geometry.py b/src/orca/geometry/base_geometry.py index c62759d..7760c6c 100644 --- a/src/orca/geometry/base_geometry.py +++ b/src/orca/geometry/base_geometry.py @@ -48,6 +48,42 @@ def n_ports(self) -> int: return len(read_simconfig(self.simconfig_filename)["ports"]) + def is_feasible(self, params: dict[str, Any]) -> bool: # noqa: ARG002 - hook with a default + """ + Whether a parameter combination describes a layout that can be drawn. + + Called before :meth:`create_gds_file`, for every draw of the input + parameter iterator; infeasible draws are rejected and redrawn, so the + requested number of samples is met and only buildable layouts are + simulated. Override this for cheap, closed-form constraints between + parameters (a winding that must fit its diameter, a via array that must + fit its trace). Do not clamp or repair parameters instead: the parameter + table records the requested values, and a repaired layout would train + the model on a geometry it does not have. + + The default accepts everything. :meth:`create_gds_file` should still + raise ``ValueError`` for a combination it cannot draw; that is the + safety net, this is the filter. + """ + return True + + def feasibility_constraints(self) -> list[str]: + """ + :meth:`is_feasible` as expressions a consumer of the model can evaluate. + + Each string is a boolean expression over the input parameter names in + the grammar of :mod:`orca.geometry.constraints`, for example + ``"bottom_linewidth <= bottom_winding_diameter / 3"``. The ONNX + exporter writes them to the ``input_constraints`` metadata key, so + COBRA can refuse a query for a geometry that cannot be built rather + than return a prediction the model was never trained for. + + They must accept exactly the parameter sets :meth:`is_feasible` + accepts; derive both from the same numbers. The default declares no + constraints, which a consumer reads as "the whole parameter box". + """ + return [] + @staticmethod @abstractmethod def create_gds_file(name: str, output_path: str, params: dict[str, Any]) -> str: diff --git a/src/orca/geometry/cells/transformer.py b/src/orca/geometry/cells/transformer.py index f08d02c..21a4a0d 100644 --- a/src/orca/geometry/cells/transformer.py +++ b/src/orca/geometry/cells/transformer.py @@ -4,8 +4,6 @@ from orca.geometry.layers import SG13G2 -GRID_NM = 10 # 10 nm manufacturing grid = 0.01 µm - def _ensure_active_pdk() -> None: """Gdsfactory refuses to extrude paths without an active PDK. We only use it as a @@ -16,31 +14,101 @@ def _ensure_active_pdk() -> None: except ValueError: gf.gpdk.PDK.activate() -def _snap_inplace(c: gf.Component) -> None: - """Snap all polygon vertices to GRID_NM across the component hierarchy. +def check_tf_octa_c_parameters( + bottom_winding_diameter: float = 50.0, + top_winding_diameter: float = 50.0, + center_displacement: float = 15.0, + bottom_linewidth: float = 5.0, + bottom_center_tap_width: float = 0.0, + lower_feed_type: int = 1, + top_linewidth: float = 5.0, + upper_center_tap_width: float = 0.0, + upper_feed_type: int = 1, + feedline_spacing: float = 6.0, + gnd_upper_spacing: float = 10.0, + gnd_lower_spacing: float = 10.0, + gnd_side_spacing: float = 10.0, + gnd_ring_width: float = 10.0, +) -> None: + """Raise ``ValueError`` if :func:`tf_octa_c` cannot draw these parameters. - gf.Path.extrude() computes perpendicular offsets for angled segments - (e.g. the 22.5° octagon sides) that land off the 10 nm grid. - This iterates every cell in the component's call tree and snaps - each polygon vertex in-place — without flattening, so the cutout - geometry and all reference offsets stay untouched. + Same arguments as :func:`tf_octa_c`. Called by it, and by + ``TransformerOcta.is_feasible`` to reject a draw before anything is drawn. """ - layout = c.layout() - all_cell_idxs = set(c.called_cells()) - all_cell_idxs.add(c.cell_index()) - - for idx in all_cell_idxs: - cell = layout.cell(idx) - for layer_idx in layout.layer_indexes(): - for shape in cell.shapes(layer_idx).each(kdb.Shapes.SPolygons): - pts = [ - kdb.Point( - round(p.x / GRID_NM) * GRID_NM, - round(p.y / GRID_NM) * GRID_NM, - ) - for p in shape.polygon.each_point_hull() - ] - shape.polygon = kdb.Polygon(pts) + bottom_centertap_width = ( + bottom_center_tap_width if bottom_center_tap_width > 0.1 else bottom_linewidth + ) + top_centertap_width = ( + upper_center_tap_width if upper_center_tap_width > 0.1 else top_linewidth + ) + for label, feed_type in (("lower_feed_type", lower_feed_type), ("upper_feed_type", upper_feed_type)): + if feed_type not in (0, 1): + raise NotImplementedError( + f"{label}={feed_type}: only 0 (no center tap) and 1 (center tap) are implemented." + ) + draw_bottom_tap = lower_feed_type == 1 + draw_top_tap = upper_feed_type == 1 + # The center tap of one winding crosses the feed gap of the other, which widens that gap. + fs_top = max(feedline_spacing, bottom_centertap_width) if draw_bottom_tap else feedline_spacing + fs_bot = max(feedline_spacing, top_centertap_width) if draw_top_tap else feedline_spacing + + # Check if linewidth is too large for winding diameter + if bottom_linewidth > bottom_winding_diameter / 3.0: + raise ValueError("bottom_linewidth is too large for input_winding_diameter.") + if top_linewidth > top_winding_diameter / 3.0: + raise ValueError("upper_linewidth is too large for output_winding_diameter.") + # Check if center tap width is too large for winding diameter of the other winding + if draw_bottom_tap and bottom_centertap_width > top_winding_diameter / 3.0: + raise ValueError( + "bottom_center_tap_width is too large for output_winding_diameter." + ) + if draw_top_tap and top_centertap_width > bottom_winding_diameter / 3.0: + raise ValueError( + "upper_center_tap_width is too large for input_winding_diameter." + ) + if abs(bottom_winding_diameter - top_winding_diameter) > 40.0: + raise ValueError( + "input_winding_diameter and output_winding_diameter difference is too large. No sufficient coupling." + ) + + # The winding path starts at the feed gap and runs along the octagon's vertical + # side to the first vertex, where it bends by 45 degrees. That first segment must + # be at least as long as the miter reach of the bend (w/2 * tan 22.5 deg), or + # gf.Path.extrude() folds the trace into a zero-width spike; a gap wider than + # the side is not a transformer at all. + miter_reach = np.tan(np.radians(22.5)) / 2.0 + half_side = np.sin(np.radians(22.5)) / 2.0 + for label, diameter, width, gap in ( + ("top", top_winding_diameter, top_linewidth, fs_top), + ("bottom", bottom_winding_diameter, bottom_linewidth, fs_bot), + ): + if diameter * half_side - gap / 2.0 < width * miter_reach: + raise ValueError( + f"The {label} winding's feed gap of {gap:g} plus the miter of its {width:g} wide " + f"trace does not fit in the flat side of a {diameter:g} octagon." + ) + + # Ground ring: the ports on both sides and the ring bars must leave a positive opening. + tf_y = max(top_winding_diameter, bottom_winding_diameter) / 2.0 + gnd_side_spacing + port_xr = ( + max( + center_displacement / 2.0 + top_winding_diameter / 2.0, + -center_displacement / 2.0 + bottom_winding_diameter / 2.0, + ) + + gnd_upper_spacing + ) + port_xl = ( + min( + center_displacement / 2.0 - top_winding_diameter / 2.0, + -center_displacement / 2.0 - bottom_winding_diameter / 2.0, + ) + - gnd_lower_spacing + ) + if not (port_xr - gnd_ring_width > port_xl + gnd_ring_width and tf_y - gnd_ring_width > 0): + raise ValueError( + "Ground ring dimensions are invalid due to port spacing. Adjust parameters." + ) + def tf_octa_c( name: str = "tf_octa_c", @@ -83,6 +151,22 @@ def tf_octa_c( gnd_ring_width: Ring width. """ _ensure_active_pdk() + check_tf_octa_c_parameters( + bottom_winding_diameter=bottom_winding_diameter, + top_winding_diameter=top_winding_diameter, + center_displacement=center_displacement, + bottom_linewidth=bottom_linewidth, + bottom_center_tap_width=bottom_center_tap_width, + lower_feed_type=lower_feed_type, + top_linewidth=top_linewidth, + upper_center_tap_width=upper_center_tap_width, + upper_feed_type=upper_feed_type, + feedline_spacing=feedline_spacing, + gnd_upper_spacing=gnd_upper_spacing, + gnd_lower_spacing=gnd_lower_spacing, + gnd_side_spacing=gnd_side_spacing, + gnd_ring_width=gnd_ring_width, + ) LAYER_BOT = SG13G2.TopMetal1 LAYER_TOP = SG13G2.TopMetal2 @@ -98,11 +182,6 @@ def tf_octa_c( top_centertap_width = ( upper_center_tap_width if upper_center_tap_width > 0.1 else top_linewidth ) - for label, feed_type in (("lower_feed_type", lower_feed_type), ("upper_feed_type", upper_feed_type)): - if feed_type not in (0, 1): - raise NotImplementedError( - f"{label}={feed_type}: only 0 (no center tap) and 1 (center tap) are implemented." - ) draw_bottom_tap = lower_feed_type == 1 draw_top_tap = upper_feed_type == 1 @@ -128,25 +207,6 @@ def tf_octa_c( bot_left_x = (-center_displacement / 2.0) - (bottom_winding_diameter / 2.0) port_xl = min(top_left_x, bot_left_x) - gnd_lower_spacing - # Check if linewidth is too large for winding diameter - if bottom_linewidth > bottom_winding_diameter / 3.0: - raise ValueError("bottom_linewidth is too large for input_winding_diameter.") - if top_linewidth > top_winding_diameter / 3.0: - raise ValueError("upper_linewidth is too large for output_winding_diameter.") - # Check if center tap width is too large for winding diameter of the other winding - if draw_bottom_tap and bottom_centertap_width > top_winding_diameter / 3.0: - raise ValueError( - "bottom_center_tap_width is too large for output_winding_diameter." - ) - if draw_top_tap and top_centertap_width > bottom_winding_diameter / 3.0: - raise ValueError( - "upper_center_tap_width is too large for input_winding_diameter." - ) - if abs(bottom_winding_diameter - top_winding_diameter) > 40.0: - raise ValueError( - "input_winding_diameter and output_winding_diameter difference is too large. No sufficient coupling." - ) - # ------------------------------------------------- # 2. Helper: Winding Generator # ------------------------------------------------- @@ -432,10 +492,6 @@ def add_port_marker(center, width, layer, orientation): "Ground ring dimensions are invalid due to port spacing. Adjust parameters." ) - # Snap all polygon vertices to the 10 nm manufacturing grid. - # gf.Path.extrude() produces off-grid vertices for angled segments. - # _snap_inplace iterates the component's cell hierarchy without flattening, - # so the cutout geometry and reference offsets stay untouched. - _snap_inplace(c) - + # gf.Path.extrude() leaves off-grid vertices on the angled segments; the + # DRCChecker stage snaps them to the manufacturing grid (orca.geometry.drc). return c diff --git a/src/orca/geometry/input_parameters.py b/src/orca/geometry/input_parameters.py index f345ba6..67743aa 100644 --- a/src/orca/geometry/input_parameters.py +++ b/src/orca/geometry/input_parameters.py @@ -7,7 +7,11 @@ import numpy as np if TYPE_CHECKING: - from collections.abc import Iterator + from collections.abc import Callable, Iterator + +#: Draws the 'random' strategy may spend per requested sample before giving up +#: on a constraint that rejects almost everything. +MAX_DRAWS_PER_SAMPLE = 100 class InputParameterIterator: @@ -52,8 +56,15 @@ def __init__( # Created after set_sample_count is called self._iterator: Iterator[Any] | None = None + self.feasible: Callable[[dict[str, Any]], bool] | None = None + self.n_rejected = 0 - def set_sample_count(self, n_samples: int, seed: int | None = None): + def set_sample_count( + self, + n_samples: int, + seed: int | None = None, + feasible: Callable[[dict[str, Any]], bool] | None = None, + ): """ Sets the number of samples to generate. This is used for strategies that depend on the total number of samples, such as 'uniform_grid' and 'random'. @@ -61,8 +72,16 @@ def set_sample_count(self, n_samples: int, seed: int | None = None): Args: n_samples (int): Number of samples to generate. seed (int|None): Overrides the seed given to __init__ for the 'random' strategy. + feasible (Callable|None): Constraint between parameters, typically a geometry's + ``is_feasible``. Combinations it rejects are skipped and counted in + :attr:`n_rejected`. The 'random' strategy redraws until ``n_samples`` + feasible combinations were produced (up to :data:`MAX_DRAWS_PER_SAMPLE` + draws per sample); the grid strategies simply yield fewer points. """ self.n_samples = n_samples + self.feasible = feasible + self.n_rejected = 0 + self.n_accepted = 0 if seed is not None: self.seed = seed # Reinitialize the iterator based on the picking strategy @@ -89,14 +108,22 @@ def __next__(self) -> dict[str, Any]: raise RuntimeError( "set_sample_count() must be called before iterating over the input parameters." ) - self.n_geometries_created += 1 # May be used for logging or tracking - # Raises StopIteration when exhausted, which is propagated to this iterator - params = next(self._iterator) - # Convert numpy types to Python native types to avoid type issues with downstream libraries - params = [ - param.item() if isinstance(param, np.generic) else param for param in params - ] - return dict(zip(self.input_names, params, strict=True)) + while True: + if self.picking_strategy == "random" and self.n_accepted >= self.n_samples: + raise StopIteration + # Raises StopIteration when exhausted, which is propagated to this iterator + params = next(self._iterator) + # Convert numpy types to Python native types to avoid type issues with downstream libraries + params = [ + param.item() if isinstance(param, np.generic) else param for param in params + ] + sample = dict(zip(self.input_names, params, strict=True)) + if self.feasible is not None and not self.feasible(sample): + self.n_rejected += 1 + continue + self.n_accepted += 1 + self.n_geometries_created += 1 # May be used for logging or tracking + return sample def get_min_max_values(self) -> tuple[list[float], list[float]]: """ @@ -168,7 +195,10 @@ def random_sampling(self): and returns a dict of {"name": value} for each sample. """ rng = np.random.default_rng(self.seed) - for _ in range(self.n_samples): + # With a constraint, draw past n_samples so rejected draws can be replaced; + # __next__ stops once n_samples were accepted, so the stream stays the same. + n_draws = self.n_samples * (MAX_DRAWS_PER_SAMPLE if self.feasible is not None else 1) + for _ in range(n_draws): sampled_params = [] for name in self.input_names: values = self.input_values[name] diff --git a/src/orca/geometry/presets/inductor_octa.py b/src/orca/geometry/presets/inductor_octa.py index 4adcda8..6cbfcf4 100644 --- a/src/orca/geometry/presets/inductor_octa.py +++ b/src/orca/geometry/presets/inductor_octa.py @@ -3,7 +3,13 @@ from typing import TYPE_CHECKING, Any from orca import BaseGeometry -from orca.geometry.cells.inductor import get_min_outer_diameter, symmetric_octa_IHP +from orca.geometry.cells.inductor import ( + VIA_GAP, + VIA_MARGIN, + VIA_SIZE, + get_min_outer_diameter, + symmetric_octa_IHP, +) from orca.geometry.input_parameters import InputParameterIterator if TYPE_CHECKING: @@ -68,6 +74,28 @@ def create_dataset(self) -> "BaseDataset": output_normalizer=StandardNormalizer(), ) + def feasibility_constraints(self) -> list[str]: + # get_min_outer_diameter as one expression; kept in step with it by a test. + two_vias = 2 * VIA_SIZE + VIA_GAP + 2 * VIA_MARGIN + overlap = f"(width if width >= {two_vias:g} else 1.1 * {two_vias:g})" + crossover = ( + f"max(3 * width + 2 * space, " + f"(2 * space + width) * (sqrt2 - 1) + (space + width) + 2 * {overlap})" + ) + inner_segment = f"({crossover} + (0 if turns < 3 else width + 2 * space))" + inner_diameter = ( + f"({inner_segment} * (1 + sqrt2) if turns > 1 else 2 * (width + space) * (1 + sqrt2))" + ) + outer_diameter = f"{inner_diameter} + 2 * turns * width + 2 * (turns - 1) * space" + return [f"diameter >= ceil(100 * ({outer_diameter})) / 100"] + + def is_feasible(self, params: dict[str, Any]) -> bool: + # The windings, crossovers and feed vias must fit inside the outer diameter. + N = round(params["turns"]) + return float(params["diameter"]) >= get_min_outer_diameter( + N, float(params["width"]), float(params["space"]) + ) + @staticmethod def create_gds_file(name: str, output_path: str, params: dict[str, Any]) -> str: # noqa: ARG004 - the cell name is derived from the parameters N = round(params["turns"]) @@ -75,9 +103,16 @@ def create_gds_file(name: str, output_path: str, params: dict[str, Any]) -> str: s = float(params["space"]) D = float(params["diameter"]) - # clamp the outer diameter to the minimum buildable (DRC-valid) value + # Refuse, rather than clamp, a diameter below the buildable minimum: the + # parameter table records the requested value, so a clamped layout would + # train the model on a diameter the layout does not have. is_feasible + # rejects such draws before they get here. do_min = get_min_outer_diameter(N, w, s) - D = max(D, do_min) + if do_min > D: + raise ValueError( + f"diameter={D:g} is below the minimum {do_min:g} for turns={N}, " + f"width={w:g}, space={s:g}." + ) symmetric_octa_IHP( N=N, D=D, w=w, s=s, diff --git a/src/orca/geometry/presets/tf_octa_c_ports.py b/src/orca/geometry/presets/tf_octa_c_ports.py index 99e2e1c..0d0ae15 100644 --- a/src/orca/geometry/presets/tf_octa_c_ports.py +++ b/src/orca/geometry/presets/tf_octa_c_ports.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Any from orca import BaseGeometry -from orca.geometry.cells.transformer import tf_octa_c +from orca.geometry.cells.transformer import check_tf_octa_c_parameters, tf_octa_c from orca.geometry.input_parameters import InputParameterIterator if TYPE_CHECKING: @@ -65,24 +65,55 @@ def create_dataset(self) -> "BaseDataset": ) @staticmethod - def create_gds_file(name: str, output_path: str, params: dict[str, Any]) -> str: - c = tf_octa_c( - name=name, - bottom_winding_diameter=params["bottom_winding_diameter"], - top_winding_diameter=params["top_winding_diameter"], - center_displacement=params["center_displacement"], - bottom_linewidth=params["bottom_linewidth"], - top_linewidth=params["top_linewidth"], - bottom_center_tap_width=0, - upper_center_tap_width=0, - lower_feed_type=1, - upper_feed_type=1, - feedline_spacing=max(params["bottom_linewidth"], params["top_linewidth"]) - + 5, - gnd_upper_spacing=40, - gnd_lower_spacing=40, - gnd_side_spacing=40, - gnd_ring_width=20, + def _cell_arguments(params: dict[str, Any]) -> dict[str, Any]: + """The tf_octa_c arguments for a parameter draw; the rest is fixed for this preset.""" + return { + "bottom_winding_diameter": params["bottom_winding_diameter"], + "top_winding_diameter": params["top_winding_diameter"], + "center_displacement": params["center_displacement"], + "bottom_linewidth": params["bottom_linewidth"], + "top_linewidth": params["top_linewidth"], + "bottom_center_tap_width": 0, + "upper_center_tap_width": 0, + "lower_feed_type": 1, + "upper_feed_type": 1, + "feedline_spacing": max(params["bottom_linewidth"], params["top_linewidth"]) + 5, + "gnd_upper_spacing": 40, + "gnd_lower_spacing": 40, + "gnd_side_spacing": 40, + "gnd_ring_width": 20, + } + + def feasibility_constraints(self) -> list[str]: + # The rules of check_tf_octa_c_parameters for this preset's fixed + # arguments: center taps as wide as their winding, feed gap + # max(linewidths) + 5. The ground-ring rule always holds for the ranges + # above and is left out. Kept in step with is_feasible by a test. + gap = "(max(bottom_linewidth, top_linewidth) + 5)" + fold = ( + "{d} / 2 * sin(radians(22.5)) - {gap} / 2 >= {w} / 2 * tan(radians(22.5))" ) + return [ + "abs(bottom_winding_diameter - top_winding_diameter) <= 40", + "bottom_linewidth <= bottom_winding_diameter / 3", + "top_linewidth <= top_winding_diameter / 3", + # each center tap crosses the other winding's feed gap + "bottom_linewidth <= top_winding_diameter / 3", + "top_linewidth <= bottom_winding_diameter / 3", + # the feed gap plus the miter of the first bend must fit the octagon's side + fold.format(d="top_winding_diameter", w="top_linewidth", gap=gap), + fold.format(d="bottom_winding_diameter", w="bottom_linewidth", gap=gap), + ] + + def is_feasible(self, params: dict[str, Any]) -> bool: + try: + check_tf_octa_c_parameters(**self._cell_arguments(params)) + except ValueError: + return False + return True + + @staticmethod + def create_gds_file(name: str, output_path: str, params: dict[str, Any]) -> str: + c = tf_octa_c(name=name, **TransformerOcta._cell_arguments(params)) c.write_gds(output_path, with_metadata=False) return output_path From d0a2f9856f0f89ff050ab235b5b84d38badd2f76 Mon Sep 17 00:00:00 2001 From: David Lurz Date: Mon, 21 Sep 2026 22:51:21 +0200 Subject: [PATCH 3/7] Updated documentation with new features --- .claude/CLAUDE.md | 18 +++++++++++++----- README.md | 34 +++++++++++++++++++++++----------- docs/custom_class.md | 24 ++++++++++++++++++++++++ docs/index.md | 4 +++- docs/pipeline.md | 32 ++++++++++++++++++++------------ docs/running_orca.md | 14 ++++++++------ docs/setup.md | 2 +- 7 files changed, 92 insertions(+), 36 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 8a01cbf..387b124 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -5,17 +5,20 @@ models of RFIC passives. A run is a pipeline: generate GDS layouts with gdsfactory → convert them for Palace (`gds2palace`) → EM-simulate with Palace → train a PyTorch model on the S-parameters → export it to ONNX → test it. The ONNX models are consumed by COBRA (`../COBRA`), which reads the -`input_parameter_ranges` and `physics_guarantees` metadata ORCA writes. +`input_parameter_ranges`, `input_constraints` and `physics_guarantees` metadata +ORCA writes. ## Repository Structure - `src/orca/orca.py`: the `ORCA` runner; sorts stages by `index` and runs them over one `PipelineContext`. Output goes to `output//`. - `src/orca/pipeline/`: `PipelineStage` base, `PipelineContext`, and the stages - in fixed order: `GDSGenerator` (0), `GDSConverter` (1), `PalaceSimulator` (2), - `ModelTrainer` (4), `OnnxExporter` (5), `ModelTester` (6). + in fixed order: `GDSGenerator` (0), `DRCChecker` (1), `GDSConverter` (2), + `PalaceSimulator` (3), `ModelTrainer` (4), `OnnxExporter` (5), `ModelTester` (6). - `src/orca/geometry/`: `BaseGeometry` contract, `InputParameterIterator`, - layer stackups, reusable cells, and presets (`inductor_octa`, + layer stackups, the SG13G2 design rules (`drc.py`: grid snapping and KLayout + checks used by `DRCChecker`; geometry code does not snap itself), reusable + cells, and presets (`inductor_octa`, `tf_octa_c_ports`) with their `.simcfg`/`.xml` package data. - `src/orca/simulation/`: GDS→Palace conversion, Palace launchers (local, Apptainer, Slurm), and Touchstone result merging. @@ -38,7 +41,12 @@ pipeline orchestration, and GUI stay in their own packages. say which stage was skipped, not fail on a `KeyError`. - **Geometries:** a `BaseGeometry` subclass provides `name`, `stackup_xml`, `simconfig_filename`, `input_parameter_iterator`, `create_gds_file`, and - `create_dataset`. Presets are examples of the contract; a change to the + `create_dataset`; `is_feasible(params)` is optional and rejects draws before + they are drawn, and `feasibility_constraints()` states the same rules as + expressions (`geometry/constraints.py` grammar) for the ONNX metadata — a + test must keep the two in agreement. Never clamp or repair parameters inside `create_gds_file` — + the parameter table records the requested values, so the model would learn a + geometry that was not built. Presets are examples of the contract; a change to the contract updates the presets and `docs/custom_class.md`. - **Public API:** everything importable as `orca.X` is listed in `src/orca/__init__.py`. Anything that needs PyTorch goes in diff --git a/README.md b/README.md index 4a3a98d..ffd94c3 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,7 @@ geometry = TransformerOcta() orca_instance = ORCA( [ orca.GDSGenerator(num_samples=1000), + orca.DRCChecker(), orca.GDSConverter(), orca.PalaceSimulator(palace_executable="palace"), orca.ModelTrainer(), @@ -200,6 +201,7 @@ For large-scale simulation runs, we provide an OpenStack VM image and a CLI cont | Stage | Class | Description | |-------|-------|-------------| | GDS generation | `GDSGenerator` | Creates parameterized GDS layout files from a geometry class | +| Design-rule check | `DRCChecker` | Snaps layouts to the manufacturing grid and drops those violating the IHP SG13G2 rules | | GDS conversion | `GDSConverter` | Converts GDS files to Palace-compatible simulation meshes using gds2palace | | EM simulation | `PalaceSimulator` | Runs full-wave EM simulations in Palace and stores results as Touchstone files | | Model training | `ModelTrainer` | Trains a PyTorch MLP to map geometry parameters + frequency to S-parameters | @@ -217,10 +219,16 @@ ORCA runs a linear pipeline. Each stage receives a context dictionary and adds i │ ORCA pipeline │ │ │ │ ┌──────────────┐ GDS files ┌──────────────────┐ │ -│ │ GDSGenerator │───────────────▶│ GDSConverter │ │ -│ │ │ │ (gds2palace mesh) │ │ +│ │ GDSGenerator │───────────────▶│ DRCChecker │ │ +│ │ │ │ (grid + SG13G2) │ │ │ └──────────────┘ └────────┬─────────┘ │ -│ │ mesh files │ +│ │ clean layouts │ +│ ▼ │ +│ ┌──────────────────┐ │ +│ │ GDSConverter │ │ +│ │ (gds2palace mesh)│ │ +│ └────────┬─────────┘ │ +│ │ mesh files │ │ ▼ │ │ ┌──────────────────┐ │ │ │ PalaceSimulator │ │ @@ -247,13 +255,17 @@ ORCA runs a linear pipeline. Each stage receives a context dictionary and adds i ### Stage 1 — GDS generation (`GDSGenerator`) -The geometry class's `input_parameter_iterator` samples parameter combinations (randomly or on a grid). For each combination, `create_gds_file()` is called to produce a GDS layout file. The number of samples is set by `num_samples`; `seed` makes the `"random"` picking strategy reproducible. +The geometry class's `input_parameter_iterator` samples parameter combinations (randomly or on a grid). For each combination, `create_gds_file()` is called to produce a GDS layout file. The number of samples is set by `num_samples`; `seed` makes the `"random"` picking strategy reproducible. Every draw is first passed to the geometry's `is_feasible()`; combinations it rejects (a winding that does not fit its diameter, a feed gap wider than the octagon's side) are counted and, with the `"random"` strategy, redrawn, so `num_samples` buildable layouts come out. A geometry that still raises `ValueError` in `create_gds_file()` costs a sample, and the stage warns when it delivered fewer layouts than requested. + +### Stage 2 — Design-rule check (`DRCChecker`) + +Every generated layout is snapped to the manufacturing grid (5 nm for SG13G2) and checked against the IHP SG13G2 back-end design rules with KLayout: off-grid vertices, edge angles, acute corners, minimum metal width and spacing, and via size, spacing and enclosure. The rule names follow the PDK's KLayout deck (`TM2.a`, `TV2.d`, ...). Off-grid vertices are repaired in place; layouts with remaining violations are reported in `_drc_report.csv` and left out of the later stages, so parameter combinations that draw unbuildable geometry never reach the simulator or the model. -### Stage 2 — GDS conversion (`GDSConverter`) +### Stage 3 — GDS conversion (`GDSConverter`) -Each GDS file is converted to a Palace-ready simulation setup using [gds2palace](https://github.com/VolkerMuehlhaus/gds2palace_ihp_sg13g2). The geometry's `stackup_xml` defines the physical layer stackup and material properties; the `simconfig_filename` defines the simulation parameters (port positions, frequency sweep, mesh settings). +Each GDS file that passed DRC is converted to a Palace-ready simulation setup using [gds2palace](https://github.com/VolkerMuehlhaus/gds2palace_ihp_sg13g2). The geometry's `stackup_xml` defines the physical layer stackup and material properties; the `simconfig_filename` defines the simulation parameters (port positions, frequency sweep, mesh settings). -### Stage 3 — EM simulation (`PalaceSimulator`) +### Stage 4 — EM simulation (`PalaceSimulator`) Palace runs a full-wave finite-element EM simulation for each layout variant and writes the S-parameters to a Touchstone file (`.sNp`). Simulations are distributed across available CPU cores. The `palace_executable` argument can point to a local binary or a container invocation (e.g. `apptainer exec palace.sif palace`). @@ -264,15 +276,15 @@ Several simulations can run at once, each already parallelized internally with M Palace is memory-bandwidth bound, so several smaller simulations confined to their own NUMA domain usually give a higher throughput than one simulation spread over a whole node — as long as one simulation fits into a domain's memory (use `bind="socket"` otherwise). The layout is derived from the machine or allocation at runtime, so `num_parallel_sims` and `num_processes` are capped to what is actually available. Pass `save_log=True` to keep each simulation's full Palace output in `palace.log` in its simulation folder (off by default, Palace prints a lot); failures are reported either way. -### Stage 4 — Model training (`ModelTrainer`) +### Stage 5 — Model training (`ModelTrainer`) A PyTorch MLP is trained on the simulation data. Inputs are geometry parameters and frequency; outputs are the real and imaginary parts of each S-parameter entry. Normalization is defined in the geometry's dataset and applied automatically. An optional basis expansion of the inputs — for example a Chebyshev expansion of frequency — is chosen on the stage itself with `ModelTrainer(basis="chebyshev")`; it lives inside the model, so it is tuned with it and exported into the ONNX graph. Hyperparameters such as learning rate, batch size, and network depth can be passed to `ModelTrainer`. -### Stage 5 — ONNX export (`OnnxExporter`) +### Stage 6 — ONNX export (`OnnxExporter`) -The trained PyTorch model is exported to ONNX format with a fixed frequency sweep as part of the model signature. The resulting `.onnx` file is self-contained and can be run with `onnxruntime` — no PyTorch installation required at inference time. +The trained PyTorch model is exported to ONNX format with a fixed frequency sweep as part of the model signature. The resulting `.onnx` file is self-contained and can be run with `onnxruntime` — no PyTorch installation required at inference time. The metadata carries `input_parameter_ranges`, `input_constraints` (which part of those ranges is buildable — the part the model was trained on) and `physics_guarantees` for the consumer. -### Stage 6 — Model testing (`ModelTester`) +### Stage 7 — Model testing (`ModelTester`) The ONNX model is loaded and evaluated against held-out simulation data. Prediction errors are logged to help assess whether the surrogate is accurate enough for use in COBRA. diff --git a/docs/custom_class.md b/docs/custom_class.md index 8f84b8d..35cf265 100644 --- a/docs/custom_class.md +++ b/docs/custom_class.md @@ -53,6 +53,30 @@ The Python class should be a `@dataclass` extending `orca.BaseGeometry` and must - `create_gds_file(name, output_path, params) -> str` — Generates a GDS layout file from geometry parameters. Returns the path to the created file. - `create_dataset() -> BaseDataset` — Builds the dataset (e.g. `GeoToSParamDatasetSingleFrequency`) with its output codec and normalizers, used for training. It is called once per geometry instance, the first time `geometry.dataset` is read, so each instance gets its own dataset and normalizer statistics. +**Optional methods:** + +- `feasibility_constraints() -> list[str]` — The same rules as `is_feasible()`, written as boolean expressions over the input parameter names, e.g. `"bottom_linewidth <= bottom_winding_diameter / 3"`. `OnnxExporter` stores them in the model's `input_constraints` metadata, so COBRA can refuse a query for a geometry that cannot be built instead of returning a prediction the model was never trained for. The grammar is a small subset of Python (arithmetic, comparisons, `and`/`or`/`not`, `a if c else b`, and `abs min max sqrt sin cos tan radians ceil floor round`, plus `pi` and `sqrt2`), documented in `orca.geometry.constraints`; anything else is rejected at export. Derive the strings from the same numbers as `is_feasible()` and add a test that they agree on random draws (see `tests/test_constraints.py` for the presets' version). +- `is_feasible(params) -> bool` — Whether a parameter combination describes a layout that can be drawn (default: always `True`). `GDSGenerator` calls it for every draw of the iterator; rejected draws are counted and, with the `"random"` strategy, redrawn, so the requested number of samples is met with buildable layouts only. Put cheap, closed-form constraints between parameters here — a winding that must fit its diameter, a feed gap that must fit the octagon's side. The presets derive it from the same check their cell code runs, so the two cannot disagree. + +!!! warning "Reject, never clamp" + + Do not repair a bad parameter inside `create_gds_file()` (for example clamp a + too-small diameter to the buildable minimum). The parameter table records the + *requested* values, so a repaired layout trains the model on a geometry it does + not have, and the surrogate then returns confident results for inputs that + were never built. Reject the draw in `is_feasible()` and raise `ValueError` + in `create_gds_file()` as the safety net. + +!!! note "Grid snapping and design rules are not the geometry's job" + + Draw the layout and return; do not snap vertices to the manufacturing grid or + re-implement design-rule checks in `create_gds_file()`. The `DRCChecker` stage + snaps every generated GDS file to the SG13G2 grid and drops layouts that break + the PDK's metal and via rules (see [Pipeline Stages](pipeline.md)). It is the + safety net behind `is_feasible()`, not a substitute for it: a rule that can be + written down belongs in `is_feasible()`, where it costs nothing and keeps the + sample count honest. + !!! tip "Keep the training imports inside `create_dataset()`" The dataset classes and normalizers need PyTorch, which is an optional diff --git a/docs/index.md b/docs/index.md index 702a19c..2285f77 100644 --- a/docs/index.md +++ b/docs/index.md @@ -67,7 +67,8 @@ ORCA automates the full loop from geometry to a trained, exported surrogate mode ```mermaid flowchart LR A[Geometry Class] --> B[GDSGenerator] - B --> C[GDSConverter] + B --> X[DRCChecker] + X --> C[GDSConverter] C --> D[PalaceSimulator] D --> E[S-Parameter Dataset] E --> F[ModelTrainer] @@ -81,6 +82,7 @@ flowchart LR | Stage | Purpose | |---|---| | `GDSGenerator` | Samples geometry parameters and writes GDS layout files | +| `DRCChecker` | Snaps layouts to the manufacturing grid and drops those violating the SG13G2 design rules | | `GDSConverter` | Converts GDS files to Palace-compatible mesh inputs | | `PalaceSimulator` | Runs full-wave EM simulations and stores Touchstone results | | `ModelTrainer` | Trains a PyTorch neural network on the simulation dataset | diff --git a/docs/pipeline.md b/docs/pipeline.md index bf43508..f7a9660 100644 --- a/docs/pipeline.md +++ b/docs/pipeline.md @@ -1,9 +1,10 @@ --- title: Pipeline Stages description: >- - How ORCA works internally: the six pipeline stages from parametric GDS - generation and gds2palace mesh conversion through Palace full-wave EM - simulation, PyTorch model training, ONNX export and model testing. + How ORCA works internally: the seven pipeline stages from parametric GDS + generation, SG13G2 design-rule checking and gds2palace mesh conversion + through Palace full-wave EM simulation, PyTorch model training, ONNX export + and model testing. --- # Pipeline Stages @@ -12,7 +13,8 @@ ORCA runs a linear pipeline. Each stage receives a context dictionary and adds i ```mermaid flowchart TB - A[GDSGenerator] -- GDS files --> B["GDSConverter
(gds2palace mesh)"] + A[GDSGenerator] -- GDS files --> X["DRCChecker
(grid snap + SG13G2 rules)"] + X -- clean layouts --> B["GDSConverter
(gds2palace mesh)"] B -- mesh files --> C["PalaceSimulator
(full-wave EM)"] C -- "Touchstone .sNp" --> D["ModelTrainer
(PyTorch MLP)"] D -- trained model --> E[OnnxExporter] @@ -21,15 +23,21 @@ flowchart TB ## Stage 1 — GDS generation (`GDSGenerator`) -The geometry class's `input_parameter_iterator` samples parameter combinations (randomly or on a grid). For each combination, `create_gds_file()` is called to produce a GDS layout file. The number of samples is set by `num_samples`; `seed` makes the `"random"` picking strategy reproducible. +The geometry class's `input_parameter_iterator` samples parameter combinations (randomly or on a grid). For each combination, `create_gds_file()` is called to produce a GDS layout file. The number of samples is set by `num_samples`; `seed` makes the `"random"` picking strategy reproducible. Every draw is first passed to the geometry's `is_feasible()`; combinations it rejects (a winding that does not fit its diameter, a feed gap wider than the octagon's side) are counted and, with the `"random"` strategy, redrawn, so `num_samples` buildable layouts come out. A geometry that still raises `ValueError` in `create_gds_file()` costs a sample, and the stage warns when it delivered fewer layouts than requested. -## Stage 2 — GDS conversion (`GDSConverter`) +## Stage 2 — Design-rule check (`DRCChecker`) -Each GDS file is converted to a Palace-ready simulation setup using [gds2palace](https://github.com/VolkerMuehlhaus/gds2palace_ihp_sg13g2). The geometry's `stackup_xml` defines the physical layer stackup and material properties; the `simconfig_filename` defines the simulation parameters (port positions, frequency sweep, mesh settings). +Every generated layout is snapped to the manufacturing grid and checked against the IHP SG13G2 back-end design rules with KLayout: vertices off the grid, edge angles other than 0/45/90° (0/90° on vias), acute corners, minimum metal width and spacing (`M1`, `M2`–`M5`, `TM1`, `TM2`), and via size, spacing and metal enclosure (`V1`–`V4`, `TV1`, `TV2`). The rule values and names are those of the PDK's own KLayout deck, so a finding such as `TV2.d` can be looked up in the SG13G2 design rule manual; the rule table lives in `orca.geometry.drc`. + +Off-grid vertices are repaired rather than reported: `snap_to_grid=True` (the default) moves every vertex onto the `grid_nm` grid (5 nm for SG13G2) and writes the GDS file back, so geometry code does not need its own snapping. Layouts with remaining violations are left out of the parameter table the later stages use (`drop_violations=True`); the stage writes `_drc_report.csv` with the per-layout counts and `_drc.csv` with the layouts that passed, both next to the GDS files, and logs a summary. This is where parameter combinations that draw unbuildable geometry — a winding folded over itself, a via clipped by a miter — are stopped before they cost simulation time or teach the model shapes that cannot be fabricated. + +## Stage 3 — GDS conversion (`GDSConverter`) + +Each GDS file — those that passed DRC when the stage ran, otherwise all of them — is converted to a Palace-ready simulation setup using [gds2palace](https://github.com/VolkerMuehlhaus/gds2palace_ihp_sg13g2). The geometry's `stackup_xml` defines the physical layer stackup and material properties; the `simconfig_filename` defines the simulation parameters (port positions, frequency sweep, mesh settings). Conversions run in parallel worker processes, one sample per task. A sample whose geometry gds2palace/gmsh cannot mesh (e.g. `PLC Error: A segment and a facet intersect`) is logged and skipped. gmsh can also loop forever on degenerate geometry, so each conversion has a time limit — `GDSConverter(timeout=60)` seconds by default — after which its worker is killed and the sample is skipped as well, instead of stalling the whole pipeline. -## Stage 3 — EM simulation (`PalaceSimulator`) +## Stage 4 — EM simulation (`PalaceSimulator`) Palace runs a full-wave finite-element EM simulation for each layout variant and writes the S-parameters to a Touchstone file (`.sNp`). Simulations are distributed across available CPU cores. The `palace_executable` argument can point to a local binary or a container invocation (e.g. `apptainer exec palace.sif palace`). @@ -40,14 +48,14 @@ Several simulations can run at once, each already parallelized internally with M Palace is memory-bandwidth bound, so several smaller simulations confined to their own NUMA domain usually give a higher throughput than one simulation spread over a whole node — as long as one simulation fits into a domain's memory (use `bind="socket"` otherwise). The layout is derived from the machine or allocation at runtime, so `num_parallel_sims` and `num_processes` are capped to what is actually available. Pass `save_log=True` to keep each simulation's full Palace output in `palace.log` in its simulation folder (off by default, Palace prints a lot); failures are reported either way. -## Stage 4 — Model training (`ModelTrainer`) +## Stage 5 — Model training (`ModelTrainer`) A PyTorch MLP is trained on the simulation data. Inputs are geometry parameters and frequency; outputs are the real and imaginary parts of each S-parameter entry. Normalization is defined in the geometry's dataset and applied automatically. An optional basis expansion of the inputs — for example a Chebyshev expansion of frequency — is chosen on the stage itself with `ModelTrainer(basis="chebyshev")`; it lives inside the model, so it is tuned with it and exported into the ONNX graph. Hyperparameters such as learning rate, batch size, and network depth can be passed to `ModelTrainer`. -## Stage 5 — ONNX export (`OnnxExporter`) +## Stage 6 — ONNX export (`OnnxExporter`) -The trained PyTorch model is exported to ONNX format with a fixed frequency sweep as part of the model signature. The resulting `.onnx` file is self-contained and can be run with `onnxruntime` — no PyTorch installation required at inference time. +The trained PyTorch model is exported to ONNX format with a fixed frequency sweep as part of the model signature. The resulting `.onnx` file is self-contained and can be run with `onnxruntime` — no PyTorch installation required at inference time. Three metadata keys describe the model to its consumer: `input_parameter_ranges` (the box each input was sampled from), `input_constraints` (the geometry's `feasibility_constraints()`, expressions that single out the buildable part of that box — the only part the model has seen) and `physics_guarantees` (properties such as reciprocity the architecture enforces by construction). -## Stage 6 — Model testing (`ModelTester`) +## Stage 7 — Model testing (`ModelTester`) The ONNX model is loaded and evaluated against held-out simulation data. Prediction errors are logged to help assess whether the surrogate is accurate enough for use in [COBRA](https://github.com/DI-PASSIONATE/COBRA). diff --git a/docs/running_orca.md b/docs/running_orca.md index 8a1e2fd..a340204 100644 --- a/docs/running_orca.md +++ b/docs/running_orca.md @@ -50,6 +50,7 @@ geometry = TransformerOcta() orca_instance = ORCA( [ orca.GDSGenerator(num_samples=1000), + orca.DRCChecker(), orca.GDSConverter(), orca.PalaceSimulator( palace_executable="apptainer exec ~/palace/palace.sif palace", @@ -75,14 +76,15 @@ Wrap the call in `if __name__ == "__main__":` (or a `main()` function) as shown: This will: 1. Generate 1000 parameterised GDS layout variants. -2. Convert each to a Palace mesh and run full-wave EM simulation. -3. Store results in Touchstone format under `output//`. -4. Train a neural network on the simulation dataset. -5. Export the trained model to ONNX format. -6. Test prediction accuracy against held-out simulation data. +2. Snap them to the manufacturing grid and drop those violating the SG13G2 design rules. +3. Convert each to a Palace mesh and run full-wave EM simulation. +4. Store results in Touchstone format under `output//`. +5. Train a neural network on the simulation dataset. +6. Export the trained model to ONNX format. +7. Test prediction accuracy against held-out simulation data. !!! tip - You can run only a subset of pipeline stages by modifying the list passed to `ORCA(...)`. Stages are sorted by their internal index and some depend on outputs of earlier stages. Stage indices: `GDSGenerator=0`, `GDSConverter=1`, `PalaceSimulator=2`, `ModelTrainer=4`, `OnnxExporter=5`, `ModelTester=6`. + You can run only a subset of pipeline stages by modifying the list passed to `ORCA(...)`. Stages are sorted by their internal index and some depend on outputs of earlier stages. Stage indices: `GDSGenerator=0`, `DRCChecker=1`, `GDSConverter=2`, `PalaceSimulator=3`, `ModelTrainer=4`, `OnnxExporter=5`, `ModelTester=6`. ## 4. Run at Scale with OpenStack diff --git a/docs/setup.md b/docs/setup.md index 2f66784..b1a42a4 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -64,7 +64,7 @@ cd ORCA pip installs the PyPI build of PyTorch (CUDA-bundled on Linux). For a CPU-only or a specific CUDA build, install torch first with the command from [PyTorch.org](https://pytorch.org/get-started/locally/); pip then keeps that version. !!! tip "Simulation-only install" - PyTorch and the other model-training packages are an optional extra. If you only want to generate layouts and run Palace simulations — on an HPC cluster, say — drop the `[train]` extra: `pip install -e .`. `GDSGenerator`, `GDSConverter` and `PalaceSimulator` work without it; `ModelTrainer`, `OnnxExporter` and `ModelTester` report the missing packages when used. + PyTorch and the other model-training packages are an optional extra. If you only want to generate layouts and run Palace simulations — on an HPC cluster, say — drop the `[train]` extra: `pip install -e .`. `GDSGenerator`, `DRCChecker`, `GDSConverter` and `PalaceSimulator` work without it; `ModelTrainer`, `OnnxExporter` and `ModelTester` report the missing packages when used. ## Verify Setup From 3018d4e80ebf75feeb93de197e15d8a001a4e042 Mon Sep 17 00:00:00 2001 From: David Lurz Date: Mon, 21 Sep 2026 22:57:44 +0200 Subject: [PATCH 4/7] constraint evaluation class --- src/orca/geometry/constraints.py | 179 +++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 src/orca/geometry/constraints.py diff --git a/src/orca/geometry/constraints.py b/src/orca/geometry/constraints.py new file mode 100644 index 0000000..43b05b3 --- /dev/null +++ b/src/orca/geometry/constraints.py @@ -0,0 +1,179 @@ +""" +Feasibility constraints: closed-form rules between input parameters, as strings. + +A geometry's :meth:`~orca.geometry.base_geometry.BaseGeometry.feasibility_constraints` +returns expressions such as ``"bottom_linewidth <= bottom_winding_diameter / 3"``. +They are written into the exported ONNX model under the ``input_constraints`` +metadata key, so a consumer (COBRA) can tell an input it is about to query lies +outside the region the model was trained on, instead of getting a confident +prediction for a geometry that cannot be built. + +The language is a subset of Python expressions and is evaluated here without +``eval``; a consumer in another language implements the same grammar: + +- numbers, ``True``/``False``, and the input parameter names (including + ``frequency``); +- ``+ - * / // % **``, unary ``-``, comparisons (chained allowed), ``and``, + ``or``, ``not``, and the conditional ``a if c else b``; +- calls to :data:`FUNCTIONS`: ``abs min max sqrt sin cos tan radians ceil floor round``; +- the constants ``pi`` and ``sqrt2``. + +Each expression must evaluate to a boolean; a parameter set is feasible when +every expression holds. Anything outside this grammar is rejected by +:func:`validate_constraint` at export time. +""" + +from __future__ import annotations + +import ast +import math +import operator +from typing import TYPE_CHECKING, Any, Final + +if TYPE_CHECKING: + from collections.abc import Callable, Mapping + +#: Functions a constraint may call. +FUNCTIONS: Final[dict[str, Callable[..., Any]]] = { + "abs": abs, + "min": min, + "max": max, + "sqrt": math.sqrt, + "sin": math.sin, + "cos": math.cos, + "tan": math.tan, + "radians": math.radians, + "ceil": math.ceil, + "floor": math.floor, + "round": round, +} + +#: Named constants a constraint may use. +CONSTANTS: Final[dict[str, float]] = {"pi": math.pi, "sqrt2": math.sqrt(2)} + +_BINARY: Final[dict[type[ast.operator], Callable[[Any, Any], Any]]] = { + ast.Add: operator.add, + ast.Sub: operator.sub, + ast.Mult: operator.mul, + ast.Div: operator.truediv, + ast.FloorDiv: operator.floordiv, + ast.Mod: operator.mod, + ast.Pow: operator.pow, +} + +_COMPARE: Final[dict[type[ast.cmpop], Callable[[Any, Any], bool]]] = { + ast.Lt: operator.lt, + ast.LtE: operator.le, + ast.Gt: operator.gt, + ast.GtE: operator.ge, + ast.Eq: operator.eq, + ast.NotEq: operator.ne, +} + + +class ConstraintError(ValueError): + """A constraint expression is outside the supported grammar or names an unknown input.""" + + +def validate_constraint(expression: str, input_names: list[str] | tuple[str, ...]) -> None: + """Raise :class:`ConstraintError` unless *expression* is well-formed over *input_names*.""" + try: + tree = ast.parse(expression, mode="eval") + except SyntaxError as e: + raise ConstraintError(f"Constraint {expression!r} is not a valid expression: {e.msg}") from e + _check(tree.body, expression, set(input_names)) + + +def evaluate_constraint(expression: str, params: Mapping[str, Any]) -> bool: + """Whether *expression* holds for the parameter values in *params*. + + Raises: + ConstraintError: If the expression is outside the grammar, names a + parameter that is not in *params*, or does not evaluate to a boolean. + """ + tree = ast.parse(expression, mode="eval") + _check(tree.body, expression, set(params)) + result = _eval(tree.body, params) + if not isinstance(result, bool): + raise ConstraintError(f"Constraint {expression!r} evaluates to {result!r}, not to a boolean.") + return result + + +def is_feasible_by(constraints: list[str], params: Mapping[str, Any]) -> bool: + """Whether *params* satisfy every expression in *constraints*.""" + return all(evaluate_constraint(expression, params) for expression in constraints) + + +def _check(node: ast.expr, expression: str, names: set[str]) -> None: + """Walk *node* and reject anything outside the grammar.""" + match node: + case ast.Constant(value=bool() | int() | float()): + pass + case ast.Name(id=name): + if name not in names and name not in CONSTANTS: + raise ConstraintError( + f"Constraint {expression!r} uses {name!r}, which is not an input " + f"parameter ({', '.join(sorted(names))}) or a constant." + ) + case ast.UnaryOp(op=ast.USub() | ast.UAdd() | ast.Not(), operand=operand): + _check(operand, expression, names) + case ast.BinOp(left=left, op=op, right=right) if type(op) in _BINARY: + _check(left, expression, names) + _check(right, expression, names) + case ast.BoolOp(values=values): + for value in values: + _check(value, expression, names) + case ast.Compare(left=left, ops=ops, comparators=comparators): + if any(type(op) not in _COMPARE for op in ops): + raise ConstraintError(f"Constraint {expression!r} uses an unsupported comparison.") + for value in (left, *comparators): + _check(value, expression, names) + case ast.IfExp(test=test, body=body, orelse=orelse): + for value in (test, body, orelse): + _check(value, expression, names) + case ast.Call(func=ast.Name(id=name), args=args, keywords=[]): + if name not in FUNCTIONS: + raise ConstraintError( + f"Constraint {expression!r} calls {name!r}; allowed functions are " + f"{', '.join(FUNCTIONS)}." + ) + for value in args: + _check(value, expression, names) + case _: + raise ConstraintError( + f"Constraint {expression!r} contains unsupported syntax ({type(node).__name__})." + ) + + +def _eval(node: ast.expr, params: Mapping[str, Any]) -> Any: + """Evaluate a node that passed :func:`_check`.""" + match node: + case ast.Constant(value=value): + return value + case ast.Name(id=name): + return params[name] if name in params else CONSTANTS[name] + case ast.UnaryOp(op=ast.USub(), operand=operand): + return -_eval(operand, params) + case ast.UnaryOp(op=ast.UAdd(), operand=operand): + return +_eval(operand, params) + case ast.UnaryOp(op=ast.Not(), operand=operand): + return not _eval(operand, params) + case ast.BinOp(left=left, op=op, right=right): + return _BINARY[type(op)](_eval(left, params), _eval(right, params)) + case ast.BoolOp(op=ast.And(), values=values): + return all(_eval(value, params) for value in values) + case ast.BoolOp(op=ast.Or(), values=values): + return any(_eval(value, params) for value in values) + case ast.Compare(left=left, ops=ops, comparators=comparators): + current = _eval(left, params) + for op, comparator in zip(ops, comparators, strict=True): + following = _eval(comparator, params) + if not _COMPARE[type(op)](current, following): + return False + current = following + return True + case ast.IfExp(test=test, body=body, orelse=orelse): + return _eval(body, params) if _eval(test, params) else _eval(orelse, params) + case ast.Call(func=ast.Name(id=name), args=args): + return FUNCTIONS[name](*(_eval(value, params) for value in args)) + raise ConstraintError(f"Unsupported node {type(node).__name__}.") # unreachable after _check From 6aeb3505400eab834f1ae163f7c0c0f219338bd4 Mon Sep 17 00:00:00 2001 From: David Lurz Date: Mon, 21 Sep 2026 22:59:43 +0200 Subject: [PATCH 5/7] constraint test --- tests/test_constraints.py | 103 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 tests/test_constraints.py diff --git a/tests/test_constraints.py b/tests/test_constraints.py new file mode 100644 index 0000000..b83ea38 --- /dev/null +++ b/tests/test_constraints.py @@ -0,0 +1,103 @@ +"""Tests of the feasibility-constraint expressions and their agreement with is_feasible.""" + +from __future__ import annotations + +import json + +import pytest + +from orca.geometry.constraints import ( + ConstraintError, + evaluate_constraint, + is_feasible_by, + validate_constraint, +) +from orca.geometry.presets.inductor_octa import InductorOcta +from orca.geometry.presets.tf_octa_c_ports import TransformerOcta + + +class TestEvaluator: + @pytest.mark.parametrize( + ("expression", "params", "expected"), + [ + ("a <= b / 3", {"a": 1, "b": 3}, True), + ("a <= b / 3", {"a": 1.1, "b": 3}, False), + ("0 < a < b", {"a": 1, "b": 2}, True), + ("0 < a < b", {"a": 2, "b": 1}, False), + ("abs(a - b) <= 40 and a > 0", {"a": 10, "b": 45}, True), + ("abs(a - b) <= 40 or a > 0", {"a": 10, "b": 60}, True), + ("not a > b", {"a": 1, "b": 2}, True), + ("(a if a > b else b) == 5", {"a": 5, "b": 2}, True), + ("max(a, b) + min(a, b) == a + b", {"a": 3, "b": 7}, True), + ("a / 2 * sin(radians(30)) > 0.99", {"a": 4}, True), + ("sqrt(a) == sqrt2", {"a": 2}, True), + ("ceil(100 * a) / 100 >= 1.24", {"a": 1.231}, True), + ("-a < 0", {"a": 1}, True), + ("a ** 2 % 3 == 1", {"a": 2}, True), + ("a // 2 == 1", {"a": 3}, True), + ("pi > 3", {}, True), + ], + ) + def test_evaluates(self, expression, params, expected): + assert evaluate_constraint(expression, params) is expected + + @pytest.mark.parametrize( + "expression", + [ + "__import__('os').system('true')", + "a.real > 0", + "[a][0] > 0", + "(lambda: 1)() > 0", + "a if a else b", # not boolean + "exec('1') is None", + "a is b", + "a in (1, 2)", + "f'{a}' == '1'", + "pow(a, 2) > 0", # not in FUNCTIONS + "max(a, key=abs) > 0", + ], + ) + def test_rejects_unsupported_syntax(self, expression): + with pytest.raises(ConstraintError): + evaluate_constraint(expression, {"a": 1, "b": 2}) + + def test_rejects_unknown_names(self): + with pytest.raises(ConstraintError, match="'width', which is not an input parameter"): + validate_constraint("width < diameter", ["diameter", "turns"]) + + def test_rejects_non_boolean_result(self): + with pytest.raises(ConstraintError, match="not to a boolean"): + evaluate_constraint("a + b", {"a": 1, "b": 2}) + + def test_rejects_syntax_errors(self): + with pytest.raises(ConstraintError, match="not a valid expression"): + validate_constraint("a <", ["a"]) + + def test_is_feasible_by(self): + assert is_feasible_by(["a > 0", "b > a"], {"a": 1, "b": 2}) + assert not is_feasible_by(["a > 0", "b > a"], {"a": 3, "b": 2}) + assert is_feasible_by([], {"a": 3}) + + +@pytest.mark.parametrize("geometry_class", [TransformerOcta, InductorOcta]) +class TestPresetConstraints: + def test_are_valid_over_the_declared_inputs(self, geometry_class): + geometry = geometry_class() + names = list(geometry.input_parameter_iterator.get_ranges()) + for expression in geometry.feasibility_constraints(): + validate_constraint(expression, names) + json.dumps(geometry.feasibility_constraints()) + + def test_agree_with_is_feasible(self, geometry_class): + # Random draws over the whole parameter box, without the constraint, so + # both feasible and infeasible combinations are seen. + geometry = geometry_class() + iterator = geometry.input_parameter_iterator + iterator.set_sample_count(2000, seed=9) + constraints = geometry.feasibility_constraints() + verdicts = set() + for params in iterator: + expected = geometry.is_feasible(params) + assert is_feasible_by(constraints, params | {"frequency": 1e9}) is expected, params + verdicts.add(expected) + assert verdicts == {True, False} From 164fdf76802976b64558999e6411348ae942025d Mon Sep 17 00:00:00 2001 From: David Lurz Date: Tue, 22 Sep 2026 11:58:58 +0200 Subject: [PATCH 6/7] inductor for slurm runs --- examples/main.py | 3 ++- examples/slurm_runs/main.py | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/examples/main.py b/examples/main.py index 77182b0..8f2ba52 100644 --- a/examples/main.py +++ b/examples/main.py @@ -1,5 +1,6 @@ import orca from orca.geometry.presets.tf_octa_c_ports import TransformerOcta +from orca.geometry.presets.inductor_octa import InductorOcta PLOT = False @@ -23,7 +24,7 @@ def main(): torch.manual_seed(40) except ModuleNotFoundError: pass - geometry = TransformerOcta() + geometry = InductorOcta() orca_instance = orca.ORCA( [ diff --git a/examples/slurm_runs/main.py b/examples/slurm_runs/main.py index 22a7960..f522948 100644 --- a/examples/slurm_runs/main.py +++ b/examples/slurm_runs/main.py @@ -1,5 +1,6 @@ import orca from orca.geometry.presets.tf_octa_c_ports import TransformerOcta +#from orca.geometry.presets.inductor_octa import InductorOcta def main(): @@ -9,11 +10,14 @@ def main(): torch.manual_seed(40) except ModuleNotFoundError: pass + geometry = TransformerOcta(name="tf_octa_c_ports") + # geometry = InductorOcta(name="inductor_octa") orca_instance = orca.ORCA( [ orca.GDSGenerator(num_samples=6000, seed=40), + orca.DRCChecker(), orca.GDSConverter(), # launcher="slurm" runs the simulations as srun job steps on the nodes of this allocation # (#SBATCH --nodes in the job script). bind="numa" with num_parallel_sims=0 runs one From c345d5a1efb8aac35c09523560df82b459780592 Mon Sep 17 00:00:00 2001 From: David Lurz Date: Tue, 22 Sep 2026 12:02:00 +0200 Subject: [PATCH 7/7] ruff fixes --- examples/main.py | 3 ++- examples/slurm_runs/main.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/main.py b/examples/main.py index 8f2ba52..5d56028 100644 --- a/examples/main.py +++ b/examples/main.py @@ -1,5 +1,6 @@ import orca -from orca.geometry.presets.tf_octa_c_ports import TransformerOcta + +# from orca.geometry.presets.tf_octa_c_ports import TransformerOcta from orca.geometry.presets.inductor_octa import InductorOcta PLOT = False diff --git a/examples/slurm_runs/main.py b/examples/slurm_runs/main.py index f522948..885913d 100644 --- a/examples/slurm_runs/main.py +++ b/examples/slurm_runs/main.py @@ -1,5 +1,6 @@ import orca from orca.geometry.presets.tf_octa_c_ports import TransformerOcta + #from orca.geometry.presets.inductor_octa import InductorOcta