Coverage for qpdk / simulation / aedt_base.py: 50%
132 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-14 10:27 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-14 10:27 +0000
1"""Base AEDT simulation utilities using PyAEDT.
3This module provides shared helper functions and a base class for AEDT
4simulations (HFSS, Q3D, Q2D) from gdsfactory components.
5"""
7from __future__ import annotations
9import contextlib
10import re
11import tempfile
12from collections.abc import Generator
13from pathlib import Path
14from typing import TYPE_CHECKING, Any
16import gdsfactory as gf
17from gdsfactory.technology.layer_stack import LayerLevel
19from qpdk import LAYER_STACK
20from qpdk.cells.helpers import (
21 add_margin_to_layer,
22 apply_additive_metals,
23 invert_mask_polarity,
24 remove_metadata_layers,
25)
26from qpdk.tech import LAYER
28if TYPE_CHECKING:
29 from ansys.aedt.core import Hfss, Q2d
30 from ansys.aedt.core.q3d import Q3d
31 from gdsfactory.component import Component
32 from gdsfactory.technology import LayerStack
35def _get_layer_number_from_level(layer_level: LayerLevel) -> int | None:
36 """Extract layer number from a LayerLevel's layer definition."""
37 if hasattr(layer_level, "derived_layer") and layer_level.derived_layer is not None:
38 derived = layer_level.derived_layer
39 if hasattr(derived, "layer"):
40 inner = derived.layer
41 if hasattr(inner, "layer"):
42 val = inner.layer
43 if isinstance(val, tuple) and len(val) >= 1:
44 return int(val[0])
45 return int(val)
46 if isinstance(inner, tuple) and len(inner) >= 1:
47 return int(inner[0])
49 layer = layer_level.layer
50 if isinstance(layer, tuple) and len(layer) >= 1:
51 return int(layer[0])
52 if hasattr(layer, "layer"):
53 inner = layer.layer
54 if isinstance(inner, tuple) and len(inner) >= 1:
55 return int(inner[0])
56 if hasattr(inner, "layer"):
57 val = inner.layer
58 if isinstance(val, tuple) and len(val) >= 1:
59 return int(val[0])
60 return int(val)
61 return int(inner)
62 return None
65def layer_stack_to_gds_mapping(
66 layer_stack: LayerStack | None = None,
67 thickness_override: float | None = None,
68) -> dict[int, tuple[float, float]]:
69 """Convert a LayerStack to HFSS/Q3D GDS import mapping dictionary."""
70 if layer_stack is None:
71 layer_stack = LAYER_STACK
73 mapping: dict[int, tuple[float, float]] = {}
75 for layer_level in layer_stack.layers.values():
76 layer_number = _get_layer_number_from_level(layer_level)
77 if layer_number is None:
78 continue
80 elevation = layer_level.zmin if layer_level.zmin is not None else 0.0
81 thickness = (
82 thickness_override
83 if thickness_override is not None
84 else (layer_level.thickness if layer_level.thickness else 0.0)
85 )
86 mapping[layer_number] = (elevation, thickness)
88 return mapping
91def prepare_component_for_aedt(
92 component: Component,
93 margin_draw: float = 0.0,
94 margin_etch: float = 0.0,
95) -> Component:
96 """Prepare a component for AEDT simulation export."""
97 c = gf.Component(name=f"{component.name}_aedt")
98 c << component.copy()
99 if margin_etch > 0.0:
100 c = add_margin_to_layer(
101 c,
102 layer_margins=[
103 (LAYER.M1_ETCH, margin_etch),
104 (LAYER.M2_ETCH, margin_etch),
105 ],
106 )
107 c = apply_additive_metals(c)
108 c = invert_mask_polarity(c)
109 if margin_draw > 0.0:
110 c = add_margin_to_layer(
111 c,
112 layer_margins=[
113 (LAYER.M1_DRAW, margin_draw),
114 (LAYER.M2_DRAW, margin_draw),
115 ],
116 )
117 c = c.remove_layers(layer for layer in LAYER if str(layer).endswith("_ETCH"))
118 c = remove_metadata_layers(c)
119 c.add_ports(component.ports)
120 return c
123@contextlib.contextmanager
124def export_component_to_gds_temp(
125 component: Component,
126 gds_path: str | Path | None = None,
127 prefix: str = "qpdk_aedt_",
128) -> Generator[Path, None, None]:
129 """Context manager for exporting a component to a temporary GDS file."""
130 if gds_path is not None:
131 path = Path(gds_path)
132 component.write_gds(str(path))
133 yield path
134 else:
135 with tempfile.TemporaryDirectory(prefix=prefix) as temp_dir:
136 path = Path(temp_dir) / "component.gds"
137 component.write_gds(str(path))
138 yield path
141def rename_imported_objects(
142 app: Any, new_objects: list[str], layer_stack: LayerStack
143) -> list[str]:
144 """Rename imported GDS objects based on the layer stack."""
145 num_to_name = {}
146 for name, level in layer_stack.layers.items():
147 layer_num = _get_layer_number_from_level(level)
148 if layer_num is not None and layer_num not in num_to_name:
149 num_to_name[layer_num] = name
151 renamed_objects = []
152 for obj_name in new_objects:
153 match = re.match(r"^signal(\d+)(_.*)?$", obj_name)
154 new_name = obj_name
155 if match:
156 layer_num = int(match.group(1))
157 suffix = match.group(2) or ""
158 if layer_num in num_to_name:
159 layer_name = num_to_name[layer_num]
160 new_name = f"{layer_name}{suffix}"
161 try:
162 app.modeler[obj_name].name = new_name
163 except Exception:
164 new_name = obj_name
165 renamed_objects.append(new_name)
167 return renamed_objects
170def add_materials_to_aedt(app: Hfss | Q2d | Q3d) -> None:
171 """Add QPDK materials to the PyAEDT application."""
172 from qpdk.tech import material_properties
174 for name, props in material_properties.items():
175 if app.materials.exists_material(name):
176 continue
178 mat = app.materials.add_material(name)
180 for prop_name, prop_value in props.items():
181 if prop_value == float("inf"):
182 if prop_name == "relative_permittivity":
183 mat.conductivity = 1e30
184 continue
186 if prop_name == "relative_permittivity":
187 mat.permittivity = prop_value
188 elif prop_name == "conductivity":
189 mat.conductivity = prop_value
192class AEDTBase:
193 """Base class for AEDT simulations."""
195 def __init__(self, app: Hfss | Q2d | Q3d):
196 """Initialize the AEDT base class.
198 Args:
199 app: The PyAEDT application instance.
200 """
201 self.app = app
203 @property
204 def modeler(self):
205 """Return the AEDT modeler instance."""
206 return self.app.modeler
208 def add_materials(self) -> None:
209 """Add QPDK materials to the AEDT project."""
210 add_materials_to_aedt(self.app)
212 def add_substrate(
213 self,
214 component: Component,
215 thickness: float = 500.0,
216 material: str = "silicon",
217 name: str = "Substrate",
218 ) -> str:
219 """Add a substrate box below the component geometry."""
220 bounds = component.bbox()
221 x_min, y_min = bounds.p1.x, bounds.p1.y
222 dx, dy = bounds.p2.x - x_min, bounds.p2.y - y_min
224 substrate = self.modeler.create_box(
225 origin=[x_min, y_min, -thickness],
226 sizes=[dx, dy, thickness],
227 name=name,
228 material=material,
229 )
230 substrate.mesh_order = 4
231 return substrate.name
233 def save(self) -> None:
234 """Save the AEDT project."""
235 self.app.save_project()