diff --git a/README.md b/README.md index 0c2c7e7..645745e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,518 @@ -# mxpic_router +# mxpic_router GDS Generation Logic -routing program for mxpic \ No newline at end of file +`mxpic_router` is the external layout build engine used by `mxpic_EDA`. +The EDA repo owns canvas editing, YAML export, login, and API routing. This +repo owns the Nazca-based GDS build flow: it reads saved cell YAML files, +loads the selected technology manifest, loads PDK GDS assets, registers +routable pins, connects bundle links through `mxpic_forge.Route`, and exports +the final GDS. + +## High Level Flow + +```text +Canvas Build Layout +-> frontend/canvas.html handleBuildLayout() +-> buildYamlForPage() +-> POST /api/save-layout +-> backend/server.py save_layout() +-> writes /.yml +-> backend/routed_layout_preview.py create_routed_layout_svg() +-> mxpic_router.build_project_gds(..., target_cell_name=) +-> temporary .gds +-> gdstk.read_gds(...).top_level()[0].write_svg(...) +-> /.svg preview +``` + +```text +Canvas Build GDS +-> frontend/canvas.html handleBuildGds() +-> POST /api/build-gds +-> backend/server.py build_gds() +-> backend/gds_builder.py build_project_gds() +-> mxpic_router.build_project_gds(...) +-> Nazca export_gds(...) +-> downloadable .gds +``` + +If `mxpic_router`, `mxpic_forge`, Nazca, or optional `gdstk` are absent, the +EDA server can still run canvas and login pages. Build actions are where the +router stack is required. For Build Layout, the YAML is still saved and SVG +preview is skipped when the router stack is missing. + +## Runtime Stack + +The EDA backend checks build-time dependencies through +`backend/router_dependency.py`. + +```text +require_router_stack() +-> ensure_router_path() + -> adds sibling ../mxpic_router to sys.path if present +-> import mxpic_router +-> import nazca +-> optionally import gdstk for SVG preview +-> import mxpic_router.builder._import_mxpic_forge_route() + -> adds sibling ../mxpic_forge to sys.path + -> removes any already-loaded non-forge mxpic modules + -> from mxpic import Route +``` + +Important package naming: + +- `mxpic_router` is this active router package. +- `mxpic_router_legacy` is the old internal legacy package. +- `mxpic` should resolve to `mxpic_forge`, because `mxpic_forge` provides + `Route`. + +## Inputs To mxpic_router + +The public entry point is: + +```python +mxpic_router.build_project_gds( + project_dir, + output_path, + pdk_root, + technology_manifest_path=None, + prefer_full_gds=False, + target_cell_name=None, +) +``` + +Inputs: + +- `project_dir`: directory containing saved `.yml` or `.yaml` cell documents. +- `output_path`: final GDS path. +- `pdk_root`: role-scoped PDK root, usually one of: + - `opt_pdk_public/foundries` + - `opt_pdk_atlas/foundries` +- `technology_manifest_path`: selected technology file, for example: + - `opt_pdk_public/foundries/Silterra/EMO1_2ML_CU_Al_RDL/technology.yml` +- `prefer_full_gds`: manager/atlas users prefer full GDS files before black-box + GDS files. +- `target_cell_name`: optional. Used by SVG preview to build one saved cell as + the top cell. + +The return value is a dictionary: + +```python +{ + "output_path": output_path, + "engine": "mxpic_router", + "cells_built": [...], + "warnings": [...], +} +``` + +## How Technology Is Loaded + +Technology selection happens in `mxpic_EDA`, then the selected manifest path is +passed into `mxpic_router`. + +```text +backend/server.py list_technologies() +-> current_pdk_root() +-> scan /// +-> expose only directories containing technology.yml +``` + +```text +backend/server.py technology_manifest_path_for_project(project) +-> read project metadata "technology", such as "Silterra/EMO1_2ML_CU_Al_RDL" +-> build ///technology.yml +-> pass this path to mxpic_router +``` + +Inside this repo: + +```text +mxpic_router.builder.build_project_gds() +-> technology.load_technology_manifest(path) + -> yaml.safe_load(technology.yml) +-> technology.apply_technology_manifest(manifest, nazca) + -> for manifest.layers: + nd.add_layer(name=, layer=(layer, datatype), overwrite=True) + -> for manifest.xsections: + nd.add_xsection(name=) + nd.add_layer2xsection( + xsection=, + layer=, + growx/growy/leftedge/rightedge=..., + overwrite=True, + ) +``` + +Expected `technology.yml` shape: + +```yaml +foundry: Silterra +technology: EMO1_2ML_CU_Al_RDL +layers: + WG_STRIP: {layer: 101, datatype: 251} +routing_types: + - euler_bend + - standard_bend +defaults: + xsection: strip + family: optical + width: 0.45 + radius: 10 + routing_type: euler_bend +xsections: + strip: + family: optical + default_width: 0.45 + default_radius: 10 + layers: + - {layer: WG_STRIP, growx: 4, growy: 4} +``` + +The frontend also reads the same manifest through +`/api/technologies///manifest` so the canvas route editor +uses the same xsections, widths, radii, and routing types that Nazca will use +during GDS generation. + +## How Project YAML Is Loaded + +The frontend serializes each canvas page into the mxPIC YAML format. + +```text +frontend/canvas.html buildYamlForPage() +-> buildCanvasPinsYaml() +-> buildInstancesYaml() +-> buildElementsYaml() +-> buildRouteBundlesYaml() +-> POST /api/save-layout +-> backend/server.py writes /.yml +``` + +`mxpic_router` loads all saved YAML files from the project directory: + +```text +builder._load_project_specs(project_dir) +-> sorted os.listdir(project_dir) +-> every .yml/.yaml file +-> eda_loader.load_cell_spec(path) +-> yaml.safe_load(...) +-> eda_loader.parse_cell_dict(...) +-> CellSpec dataclass +``` + +The parser maps YAML into typed dataclasses: + +- `CellSpec`: name, type, version, pins, elements, instances, bundles. +- `PinSpec`: top-level exported cell pins. +- `ElementSpec`: canvas-only helper elements such as Port and Anchor. +- `InstanceSpec`: placed component instance, with x, y, rotation, flip, flop, + mirror, and settings. +- `BundleSpec`: group of routed links. +- `LinkSpec`: one connection, including endpoints, xsection, family, width, + radius, routing type, and optional manual points. + +Endpoint formats accepted by the loader: + +```yaml +from: instance_name:pin_name +to: other_instance:pin_name +``` + +or equivalent explicit fields: + +```yaml +src_inst: instance_name +src_pin: pin_name +dst_inst: other_instance +dst_pin: pin_name +``` + +If a link xsection starts with `metal`, the loader defaults its route family to +`electrical`; otherwise it defaults to `optical`. + +## Build Order + +`builder.build_project_gds()` builds cells in this order: + +```text +load all CellSpec objects +-> _ordered_specs() + -> composite cells first + -> project cells last +-> _build_cell(...) for each spec +-> _select_top_spec(...) + -> target_cell_name if provided + -> otherwise last ordered spec, normally the project cell +-> nd.export_gds(topcells=[built_cells[top.name]], filename=output_path) +``` + +This allows project-level YAML to instantiate composite cells that were built +earlier in the same project directory. + +## How PDK Libraries Are Found And Loaded + +The PDK tree is not stored inside `mxpic_EDA` or `mxpic_router`. +The active PDK root is selected by the EDA backend: + +```text +backend/pdk_access.py pdk_root_for_group() +-> manager -> MXPIC_PDK_ATLAS_ROOT or opt_pdk_atlas/foundries +-> developers -> MXPIC_PDK_PUBLIC_ROOT or opt_pdk_public/foundries +-> user -> MXPIC_PDK_PUBLIC_ROOT or opt_pdk_public/foundries +``` + +Expected PDK layout: + +```text +opt_pdk_public/ + foundries/ + Silterra/ + EMO1_2ML_CU_Al_RDL/ + technology.yml + primitives/ + multimode_interferometers/ + 1x2MMI_.../ + 1x2MMI_....yml + 1x2MMI_...._BB.gds + electronics/ + composites/ +``` + +The EDA library panel scans the selected project technology folder for +component folders. A folder containing a component `.yml` or `.yaml` file is a +component leaf. `technology.yml` is ignored during component scanning. + +The YAML saved by the canvas stores each PDK component by path relative to the +role PDK root. During GDS build: + +```text +builder._build_cell() +-> for each instance: + 1. if built-in basic component: + -> create local Nazca primitive + 2. else if instance.component is a previously built project cell: + -> place that built Cell + 3. else: + -> _resolve_pdk_asset(pdk_root, instance.component, prefer_full_gds) +``` + +PDK asset resolution: + +```text +_resolve_pdk_asset() +-> direct path: + / +-> fallback: + os.walk(pdk_root) until a folder basename matches the component name +-> metadata: + .yml or .yaml +-> GDS: + public/default: prefer _BB.gds, then .gds + manager/atlas: prefer .gds, then _BB.gds + fallback: first .gds in the folder using the same preference order +``` + +The PDK GDS is loaded and placed with Nazca: + +```text +loaded = nd.load_gds(asset["gds_path"]) +loaded.put(instance.x, instance.y, instance.rotation, flip=..., flop=...) +``` + +The component metadata YAML is used to recover routable pins: + +```yaml +ports: + a1: + x: -28.5 + y: 0.0 + a: 180.0 + width: 0.7 + b1: + x: 58.5 + y: 4.35 + a: 0.0 + width: 0.7 +``` + +`mxpic_router` prefers a `pins` dictionary. For the external PDK roots +`opt_pdk_public` and `opt_pdk_atlas`, it also accepts `ports` as routable pins +for compatibility with generated PDK metadata. + +Each metadata pin is transformed by the instance placement: + +```text +local pin x/y/a +-> apply flip/flop +-> rotate by instance.rotation +-> translate by instance.x / instance.y +-> register as pin_map[(instance_name, pin_name)] +``` + +## How Ports Are Registered + +During `_build_cell()`, every cell gets a local `pin_map`: + +```python +pin_map[(instance_name, pin_name)] = Nazca Pin +``` + +This map is the central contract between placement and routing. + +Sources of pins: + +1. Built-in basic components + - `waveguide`, `90 bend`, `180 bend`, `circle`/`cricle`, `taper` + - Created directly with Nazca primitives. + - Pins are registered from the placed primitive cell. + +2. Previously built project or composite cells + - If `instance.component` matches a cell built earlier in the project, the + cell is placed hierarchically. + - All placed pins except `org` are registered. + +3. External PDK GDS components + - GDS geometry is loaded with `nd.load_gds`. + - Routable pins are recreated from component metadata YAML. + +4. Top-level cell pins + - YAML `pins:` become Nazca pins. + - The builder also creates an inward-facing copy by rotating the pin 180 + degrees. + - Registered as: + +```python +pin_map[("this", pin_name)] = inward_pin +pin_map[(pin_name, pin_name)] = inward_pin +``` + +5. Canvas elements + - `Port` elements create named external-facing pins and internal `_in` pins. + - Routing uses the internal `_in` pins so links connect into the layout. + - `Anchor` elements create pairs such as `anchor_a1` and `anchor_b1`. + +## How Bundle Links Become GDS Routes + +The canvas serializes React Flow edges into YAML under: + +```yaml +bundles: + output_bus: + routing_type: euler_bend + links: + - from: input:input_io1 + to: mmi:a1 + xsection: strip + family: optical + width: 0.45 + radius: 10 + routing_type: euler_bend + points: + - x: 100.0 + y: 200.0 + - x: 300.0 + y: 200.0 +``` + +For each link: + +```text +builder._route_link(link, pin_map, Route, warnings) +-> route = Route( + radius=link.radius or 10, + width=link.width, + xs=link.xsection, + PCB=True for metal_1/metal_2 else False, + ) +-> p1 = pin_map[(link.src_inst, link.src_pin)] +-> p2 = pin_map[(link.dst_inst, link.dst_pin)] +``` + +### Manual Point Routing + +If `link.points` has at least two points: + +```text +if link also has pin endpoints: + first point is replaced by p1 coordinate + last point is replaced by p2 coordinate +_route_guided_link() +-> Route.strt_p2p(...) for straight segments +-> Route.bend_p2p(...) for corners when available +-> _guided_route_plan() trims corners by bend radius +``` + +If the route backend does not provide `bend_p2p`, corners are exported as +straight joins and a warning is added. + +### Automatic Pin To Pin Routing + +If there are no manual points, the builder chooses the route method from pin +angles: + +```text +same direction -> ubend_p2p +roughly opposite -> sbend_p2p +otherwise -> bend_p2p +missing method -> fallback to sbend_p2p +``` + +Then it calls the selected `mxpic_forge.Route` method: + +```python +route_method( + pin1=p1, + pin2=p2, + width=link.width, + radius=link.radius or 10, + xs=link.xsection, + arrow=False, +).put() +``` + +If either endpoint pin is missing, the link is skipped and a warning is added: + +```text +Missing route pin for : -> : +``` + +## Current Responsibilities By Module + +```text +mxpic_router/__init__.py +-> public exports, especially build_project_gds + +mxpic_router/technology.py +-> read technology.yml +-> register Nazca layers +-> register Nazca xsections and layer mappings + +mxpic_router/eda_loader.py +-> read saved cell YAML +-> parse pins, elements, instances, bundles, and links into dataclasses +-> normalize numbers, booleans, endpoints, points, and route family defaults + +mxpic_router/builder.py +-> import Nazca +-> import mxpic_forge Route +-> apply technology manifest +-> load all project YAML specs +-> build basic, project, and PDK instances +-> register routable pins +-> route bundle links +-> export final GDS + +mxpic_router_legacy/ +-> old legacy package, renamed so `mxpic` remains free for mxpic_forge +``` + +## Maintenance Notes + +- Keep `technology.yml` as the single source of truth for layer/xsection + definitions. The frontend route editor and Nazca build path both consume it. +- Keep PDK component metadata YAML beside its GDS files. The router needs that + metadata to recreate pins after `nd.load_gds`. +- Avoid reintroducing an internal `mxpic` package inside this repo. The name + `mxpic` must continue to resolve to `mxpic_forge`. +- When adding a new routing family or xsection, update `technology.yml` first, + then verify that `mxpic_forge.Route` supports the required route method. +- When adding a new component source, make sure it eventually contributes pins + to `pin_map`; otherwise bundle links can load but cannot route. diff --git a/mxpic_router/__init__.py b/mxpic_router/__init__.py new file mode 100644 index 0000000..ba3babb --- /dev/null +++ b/mxpic_router/__init__.py @@ -0,0 +1,23 @@ +"""Import-safe mxpic router package used by mxpic_EDA.""" + +from .builder import build_project_gds +from .eda_loader import ( + BundleSpec, + CellSpec, + InstanceSpec, + LinkSpec, + PinSpec, + load_cell_spec, + parse_cell_dict, +) + +__all__ = [ + "BundleSpec", + "CellSpec", + "InstanceSpec", + "LinkSpec", + "PinSpec", + "build_project_gds", + "load_cell_spec", + "parse_cell_dict", +] diff --git a/mxpic_router/__pycache__/__init__.cpython-314.pyc b/mxpic_router/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..28e36a4 Binary files /dev/null and b/mxpic_router/__pycache__/__init__.cpython-314.pyc differ diff --git a/mxpic_router/__pycache__/__init__.cpython-39.pyc b/mxpic_router/__pycache__/__init__.cpython-39.pyc new file mode 100644 index 0000000..3064477 Binary files /dev/null and b/mxpic_router/__pycache__/__init__.cpython-39.pyc differ diff --git a/mxpic_router/__pycache__/builder.cpython-314.pyc b/mxpic_router/__pycache__/builder.cpython-314.pyc new file mode 100644 index 0000000..978323d Binary files /dev/null and b/mxpic_router/__pycache__/builder.cpython-314.pyc differ diff --git a/mxpic_router/__pycache__/builder.cpython-39.pyc b/mxpic_router/__pycache__/builder.cpython-39.pyc new file mode 100644 index 0000000..24ffc3d Binary files /dev/null and b/mxpic_router/__pycache__/builder.cpython-39.pyc differ diff --git a/mxpic_router/__pycache__/eda_loader.cpython-314.pyc b/mxpic_router/__pycache__/eda_loader.cpython-314.pyc new file mode 100644 index 0000000..6bc678f Binary files /dev/null and b/mxpic_router/__pycache__/eda_loader.cpython-314.pyc differ diff --git a/mxpic_router/__pycache__/eda_loader.cpython-39.pyc b/mxpic_router/__pycache__/eda_loader.cpython-39.pyc new file mode 100644 index 0000000..6289e4c Binary files /dev/null and b/mxpic_router/__pycache__/eda_loader.cpython-39.pyc differ diff --git a/mxpic_router/__pycache__/technology.cpython-314.pyc b/mxpic_router/__pycache__/technology.cpython-314.pyc new file mode 100644 index 0000000..7168aec Binary files /dev/null and b/mxpic_router/__pycache__/technology.cpython-314.pyc differ diff --git a/mxpic_router/__pycache__/technology.cpython-39.pyc b/mxpic_router/__pycache__/technology.cpython-39.pyc new file mode 100644 index 0000000..c324b41 Binary files /dev/null and b/mxpic_router/__pycache__/technology.cpython-39.pyc differ diff --git a/mxpic_router/builder.py b/mxpic_router/builder.py new file mode 100644 index 0000000..860d910 --- /dev/null +++ b/mxpic_router/builder.py @@ -0,0 +1,587 @@ +import math +import os +import sys +from typing import Dict, Optional + +import yaml + +from .eda_loader import CellSpec, InstanceSpec, LinkSpec, load_cell_spec +from .technology import apply_technology_manifest, load_technology_manifest + + +def build_project_gds( + project_dir: str, + output_path: str, + pdk_root: str, + technology_manifest_path: Optional[str] = None, + prefer_full_gds: bool = False, + target_cell_name: Optional[str] = None, +) -> dict: + import nazca as nd + + Route = _import_mxpic_forge_route() + manifest = load_technology_manifest(technology_manifest_path) + apply_technology_manifest(manifest, nd) + + specs = _load_project_specs(project_dir) + if not specs: + raise ValueError("No saved cell YAML files found for this project") + + built_cells = {} + warnings = [] + for spec in _ordered_specs(specs): + built_cells[spec.name] = _build_cell(spec, built_cells, pdk_root, prefer_full_gds, Route, nd, warnings) + + top = _select_top_spec(specs, target_cell_name) + os.makedirs(os.path.dirname(output_path), exist_ok=True) + nd.export_gds(topcells=[built_cells[top.name]], filename=output_path) + return { + "output_path": output_path, + "engine": "mxpic_router", + "cells_built": [spec.name for spec in _ordered_specs(specs)], + "warnings": warnings, + } + + +def _load_project_specs(project_dir: str) -> Dict[str, CellSpec]: + specs = {} + for filename in sorted(os.listdir(project_dir)): + if not filename.lower().endswith((".yml", ".yaml")): + continue + spec = load_cell_spec(os.path.join(project_dir, filename)) + specs[spec.name] = spec + return specs + + +def _ordered_specs(specs: Dict[str, CellSpec]): + composites = [spec for spec in specs.values() if spec.type != "project"] + projects = [spec for spec in specs.values() if spec.type == "project"] + return composites + projects + + +def _select_top_spec(specs: Dict[str, CellSpec], target_cell_name: Optional[str]): + if target_cell_name: + if target_cell_name not in specs: + raise ValueError(f"Target cell not found for routed build: {target_cell_name}") + return specs[target_cell_name] + return _ordered_specs(specs)[-1] + + +def _build_cell(spec: CellSpec, built_cells: dict, pdk_root: str, prefer_full_gds: bool, Route, nd, warnings: list): + pin_map = {} + with nd.Cell(name=spec.name) as top: + for instance_name, instance in spec.instances.items(): + if _is_basic_component(instance.component): + basic_cell = _build_basic_component(instance, nd) + placed = basic_cell.put(instance.x, instance.y, instance.rotation, flip=instance.flip, flop=instance.flop) + for pin_name in getattr(placed, "pin", {}): + if pin_name != "org": + pin_map[(instance_name, pin_name)] = placed.pin[pin_name] + continue + + if instance.component in built_cells: + placed = built_cells[instance.component].put(instance.x, instance.y, instance.rotation, flip=instance.flip, flop=instance.flop) + for pin_name in getattr(placed, "pin", {}): + if pin_name != "org": + pin_map[(instance_name, pin_name)] = placed.pin[pin_name] + continue + + asset = _resolve_pdk_asset(pdk_root, instance.component, prefer_full_gds) + if not asset.get("gds_path"): + warnings.append(f"Missing GDS for {instance_name}: {instance.component}") + continue + loaded = nd.load_gds(asset["gds_path"]) + loaded.put(instance.x, instance.y, instance.rotation, flip=instance.flip, flop=instance.flop) + _register_metadata_pins(pin_map, instance_name, instance, asset.get("metadata") or {}, nd, pdk_root) + + for pin_name, pin in spec.pins.items(): + pin_out = nd.Pin(pin_name, width=pin.width).put(pin.x, pin.y, pin.angle) + pin_in = pin_out.move(0, 0, 180) + pin_map[("this", pin_name)] = pin_in + pin_map[(pin_name, pin_name)] = pin_in + + for element_name, element in spec.elements.items(): + _register_element_pins(pin_map, element_name, element, nd) + + for bundle in spec.bundles.values(): + for link in bundle.links: + _route_link(link, pin_map, Route, warnings) + return top + + +def _is_basic_component(component: str) -> bool: + return component in {"waveguide", "90 bend", "180 bend", "cricle", "circle", "taper"} + + +def _build_basic_component(instance: InstanceSpec, nd): + settings = instance.settings or {} + component = "cricle" if instance.component == "circle" else instance.component + width = _safe_float(settings.get("width"), _safe_float(settings.get("width1"), 0.5)) or 0.5 + xs = settings.get("xsection") or settings.get("xs") or "strip" + radius = _safe_float(settings.get("radius"), 10) or 10 + length = _safe_float(settings.get("length"), 100) or 100 + name = f"basic_{component.replace(' ', '_')}_{_safe_cell_name(instance.name)}" + with nd.Cell(name=name, instantiate=False) as cell: + if component == "waveguide": + line = nd.strt(length=length, width=width, xs=xs).put(0, 0, 0) + nd.Pin("a1", width=width, xs=xs).put(line.pin["a0"]) + nd.Pin("b1", width=width, xs=xs).put(line.pin["b0"]) + elif component == "90 bend": + bend = nd.bend(radius=radius, width=width, angle=90, xs=xs).put(0, 0, 0) + nd.Pin("a1", width=width, xs=xs).put(bend.pin["a0"]) + nd.Pin("b1", width=width, xs=xs).put(bend.pin["b0"]) + elif component == "180 bend": + bend = nd.bend(radius=radius, width=width, angle=180, xs=xs).put(0, 0, 0) + nd.Pin("a1", width=width, xs=xs).put(bend.pin["a0"]) + nd.Pin("b1", width=width, xs=xs).put(bend.pin["b0"]) + elif component == "cricle": + bend = nd.bend(radius=radius, width=width, angle=360, xs=xs).put(0, 0, 0) + nd.Pin("a1", width=width, xs=xs).put(bend.pin["a0"]) + nd.Pin("b1", width=width, xs=xs).put(bend.pin["b0"]) + elif component == "taper": + width1 = _safe_float(settings.get("width1"), width) or width + width2 = _safe_float(settings.get("width2"), width) or width + taper = nd.taper(length=length, width1=width1, width2=width2, xs=xs).put(0, 0, 0) + nd.Pin("a1", width=width1, xs=xs).put(taper.pin["a0"]) + nd.Pin("b1", width=width2, xs=xs).put(taper.pin["b0"]) + return cell + + +def _register_element_pins(pin_map: dict, element_name: str, element, nd) -> None: + element_cell = _build_element_cell(element_name, element, nd) + placed = element_cell.put(element.x, element.y, element.angle) + if element.type == "port": + for pin in _port_element_pin_entries(element_name, element): + pin_map[(element_name, pin["name"])] = placed.pin[f'{pin["name"]}_in'] + return + + for pin in _anchor_element_pin_entries(element_name, element): + pin_map[(element_name, pin["name"])] = placed.pin[pin["name"]] + + +def _build_element_cell(element_name: str, element, nd): + width = element.width or 0.5 + port_number = max(1, int(getattr(element, "port_number", 1) or 1)) + pitch = _safe_float(getattr(element, "pitch", 10.0), 10.0) or 10.0 + with nd.Cell(name=f"element_{_safe_cell_name(element_name)}", instantiate=False) as cell: + if element.type == "port": + for index, pin in enumerate(_port_element_pin_entries(element_name, element)): + local_y = 0.0 if port_number == 1 else _element_port_offset(index, port_number, pitch) + pin_out = nd.Pin(pin["name"], width=width).put(0.0, local_y, 180.0) + pin_in = pin_out.move(0, 0, 180) + nd.Pin(f'{pin["name"]}_in', width=width).put(pin_in.x, pin_in.y, pin_in.a) + return cell + + anchor_pins = _anchor_element_pin_entries(element_name, element) + for index in range(port_number): + local_y = -15.0 + _element_port_offset(index, port_number, pitch) + left_pin = anchor_pins[index * 2] + right_pin = anchor_pins[index * 2 + 1] + nd.Pin(left_pin["name"], width=width).put(0.0, local_y, 180.0) + nd.Pin(right_pin["name"], width=width).put(0.0, local_y, 0.0) + return cell + + +def _element_port_offset(index: int, count: int, pitch: float) -> float: + return ((count - 1) / 2 - index) * pitch + + +def _port_element_pin_entries(element_name: str, element) -> list: + count = max(1, int(getattr(element, "port_number", 1) or 1)) + defaults = [{"role": f"io{index + 1}", "name": f"{_safe_cell_name(element_name)}_io{index + 1}"} for index in range(count)] + return _named_pin_entries(defaults, getattr(element, "pins", None)) + + +def _anchor_element_pin_entries(element_name: str, element) -> list: + count = max(1, int(getattr(element, "port_number", 1) or 1)) + defaults = [] + for index in range(count): + number = index + 1 + defaults.append({"role": f"a{number}", "name": f"{_safe_cell_name(element_name)}_a{number}"}) + defaults.append({"role": f"b{number}", "name": f"{_safe_cell_name(element_name)}_b{number}"}) + return _named_pin_entries(defaults, getattr(element, "pins", None)) + + +def _named_pin_entries(defaults: list, overrides) -> list: + by_role = {str(pin.get("role") or ""): str(pin.get("name") or "") for pin in overrides or []} + by_index = [str(pin.get("name") or "") for pin in overrides or []] + entries = [] + for index, default in enumerate(defaults): + role = default["role"] + name = by_role.get(role) or (by_index[index] if index < len(by_index) else "") or default["name"] + entries.append({"role": role, "name": _safe_cell_name(name)}) + return entries + + +def _transform_element_port(local_x: float, local_y: float, angle: float, element) -> tuple: + rotation = math.radians(_safe_float(getattr(element, "angle", 0.0), 0.0) or 0.0) + cos_v = math.cos(rotation) + sin_v = math.sin(rotation) + x = element.x + local_x * cos_v - local_y * sin_v + y = element.y + local_x * sin_v + local_y * cos_v + return x, y, angle + + +def _safe_cell_name(value: str) -> str: + return "".join(ch if ch.isalnum() or ch == "_" else "_" for ch in str(value or "inst")) + + +def _register_metadata_pins(pin_map, instance_name, instance, metadata: dict, nd, pdk_root: str) -> None: + for pin_name, pin in _metadata_pins(metadata, pdk_root).items(): + x, y, angle = _transform_port( + _safe_float(pin.get("x"), 0.0), + _safe_float(pin.get("y"), 0.0), + _safe_float(pin.get("a", pin.get("angle")), 0.0), + instance.x, + instance.y, + instance.rotation, + instance.flip, + instance.flop, + ) + width = _safe_float(pin.get("width"), 0.5) or 0.5 + pin_map[(instance_name, str(pin_name))] = nd.Pin( + f"{instance_name}_{pin_name}", + width=width, + xs="strip", + ).put(x, y, angle) + + +def _metadata_pins(metadata: dict, pdk_root: str) -> dict: + if not metadata: + return {} + if isinstance(metadata.get("pins"), dict): + return metadata.get("pins") or {} + if isinstance(metadata.get("ports"), dict) and _allows_pdk_ports_as_pins(pdk_root): + return metadata.get("ports") or {} + return {} + + +def _allows_pdk_ports_as_pins(pdk_root: str) -> bool: + normalized = os.path.abspath(str(pdk_root or "")).replace("\\", "/").lower() + return "/opt_pdk_public/" in f"{normalized}/" or "/opt_pdk_atlas/" in f"{normalized}/" + + +def _metal_route_pcb_enabled(xsection: str) -> bool: + normalized = str(xsection or "").strip().lower().replace("-", "_") + return normalized in {"metal_1", "metal1", "metal_2", "metal2"} + + +def _route_link(link: LinkSpec, pin_map: dict, Route, warnings: list) -> None: + route = Route(radius=link.radius or 10, width=link.width, xs=link.xsection, PCB=_metal_route_pcb_enabled(link.xsection)) + p1 = pin_map.get((link.src_inst, link.src_pin)) + p2 = pin_map.get((link.dst_inst, link.dst_pin)) + has_pin_endpoints = bool(link.src_inst or link.src_pin or link.dst_inst or link.dst_pin) + if len(link.points or []) >= 2: + if has_pin_endpoints: + if p1 is None or p2 is None: + warnings.append(f"Missing route pin for {link.src_inst}:{link.src_pin} -> {link.dst_inst}:{link.dst_pin}") + return + points = _route_points_with_pin_endpoints(link.points, p1, p2) + else: + points = link.points + if _route_guided_link(link, route, warnings, points): + return + if p1 is None or p2 is None: + warnings.append(f"Missing route pin for {link.src_inst}:{link.src_pin} -> {link.dst_inst}:{link.dst_pin}") + return + method_name = _route_method_name_for_pins(p1, p2) + route_method = getattr(route, method_name, None) + if route_method is None: + warnings.append(f"Route method {method_name} unavailable; falling back to sbend_p2p") + route_method = route.sbend_p2p + route_method( + pin1=p1, + pin2=p2, + width=link.width, + radius=link.radius or 10, + xs=link.xsection, + arrow=False, + ).put() + +def _route_guided_link(link: LinkSpec, route, warnings: list, points_override=None) -> bool: + route_method = getattr(route, "strt_p2p", None) + if route_method is None: + warnings.append("Manual route points require Route.strt_p2p; falling back to automatic p2p route") + return False + bend_method = getattr(route, "bend_p2p", None) + source_points = points_override if points_override is not None else (link.points or []) + points = [ + {"x": _safe_float(point.get("x"), None), "y": _safe_float(point.get("y"), None)} + for point in source_points + ] + points = [point for point in points if point["x"] is not None and point["y"] is not None] + if len(points) < 2: + return False + if bend_method is None: + warnings.append("Manual route bends require Route.bend_p2p; corners were exported as straight joins") + plan = _guided_route_plan(points, (link.radius or 10) if bend_method is not None else 0) + for straight in plan["straights"]: + route_method( + pin1=(straight["start"]["x"], straight["start"]["y"], straight["angle"]), + pin2=(straight["end"]["x"], straight["end"]["y"], straight["angle"]), + width=link.width, + xs=link.xsection, + arrow=False, + ).put() + if bend_method is None: + return True + for bend in plan["bends"]: + bend_method( + pin1=(bend["start"]["x"], bend["start"]["y"], bend["angle_in"]), + pin2=(bend["end"]["x"], bend["end"]["y"], bend["angle_out"]), + radius=link.radius or 10, + width=link.width, + xs=link.xsection, + arrow=False, + ).put() + return True + + +def _route_points_with_pin_endpoints(points: list, source_pin=None, target_pin=None) -> list: + clean_points = [ + {"x": _safe_float(point.get("x"), None), "y": _safe_float(point.get("y"), None)} + for point in points or [] + if isinstance(point, dict) + ] + clean_points = [point for point in clean_points if point["x"] is not None and point["y"] is not None] + if len(clean_points) < 2: + return clean_points + source_point = _pin_point(source_pin) + target_point = _pin_point(target_pin) + if source_point is not None: + clean_points[0] = source_point + if target_point is not None: + clean_points[-1] = target_point + return clean_points + + +def _pin_point(pin): + for source in (pin, getattr(pin, "pointer", None)): + if source is None: + continue + x = _safe_float(getattr(source, "x", None), None) + y = _safe_float(getattr(source, "y", None), None) + if x is not None and y is not None: + return {"x": float(x), "y": float(y)} + xya = getattr(source, "xya", None) + if callable(xya): + try: + values = xya() + except TypeError: + values = None + if values and len(values) >= 2: + x = _safe_float(values[0], None) + y = _safe_float(values[1], None) + if x is not None and y is not None: + return {"x": float(x), "y": float(y)} + return None + + +def _guided_route_plan(points: list, radius: float) -> dict: + clean_points = [ + {"x": _safe_float(point.get("x"), None), "y": _safe_float(point.get("y"), None)} + for point in points or [] + if isinstance(point, dict) + ] + clean_points = [point for point in clean_points if point["x"] is not None and point["y"] is not None] + if len(clean_points) < 2: + return {"straights": [], "bends": []} + + trim_radius = max(_safe_float(radius, 0.0) or 0.0, 0.0) + corner_trims = {} + bends = [] + for index in range(1, len(clean_points) - 1): + previous_point = clean_points[index - 1] + corner = clean_points[index] + next_point = clean_points[index + 1] + angle_in = _segment_angle(previous_point, corner) + angle_out = _segment_angle(corner, next_point) + if angle_in is None or angle_out is None or angle_in == angle_out: + continue + turn_delta = abs(((angle_out - angle_in + 180) % 360) - 180) + if turn_delta >= 179: + continue + previous_length = _distance(previous_point, corner) + next_length = _distance(corner, next_point) + trim = min(trim_radius, previous_length / 2.0, next_length / 2.0) + if trim <= 1e-9: + continue + before = _point_toward(corner, previous_point, trim) + after = _point_toward(corner, next_point, trim) + corner_trims[index] = {"before": before, "after": after} + bends.append({ + "start": before, + "corner": {"x": float(corner["x"]), "y": float(corner["y"])}, + "end": after, + "angle_in": angle_in, + "angle_out": angle_out, + }) + + straights = [] + for index in range(len(clean_points) - 1): + start = corner_trims.get(index, {}).get("after", clean_points[index]) + end = corner_trims.get(index + 1, {}).get("before", clean_points[index + 1]) + angle = _segment_angle(start, end) + if angle is None or _distance(start, end) <= 1e-9: + continue + straights.append({ + "start": {"x": float(start["x"]), "y": float(start["y"])}, + "end": {"x": float(end["x"]), "y": float(end["y"])}, + "angle": angle, + }) + + return {"straights": straights, "bends": bends} + + +def _distance(point1: dict, point2: dict) -> float: + return math.hypot(point2["x"] - point1["x"], point2["y"] - point1["y"]) + + +def _point_toward(origin: dict, target: dict, distance: float) -> dict: + total = _distance(origin, target) + if total <= 1e-9: + return {"x": float(origin["x"]), "y": float(origin["y"])} + ratio = distance / total + return { + "x": float(origin["x"] + (target["x"] - origin["x"]) * ratio), + "y": float(origin["y"] + (target["y"] - origin["y"]) * ratio), + } + + +def _segment_angle(point1: dict, point2: dict): + dx = point2["x"] - point1["x"] + dy = point2["y"] - point1["y"] + if abs(dx) < 1e-9 and abs(dy) < 1e-9: + return None + if abs(dx) >= abs(dy): + return 0 if dx >= 0 else 180 + return 90 if dy >= 0 else 270 + + +def _pins_have_same_rotation(pin1, pin2, tolerance: float = 1e-6) -> bool: + angle1 = _pin_angle(pin1) + angle2 = _pin_angle(pin2) + if angle1 is None or angle2 is None: + return False + return abs(((angle1 - angle2 + 180) % 360) - 180) <= tolerance + + +def _route_method_name_for_pins(pin1, pin2) -> str: + angle1 = _pin_angle(pin1) + angle2 = _pin_angle(pin2) + if angle1 is None or angle2 is None: + return "sbend_p2p" + delta = (angle2 - angle1) % 360 + if delta <= 1e-6 or abs(delta - 360) <= 1e-6: + return "ubend_p2p" + if 120 < delta < 240: + return "sbend_p2p" + return "bend_p2p" + + +def _pin_angle(pin): + for source in (pin, getattr(pin, "pointer", None)): + if source is None: + continue + for attr in ("a", "angle", "rot", "rotation"): + value = getattr(source, attr, None) + if callable(value): + try: + value = value() + except TypeError: + continue + number = _safe_float(value, None) + if number is not None: + return number % 360 + xya = getattr(source, "xya", None) + if callable(xya): + try: + values = xya() + except TypeError: + values = None + if values and len(values) >= 3: + number = _safe_float(values[2], None) + if number is not None: + return number % 360 + return None + + +def _resolve_pdk_asset(pdk_root: str, component: str, prefer_full_gds: bool) -> dict: + key = (component or "").replace("\\", "/").strip("/") + name = key.split("/")[-1] + direct = os.path.join(pdk_root, *key.split("/")) + search_dirs = [direct] if os.path.isdir(direct) else [] + if not search_dirs: + for root, dirs, _ in os.walk(pdk_root): + if os.path.basename(root) == name: + search_dirs.append(root) + dirs.clear() + break + if not search_dirs: + return {} + + search_dir = search_dirs[0] + yaml_path = _first_existing(search_dir, [f"{name}.yml", f"{name}.yaml"]) + gds_path = _find_gds(search_dir, name, prefer_full_gds) + metadata = {} + if yaml_path: + with open(yaml_path, "r", encoding="utf-8") as file: + metadata = yaml.safe_load(file) or {} + return {"yaml_path": yaml_path, "gds_path": gds_path, "metadata": metadata} + + +def _find_gds(search_dir: str, name: str, prefer_full_gds: bool): + preferred = [f"{name}.gds", f"{name}_BB.gds"] if prefer_full_gds else [f"{name}_BB.gds", f"{name}.gds"] + found = _first_existing(search_dir, preferred) + if found: + return found + files = sorted(filename for filename in os.listdir(search_dir) if filename.lower().endswith(".gds")) + full = [filename for filename in files if not filename.lower().endswith("_bb.gds")] + bb = [filename for filename in files if filename.lower().endswith("_bb.gds")] + ordered = full + bb if prefer_full_gds else bb + full + return os.path.join(search_dir, ordered[0]) if ordered else None + + +def _first_existing(search_dir: str, filenames: list): + for filename in filenames: + path = os.path.join(search_dir, filename) + if os.path.exists(path): + return path + return None + + +def _transform_port(px: float, py: float, pa: float, ix: float, iy: float, rotation: float, flip: bool = False, flop: bool = False): + if flip: + py = -py + pa = -pa + if flop: + px = -px + pa = 180 - pa + theta = math.radians(rotation) + c, s = math.cos(theta), math.sin(theta) + return ix + px * c - py * s, iy + px * s + py * c, (pa + rotation) % 360 + + +def _safe_float(value, default=0.0): + if value is None: + return default + if isinstance(value, str) and value.strip().lower() in {"", "none", "null"}: + return default + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _import_mxpic_forge_route(): + forge_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "mxpic_forge")) + if os.path.isdir(forge_root) and forge_root not in sys.path: + sys.path.insert(0, forge_root) + loaded_mxpic = sys.modules.get("mxpic") + loaded_path = os.path.abspath(getattr(loaded_mxpic, "__file__", "")) if loaded_mxpic else "" + if loaded_mxpic and forge_root not in loaded_path: + for module_name in list(sys.modules): + if module_name == "mxpic" or module_name.startswith("mxpic."): + del sys.modules[module_name] + from mxpic import Route + return Route diff --git a/mxpic_router/eda_loader.py b/mxpic_router/eda_loader.py new file mode 100644 index 0000000..fcc2c28 --- /dev/null +++ b/mxpic_router/eda_loader.py @@ -0,0 +1,227 @@ +from dataclasses import dataclass, field +from typing import Dict, List, Optional + +import yaml + + +@dataclass +class PinSpec: + name: str + x: float = 0.0 + y: float = 0.0 + angle: float = 0.0 + width: float = 0.5 + layer: str = "WG_CORE" + + +@dataclass +class InstanceSpec: + name: str + component: str + x: float = 0.0 + y: float = 0.0 + rotation: float = 0.0 + flip: bool = False + flop: bool = False + mirror: bool = False + settings: Dict = field(default_factory=dict) + + +@dataclass +class ElementSpec: + name: str + type: str + x: float = 0.0 + y: float = 0.0 + angle: float = 0.0 + width: float = 0.5 + port_number: int = 1 + pitch: float = 10.0 + layer: str = "WG_CORE" + description: str = "" + pins: List[Dict[str, str]] = field(default_factory=list) + + +@dataclass +class LinkSpec: + src_inst: str = "" + src_pin: str = "" + dst_inst: str = "" + dst_pin: str = "" + xsection: str = "strip" + family: str = "optical" + width: Optional[float] = None + radius: Optional[float] = None + routing_type: str = "euler_bend" + points: List[Dict[str, float]] = field(default_factory=list) + + +@dataclass +class BundleSpec: + name: str + links: List[LinkSpec] = field(default_factory=list) + routing_type: str = "euler_bend" + radius: Optional[float] = None + width: Optional[float] = None + xsection: str = "strip" + + +@dataclass +class CellSpec: + name: str + type: str = "composite" + version: str = "1.0.0" + pins: Dict[str, PinSpec] = field(default_factory=dict) + elements: Dict[str, ElementSpec] = field(default_factory=dict) + instances: Dict[str, InstanceSpec] = field(default_factory=dict) + bundles: Dict[str, BundleSpec] = field(default_factory=dict) + + +def load_cell_spec(path: str) -> CellSpec: + with open(path, "r", encoding="utf-8") as file: + return parse_cell_dict(yaml.safe_load(file) or {}) + + +def parse_cell_dict(data: dict) -> CellSpec: + spec = CellSpec( + name=str(data.get("name") or "cell"), + type=str(data.get("type") or "composite"), + version=str(data.get("version") or "1.0.0"), + ) + + for pin in data.get("pins", []) or []: + name = str(pin.get("name") or "pin") + spec.pins[name] = PinSpec( + name=name, + x=_float(pin.get("x")), + y=_float(pin.get("y")), + angle=_float(pin.get("angle", pin.get("a"))), + width=_float(pin.get("width"), 0.5), + layer=str(pin.get("layer") or "WG_CORE"), + ) + + for element_name, element in (data.get("elements") or {}).items(): + spec.elements[str(element_name)] = ElementSpec( + name=str(element_name), + type=str(element.get("type") or "anchor"), + x=_float(element.get("x")), + y=_float(element.get("y")), + angle=_float(element.get("angle", element.get("a"))), + width=_float(element.get("width"), 0.5), + port_number=_int(element.get("pin_number", element.get("pinNumber", element.get("port_number", element.get("portNumber")))), 1), + pitch=_float(element.get("pitch"), 10.0), + layer=str(element.get("layer") or "WG_CORE"), + description=str(element.get("description") or ""), + pins=_pins(element.get("pins")), + ) + + for instance_name, instance in (data.get("instances") or {}).items(): + spec.instances[str(instance_name)] = InstanceSpec( + name=str(instance_name), + component=str(instance.get("component") or ""), + x=_float(instance.get("x")), + y=_float(instance.get("y")), + rotation=_float(instance.get("rotation")), + flip=_bool(instance.get("flip", instance.get("mirror", False))), + flop=_bool(instance.get("flop", False)), + mirror=_bool(instance.get("mirror", instance.get("flip", False))), + settings=instance.get("settings") or {}, + ) + + for bundle_name, bundle_data in (data.get("bundles") or {}).items(): + bundle = BundleSpec( + name=str(bundle_name), + routing_type=str(bundle_data.get("routing_type") or "euler_bend"), + radius=_optional_float(bundle_data.get("radius")), + width=_optional_float(bundle_data.get("width")), + xsection=str(bundle_data.get("xsection") or bundle_data.get("xs") or "strip"), + ) + for link_data in bundle_data.get("links", []) or []: + src_inst, src_pin = _endpoint(link_data.get("from"), link_data.get("src_inst"), link_data.get("src_pin")) + dst_inst, dst_pin = _endpoint(link_data.get("to"), link_data.get("dst_inst"), link_data.get("dst_pin")) + xsection = str(link_data.get("xsection") or link_data.get("xs") or bundle.xsection) + bundle.links.append(LinkSpec( + src_inst=src_inst, + src_pin=src_pin, + dst_inst=dst_inst, + dst_pin=dst_pin, + xsection=xsection, + family=str(link_data.get("family") or _family_from_xsection(xsection)), + width=_optional_float(link_data.get("width"), bundle.width), + radius=_optional_float(link_data.get("radius"), bundle.radius), + routing_type=str(link_data.get("routing_type") or bundle.routing_type), + points=_points(link_data.get("points")), + )) + spec.bundles[bundle.name] = bundle + + return spec + + +def _endpoint(compact, inst, port): + if compact: + value = str(compact) + if ":" in value: + left, right = value.split(":", 1) + return left, right + if inst or port: + return str(inst or ""), str(port or "") + return "", "" + + +def _family_from_xsection(xsection: str) -> str: + return "electrical" if str(xsection).startswith("metal") else "optical" + + +def _points(points) -> List[Dict[str, float]]: + parsed = [] + for point in points or []: + if not isinstance(point, dict): + continue + x = _optional_float(point.get("x")) + y = _optional_float(point.get("y")) + if x is None or y is None: + continue + parsed.append({"x": x, "y": y}) + return parsed + + +def _pins(pins) -> List[Dict[str, str]]: + parsed = [] + for pin in pins or []: + if not isinstance(pin, dict): + continue + name = str(pin.get("name") or "").strip() + role = str(pin.get("role") or "").strip() + if name: + parsed.append({"name": name, "role": role}) + return parsed + + +def _optional_float(value, default=None): + if value is None or value == "": + return default + return _float(value, default) + + +def _bool(value) -> bool: + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return bool(value) + + +def _float(value, default=0.0): + try: + if value is None or value == "": + return default + return float(value) + except (TypeError, ValueError): + return default + + +def _int(value, default=0): + try: + if value is None or value == "": + return default + return max(1, int(float(value))) + except (TypeError, ValueError): + return default diff --git a/mxpic_router/technology.py b/mxpic_router/technology.py new file mode 100644 index 0000000..3b96d30 --- /dev/null +++ b/mxpic_router/technology.py @@ -0,0 +1,38 @@ +import os + +import yaml + + +def load_technology_manifest(path: str) -> dict: + if not path or not os.path.exists(path): + return {} + with open(path, "r", encoding="utf-8") as file: + return yaml.safe_load(file) or {} + + +def apply_technology_manifest(manifest: dict, nd) -> None: + if not manifest: + return + for name, layer_info in (manifest.get("layers") or {}).items(): + layer = layer_info.get("layer") + datatype = layer_info.get("datatype", 0) + if layer is None: + continue + nd.add_layer(name=name, layer=(int(layer), int(datatype)), overwrite=True) + + for xsection, info in (manifest.get("xsections") or {}).items(): + nd.add_xsection(name=xsection) + for layer_binding in info.get("layers", []) or []: + layer_name = layer_binding.get("layer") + if not layer_name: + continue + kwargs = {"xsection": xsection, "layer": layer_name, "overwrite": True} + if "growx" in layer_binding: + kwargs["growx"] = layer_binding.get("growx", 0) + if "growy" in layer_binding: + kwargs["growy"] = layer_binding.get("growy", 0) + if "leftedge" in layer_binding: + kwargs["leftedge"] = tuple(layer_binding["leftedge"]) + if "rightedge" in layer_binding: + kwargs["rightedge"] = tuple(layer_binding["rightedge"]) + nd.add_layer2xsection(**kwargs) diff --git a/tests/__pycache__/test_eda_router_contract.cpython-39.pyc b/tests/__pycache__/test_eda_router_contract.cpython-39.pyc new file mode 100644 index 0000000..ec072e4 Binary files /dev/null and b/tests/__pycache__/test_eda_router_contract.cpython-39.pyc differ diff --git a/tests/test_eda_router_contract.py b/tests/test_eda_router_contract.py new file mode 100644 index 0000000..85c8d3e --- /dev/null +++ b/tests/test_eda_router_contract.py @@ -0,0 +1,226 @@ +import os +import sys +import unittest + + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + + +class EdaRouterPinsContractTest(unittest.TestCase): + def test_loader_uses_top_level_pins_and_ignores_legacy_ports(self): + from mxpic_router.eda_loader import parse_cell_dict + + spec = parse_cell_dict({ + "name": "cell", + "pins": [ + {"name": "input_io1", "x": 1, "y": 2, "angle": 90, "width": 0.6}, + ], + "ports": [ + {"name": "legacy_port", "x": 9, "y": 9, "angle": 0}, + ], + }) + + self.assertIn("input_io1", spec.pins) + self.assertNotIn("legacy_port", spec.pins) + self.assertEqual(spec.pins["input_io1"].angle, 90.0) + + def test_loader_accepts_pin_links_with_route_metadata(self): + from mxpic_router.eda_loader import parse_cell_dict + + spec = parse_cell_dict({ + "name": "cell_a", + "instances": { + "inst_a": {"component": "mmi", "x": 0, "y": 0}, + "inst_b": {"component": "mmi", "x": 100, "y": 0}, + }, + "bundles": { + "output_bus": { + "links": [{ + "from": "inst_a:out", + "to": "inst_b:in", + "xsection": "metal_1", + "family": "electrical", + "width": 5, + "radius": 20, + "routing_type": "standard_bend", + "points": [{"x": 0, "y": 0}, {"x": 50, "y": 0}], + }] + } + } + }) + + link = spec.bundles["output_bus"].links[0] + self.assertEqual(link.src_inst, "inst_a") + self.assertEqual(link.src_pin, "out") + self.assertEqual(link.dst_inst, "inst_b") + self.assertEqual(link.dst_pin, "in") + self.assertEqual(link.xsection, "metal_1") + self.assertEqual(link.width, 5) + self.assertEqual(link.points[1], {"x": 50.0, "y": 0.0}) + + def test_port_element_creates_named_io_pins_and_inside_route_pins(self): + from mxpic_router.builder import _register_element_pins + from mxpic_router.eda_loader import parse_cell_dict + + class FakePlacedPin: + def __init__(self, name, x, y, a): + self.name = name + self.x = x + self.y = y + self.a = a + + def move(self, dx, dy, da): + return FakePlacedPin(f"{self.name}_in", self.x + dx, self.y + dy, self.a + da) + + class FakePin: + created_names = [] + current_cell = None + + def __init__(self, name, width=None, xs=None): + self.name = name + self.width = width + self.xs = xs + self.created_names.append(name) + + def put(self, x, y, a): + placed = FakePlacedPin(self.name, x, y, a) + if FakePin.current_cell is not None: + FakePin.current_cell.pin[self.name] = placed + return placed + + class FakeCell: + put_calls = [] + + def __init__(self, name=None, instantiate=True): + self.name = name + self.pin = {} + + def __enter__(self): + FakePin.current_cell = self + return self + + def __exit__(self, exc_type, exc, tb): + FakePin.current_cell = None + return False + + def put(self, x, y, a): + self.put_calls.append((self.name, x, y, a)) + placed = FakeCell(name=f"{self.name}_placed") + placed.pin = { + name: FakePlacedPin(name, pin.x + x, pin.y + y, pin.a + a) + for name, pin in self.pin.items() + } + return placed + + class FakeNazca: + Pin = FakePin + Cell = FakeCell + + spec = parse_cell_dict({ + "name": "cell", + "elements": { + "input": { + "type": "port", + "x": 5, + "y": 10, + "angle": 90, + "width": 0.5, + "port_number": 2, + "pitch": 12, + "pins": [ + {"name": "input_io1", "role": "io1"}, + {"name": "input_io2", "role": "io2"}, + ], + }, + }, + }) + pin_map = {} + + _register_element_pins(pin_map, "input", spec.elements["input"], FakeNazca) + + self.assertEqual(FakePin.created_names, ["input_io1", "input_io1_in", "input_io2", "input_io2_in"]) + self.assertEqual(FakeCell.put_calls, [("element_input", 5.0, 10.0, 90.0)]) + self.assertEqual(pin_map[("input", "input_io1")].a, 450.0) + self.assertEqual(pin_map[("input", "input_io2")].a, 450.0) + self.assertEqual((pin_map[("input", "input_io1")].x, pin_map[("input", "input_io1")].y), (5.0, 16.0)) + self.assertEqual((pin_map[("input", "input_io2")].x, pin_map[("input", "input_io2")].y), (5.0, 4.0)) + + def test_anchor_element_defaults_to_named_a_b_pins(self): + from mxpic_router.builder import _register_element_pins + from mxpic_router.eda_loader import parse_cell_dict + + class FakePlacedPin: + def __init__(self, name, x, y, a): + self.name = name + self.x = x + self.y = y + self.a = a + + class FakePin: + current_cell = None + + def __init__(self, name, width=None, xs=None): + self.name = name + + def put(self, x, y, a): + placed = FakePlacedPin(self.name, x, y, a) + if FakePin.current_cell is not None: + FakePin.current_cell.pin[self.name] = placed + return placed + + class FakeCell: + def __init__(self, name=None, instantiate=True): + self.name = name + self.pin = {} + + def __enter__(self): + FakePin.current_cell = self + return self + + def __exit__(self, exc_type, exc, tb): + FakePin.current_cell = None + return False + + def put(self, x, y, a): + placed = FakeCell(name=f"{self.name}_placed") + placed.pin = { + name: FakePlacedPin(name, pin.x + x, pin.y + y, pin.a + a) + for name, pin in self.pin.items() + } + return placed + + class FakeNazca: + Pin = FakePin + Cell = FakeCell + + spec = parse_cell_dict({ + "name": "cell", + "elements": { + "anchor_1": {"type": "anchor", "x": 10, "y": 20, "angle": 0, "port_number": 2, "pitch": 12}, + }, + }) + pin_map = {} + + _register_element_pins(pin_map, "anchor_1", spec.elements["anchor_1"], FakeNazca) + + self.assertIn(("anchor_1", "anchor_1_a1"), pin_map) + self.assertIn(("anchor_1", "anchor_1_b1"), pin_map) + self.assertIn(("anchor_1", "anchor_1_a2"), pin_map) + self.assertIn(("anchor_1", "anchor_1_b2"), pin_map) + self.assertEqual(pin_map[("anchor_1", "anchor_1_a1")].a, 180.0) + self.assertEqual(pin_map[("anchor_1", "anchor_1_b1")].a, 0.0) + + def test_pdk_metadata_ports_are_registered_as_pins_for_allowed_pdk_roots(self): + from mxpic_router.builder import _metadata_pins + + metadata = {"ports": {"a1": {"x": 0, "y": 0, "a": 180}}} + + self.assertEqual( + _metadata_pins(metadata, r"D:\mxpic\opt_pdk_public\foundries"), + metadata["ports"], + ) + self.assertEqual(_metadata_pins(metadata, r"D:\mxpic\some_project_layout"), {}) + + +if __name__ == "__main__": + unittest.main()