354 lines
15 KiB
Python
354 lines
15 KiB
Python
# -----------------------------------------------------------------------------
|
|
# Description: Backend integration wrapper for project GDS generation and build result handling.
|
|
# Inside functions: build_project_gds, _build_with_mxpic_router, _load_project_cells, _ordered_cell_names, _cells_have_links, _cells_have_elements, _build_with_gdstk, _import_public_gds, _build_with_nazca, _build_nazca_element_cells, _build_nazca_element_cell, _element_port_offset, _safe_cell_name, _library_cell_by_name, _int, _number
|
|
# Developer : Qin Yue @ 2026
|
|
# Organization : OptiHK Limited
|
|
# -----------------------------------------------------------------------------
|
|
import math
|
|
import os
|
|
import sys
|
|
from dataclasses import dataclass, field
|
|
from typing import Dict, List
|
|
|
|
import yaml
|
|
|
|
from pdk_registry import PdkRegistry
|
|
|
|
|
|
@dataclass
|
|
class BuildResult:
|
|
"""Container for GDS build output paths, status details, and engine metadata."""
|
|
output_path: str
|
|
engine: str
|
|
cells_built: List[str] = field(default_factory=list)
|
|
warnings: List[str] = field(default_factory=list)
|
|
|
|
|
|
def build_project_gds(
|
|
project_dir: str,
|
|
output_path: str,
|
|
pdk_public_root: str,
|
|
technology_manifest_path: str = None,
|
|
prefer_full_gds: bool = False,
|
|
) -> BuildResult:
|
|
"""Build a hierarchical project GDS from saved cell YAML files."""
|
|
cells = _load_project_cells(project_dir)
|
|
if not cells:
|
|
raise ValueError("No saved cell YAML files found for this project")
|
|
|
|
# Prefer the routed builder whenever it is available because it understands
|
|
# bundle links, anchor connections, and PDK-aware routing rules.
|
|
try:
|
|
return _build_with_mxpic_router(
|
|
project_dir,
|
|
output_path,
|
|
pdk_public_root,
|
|
technology_manifest_path,
|
|
prefer_full_gds,
|
|
)
|
|
except ImportError as router_error:
|
|
if _cells_have_links(cells):
|
|
raise RuntimeError(
|
|
"Routed Build GDS requires mxpic_router, nazca, and mxpic_forge when layout links are present. "
|
|
f"Router import failed: {router_error}"
|
|
) from router_error
|
|
|
|
# Placement-only projects can still be exported with local GDS engines when
|
|
# the routed builder is not installed.
|
|
registry = PdkRegistry(pdk_public_root, prefer_full_gds=prefer_full_gds)
|
|
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
|
|
|
# Port/Anchor elements are logical pin-bearing devices. Use Nazca for these
|
|
# layouts so each element can be a placed cell with local nd.Pin metadata.
|
|
if _cells_have_elements(cells):
|
|
try:
|
|
return _build_with_nazca(cells, output_path, registry)
|
|
except ImportError as nazca_error:
|
|
raise RuntimeError(
|
|
"Build GDS with Port/Anchor elements requires Nazca so element cells can preserve nd.Pin metadata. "
|
|
f"Nazca import failed: {nazca_error}"
|
|
) from nazca_error
|
|
|
|
# gdstk is the preferred fallback for placement-only projects without
|
|
# Port/Anchor element pin metadata; Nazca remains a secondary fallback for
|
|
# environments where gdstk is not installed.
|
|
try:
|
|
return _build_with_gdstk(cells, output_path, registry)
|
|
except ImportError as gdstk_error:
|
|
try:
|
|
return _build_with_nazca(cells, output_path, registry)
|
|
except ImportError as nazca_error:
|
|
raise RuntimeError(
|
|
"Build GDS requires either gdstk or a working nazca installation. "
|
|
f"gdstk import failed: {gdstk_error}. nazca import failed: {nazca_error}"
|
|
) from nazca_error
|
|
|
|
|
|
def _build_with_mxpic_router(
|
|
project_dir: str,
|
|
output_path: str,
|
|
pdk_root: str,
|
|
technology_manifest_path: str,
|
|
prefer_full_gds: bool,
|
|
) -> BuildResult:
|
|
"""Delegate routed project GDS generation to the external mxpic_router package."""
|
|
# mxpic_router lives beside this repository during local development, so add
|
|
# that sibling checkout to sys.path only when it exists.
|
|
router_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "mxpic_router"))
|
|
if os.path.isdir(router_root) and router_root not in sys.path:
|
|
sys.path.insert(0, router_root)
|
|
from mxpic_router import build_project_gds as build_routed_project_gds
|
|
|
|
result = build_routed_project_gds(
|
|
project_dir=project_dir,
|
|
output_path=output_path,
|
|
pdk_root=pdk_root,
|
|
technology_manifest_path=technology_manifest_path,
|
|
prefer_full_gds=prefer_full_gds,
|
|
)
|
|
return BuildResult(
|
|
output_path=result.get("output_path", output_path),
|
|
engine=result.get("engine", "mxpic_router"),
|
|
cells_built=result.get("cells_built", []),
|
|
warnings=result.get("warnings", []),
|
|
)
|
|
|
|
|
|
def _load_project_cells(project_dir: str) -> Dict[str, dict]:
|
|
"""Load saved cell YAML documents from a project directory."""
|
|
cells = {}
|
|
for filename in sorted(os.listdir(project_dir)):
|
|
if not filename.lower().endswith((".yml", ".yaml")):
|
|
continue
|
|
path = os.path.join(project_dir, filename)
|
|
with open(path, "r", encoding="utf-8") as file:
|
|
data = yaml.safe_load(file) or {}
|
|
cell_name = str(data.get("name") or os.path.splitext(filename)[0])
|
|
cells[cell_name] = data
|
|
return cells
|
|
|
|
|
|
def _ordered_cell_names(cells: Dict[str, dict]) -> List[str]:
|
|
"""Order cells so dependencies are built before cells that reference them."""
|
|
composites = [name for name, data in cells.items() if data.get("type") != "project"]
|
|
projects = [name for name, data in cells.items() if data.get("type") == "project"]
|
|
return composites + projects
|
|
|
|
|
|
def _cells_have_links(cells: Dict[str, dict]) -> bool:
|
|
"""Detect whether any saved cell contains bundle links that require routed building."""
|
|
for data in cells.values():
|
|
for bundle in (data.get("bundles") or {}).values():
|
|
if bundle.get("links"):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _cells_have_elements(cells: Dict[str, dict]) -> bool:
|
|
"""Detect whether any saved cell contains Port/Anchor element objects."""
|
|
for data in cells.values():
|
|
elements = data.get("elements") or {}
|
|
for element in elements.values():
|
|
if str((element or {}).get("type") or "").lower() in {"port", "anchor"}:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _build_with_gdstk(cells: Dict[str, dict], output_path: str, registry: PdkRegistry) -> BuildResult:
|
|
"""Assemble a project GDS with gdstk when Nazca or routed building is unavailable."""
|
|
import gdstk
|
|
|
|
library = gdstk.Library()
|
|
built_cells = {}
|
|
warnings = []
|
|
|
|
# Build composite cells before project cells so project-level references can
|
|
# reuse cells already inserted into the same GDS library.
|
|
for cell_name in _ordered_cell_names(cells):
|
|
data = cells[cell_name]
|
|
gds_cell = library.new_cell(_safe_cell_name(cell_name, built_cells))
|
|
built_cells[cell_name] = gds_cell
|
|
for instance_name, instance in (data.get("instances") or {}).items():
|
|
component = str(instance.get("component") or "")
|
|
x = _number(instance.get("x"))
|
|
y = _number(instance.get("y"))
|
|
rotation = math.radians(_number(instance.get("rotation")))
|
|
child = built_cells.get(component)
|
|
if child is None:
|
|
# External components are resolved through the active PDK
|
|
# registry and imported as references.
|
|
asset = registry.resolve(component)
|
|
if not asset.gds_path:
|
|
warnings.append(f"Missing GDS for {instance_name}: {component}")
|
|
continue
|
|
child = _import_public_gds(gdstk, library, asset.gds_path)
|
|
gds_cell.add(gdstk.Reference(child, origin=(x, y), rotation=rotation))
|
|
|
|
library.write_gds(output_path)
|
|
return BuildResult(
|
|
output_path=output_path,
|
|
engine="gdstk",
|
|
cells_built=list(built_cells.keys()),
|
|
warnings=warnings,
|
|
)
|
|
|
|
|
|
def _import_public_gds(gdstk, library, gds_path: str):
|
|
"""Import public PDK GDS geometry into the output library."""
|
|
source = gdstk.read_gds(gds_path)
|
|
top_cells = source.top_level()
|
|
if not top_cells:
|
|
raise ValueError(f"No top-level cell found in {gds_path}")
|
|
# Avoid adding duplicate cell names when multiple instances reference the
|
|
# same imported PDK component.
|
|
for source_cell in source.cells:
|
|
if _library_cell_by_name(library, source_cell.name) is None:
|
|
library.add(source_cell)
|
|
return top_cells[0]
|
|
|
|
|
|
def _build_with_nazca(cells: Dict[str, dict], output_path: str, registry: PdkRegistry) -> BuildResult:
|
|
"""Assemble a project GDS with Nazca cells and PDK component placements."""
|
|
import nazca as nd
|
|
|
|
warnings = []
|
|
built_cells = {}
|
|
ordered_names = _ordered_cell_names(cells)
|
|
# Nazca cells are built in dependency order and then the final project cell
|
|
# is exported as the top-level GDS.
|
|
for cell_name in ordered_names:
|
|
data = cells[cell_name]
|
|
element_cells = _build_nazca_element_cells(nd, cell_name, data.get("elements") or {}, built_cells)
|
|
with nd.Cell(cell_name) as current_cell:
|
|
for instance_name, instance in (data.get("instances") or {}).items():
|
|
component = str(instance.get("component") or "")
|
|
x = _number(instance.get("x"))
|
|
y = _number(instance.get("y"))
|
|
rotation = _number(instance.get("rotation"))
|
|
if component in built_cells:
|
|
built_cells[component].put(x, y, rotation)
|
|
continue
|
|
asset = registry.resolve(component)
|
|
if not asset.gds_path:
|
|
warnings.append(f"Missing GDS for {instance_name}: {component}")
|
|
continue
|
|
loaded = nd.load_gds(asset.gds_path)
|
|
loaded.put(x, y, rotation)
|
|
for element_name, element_cell in element_cells.items():
|
|
element = (data.get("elements") or {}).get(element_name) or {}
|
|
x = _number(element.get("x"))
|
|
y = _number(element.get("y"))
|
|
rotation = _number(element.get("angle"))
|
|
element_cell.put(x, y, rotation)
|
|
built_cells[cell_name] = current_cell
|
|
|
|
top_name = ordered_names[-1]
|
|
nd.export_gds(built_cells[top_name], filename=output_path)
|
|
return BuildResult(output_path=output_path, engine="nazca", cells_built=ordered_names, warnings=warnings)
|
|
|
|
|
|
def _build_nazca_element_cells(nd, parent_cell_name: str, elements: dict, existing_cells: dict) -> Dict[str, object]:
|
|
"""Build reusable Nazca cells for built-in Port and Anchor element objects."""
|
|
element_cells = {}
|
|
known_cells = dict(existing_cells or {})
|
|
for element_name, element in (elements or {}).items():
|
|
element_type = str((element or {}).get("type") or "").lower()
|
|
if element_type not in {"port", "anchor"}:
|
|
continue
|
|
raw_cell_name = f"{parent_cell_name}_{element_name}_{element_type}"
|
|
cell_name = _safe_cell_name(raw_cell_name, {**known_cells, **element_cells})
|
|
element_cell = _build_nazca_element_cell(nd, cell_name, {**(element or {}), "name": str(element_name)})
|
|
element_cells[str(element_name)] = element_cell
|
|
return element_cells
|
|
|
|
|
|
def _build_nazca_element_cell(nd, cell_name: str, element: dict):
|
|
"""Create one local Port or Anchor cell whose pins are placed in local coordinates."""
|
|
element = element or {}
|
|
element_type = str(element.get("type") or "").lower()
|
|
width = _number(element.get("width"), 0.5)
|
|
port_number = max(1, _int(element.get("pin_number", element.get("pinNumber", element.get("port_number", element.get("portNumber")))), 1))
|
|
pitch = _number(element.get("pitch"), 10.0)
|
|
with nd.Cell(name=cell_name) as element_cell:
|
|
if element_type == "port":
|
|
for index, pin_name in enumerate(_element_pin_names(cell_name, element, port_number, "port")):
|
|
y = 0.0 if port_number == 1 else _element_port_offset(index, port_number, pitch)
|
|
nd.Pin(pin_name, width=width).put(0.0, y, 180.0)
|
|
return element_cell
|
|
|
|
anchor_pin_names = _element_pin_names(cell_name, element, port_number, "anchor")
|
|
for index in range(port_number):
|
|
y = -15.0 + _element_port_offset(index, port_number, pitch)
|
|
nd.Pin(anchor_pin_names[index * 2], width=width).put(0.0, y, 180.0)
|
|
nd.Pin(anchor_pin_names[index * 2 + 1], width=width).put(0.0, y, 0.0)
|
|
return element_cell
|
|
|
|
|
|
def _element_port_offset(index: int, count: int, pitch: float) -> float:
|
|
"""Return the local y offset for one repeated Port/Anchor pin."""
|
|
return ((max(1, count) - 1) / 2 - index) * pitch
|
|
|
|
|
|
def _element_pin_names(cell_name: str, element: dict, count: int, element_type: str) -> List[str]:
|
|
"""Return local element pin names, using YAML overrides when present."""
|
|
raw_pins = [pin for pin in (element.get("pins") or []) if isinstance(pin, dict)]
|
|
default_base = _safe_pin_name(str(element.get("name") or cell_name).replace("_port", "").replace("_anchor", ""))
|
|
roles = []
|
|
if element_type == "port":
|
|
roles = [f"io{index + 1}" for index in range(count)]
|
|
else:
|
|
for index in range(count):
|
|
roles.extend([f"a{index + 1}", f"b{index + 1}"])
|
|
by_role = {str(pin.get("role") or ""): str(pin.get("name") or "") for pin in raw_pins}
|
|
by_index = [str(pin.get("name") or "") for pin in raw_pins]
|
|
names = []
|
|
for index, role in enumerate(roles):
|
|
name = by_role.get(role) or (by_index[index] if index < len(by_index) else "") or f"{default_base}_{role}"
|
|
names.append(_safe_pin_name(name))
|
|
return names
|
|
|
|
|
|
def _safe_pin_name(name: str) -> str:
|
|
"""Generate a backend-safe pin name for local Nazca element pins."""
|
|
return "".join(ch if ch.isalnum() or ch == "_" else "_" for ch in str(name or "pin"))
|
|
|
|
|
|
def _safe_cell_name(name: str, existing: dict) -> str:
|
|
"""Generate a backend-safe unique cell name for GDS/Nazca libraries."""
|
|
base = "".join(ch if ch.isalnum() or ch in "._$" else "_" for ch in str(name)) or "cell"
|
|
candidate = base
|
|
counter = 1
|
|
used = {cell.name for cell in existing.values()}
|
|
while candidate in used:
|
|
counter += 1
|
|
candidate = f"{base}_{counter}"
|
|
return candidate
|
|
|
|
|
|
def _library_cell_by_name(library, name: str):
|
|
"""Find a cell object in a loaded layout library by name."""
|
|
for cell in library.cells:
|
|
if cell.name == name:
|
|
return cell
|
|
return None
|
|
|
|
|
|
def _int(value, default=0) -> int:
|
|
"""Convert integer YAML values with a stable default."""
|
|
try:
|
|
if value is None or value == "":
|
|
return default
|
|
return int(float(value))
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
def _number(value, default=0.0) -> float:
|
|
"""Convert numeric YAML values to floats with a stable default."""
|
|
try:
|
|
if value is None or value == "":
|
|
return default
|
|
return float(value)
|
|
except (TypeError, ValueError):
|
|
return default
|