From d0b6ac018a3d994d2315363e82d957a7f4bb1e33 Mon Sep 17 00:00:00 2001 From: PotatoMaxwell Date: Mon, 1 Jun 2026 11:34:00 +0800 Subject: [PATCH] Description file added --- README.md | 519 +++++++++++++++- mxpic_router/__init__.py | 23 + .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 516 bytes .../__pycache__/__init__.cpython-39.pyc | Bin 0 -> 468 bytes .../__pycache__/builder.cpython-314.pyc | Bin 0 -> 35729 bytes .../__pycache__/builder.cpython-39.pyc | Bin 0 -> 20013 bytes .../__pycache__/eda_loader.cpython-314.pyc | Bin 0 -> 14391 bytes .../__pycache__/eda_loader.cpython-39.pyc | Bin 0 -> 7019 bytes .../__pycache__/technology.cpython-314.pyc | Bin 0 -> 2673 bytes .../__pycache__/technology.cpython-39.pyc | Bin 0 -> 1242 bytes mxpic_router/builder.py | 587 ++++++++++++++++++ mxpic_router/eda_loader.py | 227 +++++++ mxpic_router/technology.py | 38 ++ .../test_eda_router_contract.cpython-39.pyc | Bin 0 -> 10373 bytes tests/test_eda_router_contract.py | 226 +++++++ 15 files changed, 1618 insertions(+), 2 deletions(-) create mode 100644 mxpic_router/__init__.py create mode 100644 mxpic_router/__pycache__/__init__.cpython-314.pyc create mode 100644 mxpic_router/__pycache__/__init__.cpython-39.pyc create mode 100644 mxpic_router/__pycache__/builder.cpython-314.pyc create mode 100644 mxpic_router/__pycache__/builder.cpython-39.pyc create mode 100644 mxpic_router/__pycache__/eda_loader.cpython-314.pyc create mode 100644 mxpic_router/__pycache__/eda_loader.cpython-39.pyc create mode 100644 mxpic_router/__pycache__/technology.cpython-314.pyc create mode 100644 mxpic_router/__pycache__/technology.cpython-39.pyc create mode 100644 mxpic_router/builder.py create mode 100644 mxpic_router/eda_loader.py create mode 100644 mxpic_router/technology.py create mode 100644 tests/__pycache__/test_eda_router_contract.cpython-39.pyc create mode 100644 tests/test_eda_router_contract.py 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 0000000000000000000000000000000000000000..28e36a4188163f6c231207720c57ef9920f13d16 GIT binary patch literal 516 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 literal 0 HcmV?d00001 diff --git a/mxpic_router/__pycache__/builder.cpython-39.pyc b/mxpic_router/__pycache__/builder.cpython-39.pyc new file mode 100644 index 0000000000000000000000000000000000000000..24ffc3d492684bfd9e2faf8e6ffc20b33afe4b1b GIT binary patch literal 20013 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
`J+IEnJjryE9JD%!yllvwd@~OJ&XJvOTG?J&XG! zXZv#lZ`CJHtXPT5d&@X$obxZ#NzTT!vnAzhS#*EyY?_qEqgPrvY8>t zRfq5Mw=T|JT<}TWZE0^u%G7Vak zv`JNa(^XxmDrm7(e(1T&v;+HRyJ`D)WNrs^{)uths+QFCax3MeF?hf4Zr|cXsbPP* z;Yh0C$fHoYzIUmI6fTDbbq_>b1Vw>}ztd`{ZcFI5akp4XPW z)%5$@CGXC(w>{-;f9QVhF}GsJ?KkZoC*}@G{@@eicIbG+DQ=~bG;P0s;qC<(y2j3Q z>CF9m$W&C#UAz;X4=-Mps=LzF1F7nP$5yH0 zwT!23;o$uvcaJ>ml6EuRbAshO{Vj#qS~D=wre zEpS|@T-~G4mO8)S4-?G*H(ZJ1tw6$i*T6531Tv40 z`wuTj&4(ZBvCWeIbf$XiLdE^+yVZ-|k!la8YtN->&pmERcb{A8J||TVX8c>`Zrpi) z{{6)ZQq5k;zwf!%Qfq%fEH(!m*;IC|Y$c5?A6ai&X9iJHpBQUbcaz3GZl!}*oymU1 zcbm0}V0Fa(0LGtXfZ(|WGw&kX(ca5H7+`kGq&7qbwRk`iDaSm6+B5kzLOMPna+zax znBw6&-_s6f9fP@p0N86nX-)QvErnk^%jH?0@CCY;$$5pFUN+z@Lb&}X^Qfu>RB*EHt>O4o6z(fTM@3mD#I9Tw6MA4Y zHKFcN)Xm!v7m$1czXV!WN%Ez^j>_ZYPp#9sWs7~L@z1)JD{Jnw%(pCzEbf*nci!xu zhBGMt%)yj(%a;brN1~!G4$K^w+wDP!BR#g%+5=YMA- zz{A(*9HP1O5@%14OL_9no)kNQ9v5)bB-!Ys;IE;0_N0L02Dnu?8XMs59bcjEzdNqP zb1h{b#8%Rlq5)KR?8MMj8cU3u#R|nk2y9<&{qDbW7#WIE9Gd7x{$ zE5-Le4g$&bjm<(vnzb2TKeFVvt_fcY*KL_%k63Ia*8Y;(YsCYS$K z)lAjgo}W}r>oSgtxyE~CX-CuNj;1fHWf^N=uIJ9F`BMucKRYd1Tc)|d zCnm_hY_y}{G?SP+HkVj9mhv_Ir?DAM@7-HgoW$y8OOEPbghu53K?Lqk5$sVT9Zxtr z?u5lp`Ll>Y0BcTxoX@HQgz#x)`Q-!=DgZ=(0592~-al84U{&FyBZ%Z5;FmzJ6O4hu z`u+FkbPMj=mSu-;+McnvW*Q|+ZHlkWp_X9}+f7m1K+6{Y=|=w1JPMfbQ^*K1gnipc zl>Q0$3EVK^fvJ}BuOj*Q1byY20et%KKxs361cAb}Z++2-)G5PS%EQLakxB^)2^|+~0j#11n3&t1w4wTQL-n$w?4x&YzWd?(X-D0XqfT-(q_%Z0Il5EE z?u^ATqxwMxPiGuuGv_{hFJ%n;E@Sn6{7%YJ_ndH$&*A&PzM_TD?-S_V_`bE0d}OX9 zpLlj@Kjn65gWPF=qZv3spKQtgDOEHfAg+{4m3p|R3YQ<~T~tLi;4&&ikst=>Rei;w zKriH>|3L9WT*XiX37Rof9nz&opaMuIm-Z0-l5~>j5+xhEl)uP1EGz;8WiGJ{1iHa- zUy^NKlIA7S{2O9f(G!j3g+|W>RtS)1T8OUDsGBlO-0l?uL}g&KoHC4DWs$7xxeBB literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6289e4ca8c9ad6f9be19df3c466370ef18c7d834 GIT binary patch literal 7019 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 KI`Ipnv;6~ux(6Hp literal 0 HcmV?d00001 diff --git a/mxpic_router/__pycache__/technology.cpython-39.pyc b/mxpic_router/__pycache__/technology.cpython-39.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c324b41994a3b3e4e79abbd9ee48806972e2284e GIT binary patch literal 1242 zcmZuwPj3`A6!-IZW@q*f4G^J_L+*2+S`dc{MW`yM$6P={j6kdD+B=zac4lIGNfvoe z;Tl?1h!cmb#0AbA_!N8qUn^DOD{w%4_GVKm;gO&He(#Te^Zc9-23>*!Zf+fX2nqSy zDc_C&m7j5{+bB5UP?2o4LI#40fYUdW2b{fN8AA4~Py7p3m{L)8LPt1!#5_8dyR* zEXgB6krl)5BmnfiNz|`H0{(l9k|UO~Jqz-AIMb6GKc!$}QI8v5)Hr*hn0g zN-N8nR@9cw^J!%hl~2T|YI2^^l^2_om3T*FA49$j`JE@z$98VFT&~f7*l3#9CAg}=pr@VxFEM2_zknTdpj_P{!aO2{9?S@!Y zgDKqq8ghp9!unwK&R|LIlc)Ee&;?xtCO`{a!h-o7-rkZfLc=(j5&6sEwt)j`DAt>u z0Lt(LCX|OfI>p+e)#dGA-s&RnN6Z^7IvB@H#N$%{Vmj{hf-GtKs&HrjwTMl8er{qC z_5Sd+iA;idlQ+Sl%ey9iM&u{&({+ptV!MR*JpPZzd*^r$@xI5u>d?fzf1R8F`u7nZ zIQ&nLTi!A_w+wa(vWq69B&PcoULwQ8*N4*g#iczPRry>0?gHtsf2XQ(-B<6?GQo9|#u8cEm3GC^SjrKM%O9Bcmsyu&v{+{2Te WO}sm8_^c9l-AyT%Af|D!(fJp+oHlI$ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ec072e41010785a24ecb8fcfc27746ac688c69d0 GIT binary patch literal 10373 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 YkUl}@#jGsi1H!W(u%~}&z3GYn0#xf*dH?_b literal 0 HcmV?d00001 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()