Coverage for qpdk / simulation / q3d.py: 16%
113 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-02 17:50 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-02 17:50 +0000
1"""Q3D and Q2D simulation utilities using PyAEDT."""
3from __future__ import annotations
5import math
6from pathlib import Path
7from typing import TYPE_CHECKING, cast
9import gdsfactory as gf
10import polars as pl
12from qpdk import LAYER_STACK
13from qpdk.models.cpw import get_cpw_dimensions
14from qpdk.simulation.aedt_base import (
15 AEDTBase,
16 export_component_to_gds_temp,
17 layer_stack_to_gds_mapping,
18 rename_imported_objects,
19)
21if TYPE_CHECKING:
22 from ansys.aedt.core import Q2d
23 from ansys.aedt.core.q3d import Q3d
24 from gdsfactory.component import Component
25 from gdsfactory.technology import LayerStack
26 from gdsfactory.typings import CrossSectionSpec, Ports
29class Q3D(AEDTBase):
30 """Q3D Extractor simulation wrapper.
32 Provides methods for importing components into Q3D and performing
33 parasitic capacitance/inductance extraction.
34 """
36 def __init__(self, q3d: Q3d):
37 """Initialize the Q3D wrapper.
39 Args:
40 q3d: The PyAEDT Q3d application instance.
41 """
42 super().__init__(q3d)
43 self.q3d = q3d
45 def import_component(
46 self,
47 component: Component,
48 layer_stack: LayerStack | None = None,
49 *,
50 units: str = "um",
51 gds_path: str | Path | None = None,
52 ) -> list[str]:
53 """Import a gdsfactory component into Q3D Extractor.
55 Imports the component's GDS geometry into a Q3D Extractor project,
56 mapping each GDS layer to a 3D conductor at the appropriate elevation
57 and thickness from the layer stack.
59 Args:
60 component: The gdsfactory component to import.
61 layer_stack: LayerStack defining thickness and elevation for each layer.
62 If None, uses QPDK's default LAYER_STACK.
63 units: Length units for the geometry (default: "um" for micrometers).
64 gds_path: Optional path to write the GDS file. If None, uses a temporary file.
66 Returns:
67 List of newly created conductor object names in Q3D.
69 Raises:
70 RuntimeError: If GDS import fails.
71 """
72 mapping_layers = layer_stack_to_gds_mapping(layer_stack)
74 with export_component_to_gds_temp(
75 component, gds_path, prefix="qpdk_q3d_"
76 ) as path:
77 self.modeler.model_units = units
78 existing_objects = set(self.modeler.object_names)
80 result = self.q3d.import_gds_3d(
81 input_file=str(path),
82 mapping_layers=mapping_layers,
83 units=units,
84 import_method=0,
85 )
86 if not result:
87 raise RuntimeError("Q3D GDS import failed")
89 new_objects = list(set(self.modeler.object_names) - existing_objects)
91 renamed_objects = rename_imported_objects(
92 self.q3d, new_objects, layer_stack or LAYER_STACK
93 )
95 if renamed_objects:
96 self.q3d.assign_material(renamed_objects, "pec")
98 return renamed_objects
100 def assign_nets_from_ports(
101 self,
102 ports: Ports,
103 conductor_objects: list[str],
104 ) -> list[str]:
105 """Assign Q3D signal nets based on gdsfactory port locations.
107 For each gdsfactory port, finds the conductor object whose bounding-box
108 center is nearest to the port center and assigns it as a Q3D signal net.
110 Args:
111 ports: Collection of gdsfactory ports defining signal locations.
112 conductor_objects: List of conductor object names created by
113 :meth:`import_component`.
115 Returns:
116 List of assigned signal net names (one per port).
117 """
118 self.q3d.auto_identify_nets()
120 assigned_nets: list[str] = []
121 used_objects: set[str] = set()
123 if not ports or not conductor_objects:
124 return assigned_nets
126 bboxes = {}
127 for obj_name in conductor_objects:
128 obj = self.modeler.get_object_from_name(obj_name)
129 if obj:
130 bboxes[obj_name] = obj.bounding_box
132 if not bboxes:
133 return assigned_nets
135 first_port = next(iter(ports))
136 px0, py0 = float(first_port.center[0]), float(first_port.center[1])
138 def dist_to_bbox(
139 px: float, py: float, bbox: list[float], s: float = 1.0
140 ) -> float:
141 dx = max(bbox[0] * s - px, 0, px - bbox[3] * s)
142 dy = max(bbox[1] * s - py, 0, py - bbox[4] * s)
143 return math.hypot(dx, dy)
145 scale_factor = min(
146 (10**p for p in range(-3, 5)),
147 key=lambda s: min(dist_to_bbox(px0, py0, b, s) for b in bboxes.values()),
148 )
150 for port in ports:
151 px, py = float(port.center[0]), float(port.center[1])
152 available_objs = [obj for obj in bboxes if obj not in used_objects]
153 if not available_objs:
154 break
156 def port_metric(
157 obj_name: str, px: float = px, py: float = py
158 ) -> tuple[float, float]:
159 b = bboxes[obj_name]
160 dist = dist_to_bbox(px, py, b, scale_factor)
161 area = (b[3] - b[0]) * (b[4] - b[1]) * scale_factor**2
162 return max(0.0, dist - 1.0), area
164 best_obj = min(available_objs, key=port_metric)
165 net_to_rename = next(
166 (
167 b
168 for b in self.q3d.boundaries
169 if b.type == "SignalNet" and best_obj in b.props.get("Objects", [])
170 ),
171 None,
172 )
174 if net_to_rename is not None:
175 net_to_rename.name = port.name
176 else:
177 self.q3d.assign_net(
178 assignment=[best_obj], net_name=port.name, net_type="Signal"
179 )
181 assigned_nets.append(port.name)
182 used_objects.add(best_obj)
184 return assigned_nets
186 def get_capacitance_matrix(self, setup_name: str = "Q3DSetup") -> pl.DataFrame:
187 """Extract the capacitance matrix from a Q3D Extractor simulation.
189 Retrieves all capacitance matrix entries (e.g. ``C(o1,o1)``, ``C(o1,o2)``)
190 from the solved Q3D setup.
192 Args:
193 setup_name: Name of the analysis setup.
195 Returns:
196 DataFrame with one column per capacitance expression containing
197 the extracted values in Farads.
198 """
199 nets = [b.name for b in self.q3d.boundaries if b.type == "SignalNet"]
200 expressions = [f"C({n1},{n2})" for i, n1 in enumerate(nets) for n2 in nets[i:]]
201 data: dict[str, list[float]] = {}
203 for expr in expressions:
204 solution = self.q3d.post.get_solution_data(
205 expressions=expr,
206 setup_sweep_name=f"{setup_name} : LastAdaptive",
207 )
208 if solution:
209 val = float(solution.data_real()[0])
210 unit = solution.units_data.get(expr, "pF")
211 multiplier = {
212 "fF": 1e-15,
213 "pF": 1e-12,
214 "nF": 1e-9,
215 "uF": 1e-6,
216 "mF": 1e-3,
217 "F": 1.0,
218 }.get(str(unit), 1e-12)
219 data[expr] = [val * multiplier]
221 return pl.DataFrame(data)
224class Q2D(AEDTBase):
225 """Q2D simulation wrapper.
227 Provides methods for 2D cross-sectional impedance extraction.
228 """
230 def __init__(self, q2d: Q2d):
231 """Initialize the Q2D wrapper.
233 Args:
234 q2d: The PyAEDT Q2d application instance.
235 """
236 super().__init__(q2d)
237 self.q2d = q2d
239 def create_2d_from_cross_section(
240 self,
241 cross_section: CrossSectionSpec,
242 layer_stack: LayerStack | None = None,
243 *,
244 ground_width: float | None = None,
245 units: str = "um",
246 ) -> dict[str, str]:
247 """Create a 2D model from a CPW cross-section for impedance extraction.
249 Builds the cross-sectional geometry of a coplanar waveguide in Ansys Q2D
250 (2D Extractor).
252 Args:
253 cross_section: A gdsfactory cross-section specification describing the CPW
254 geometry (width and gap).
255 layer_stack: LayerStack defining substrate and conductor properties.
256 If None, uses QPDK's default ``LAYER_STACK``.
257 ground_width: Width of each coplanar ground plane in µm. If None,
258 defaults to 10× the CPW gap.
259 units: Length units for the Q2D geometry (default ``"um"``).
261 Returns:
262 Dictionary with keys ``"signal"``, ``"gnd_left"``, ``"gnd_right"``,
263 ``"substrate"`` mapping to the created Q2D object names.
265 Raises:
266 ValueError: If cross-section mapping fails or dimensions are invalid.
267 """
268 if layer_stack is None:
269 layer_stack = LAYER_STACK
271 if units != "um":
272 raise ValueError("Q2D cross-section expects units='um'")
274 cpw_width, cpw_gap = get_cpw_dimensions(cross_section)
275 substrate_level = layer_stack.layers["Substrate"]
276 substrate_thickness = float(substrate_level.thickness)
277 substrate_material = cast(str, substrate_level.material)
279 conductor_level = layer_stack.layers["M1"]
280 conductor_thickness = float(conductor_level.thickness)
281 if conductor_thickness < 2.0:
282 gf.logger.warning(
283 "Setting conductor_thickness to 2.0 um for Q2D stability."
284 )
285 conductor_thickness = 2.0
286 conductor_material = cast(str, conductor_level.material)
288 if ground_width is None:
289 ground_width = 10.0 * cpw_gap
291 self.add_materials()
292 self.modeler.model_units = units
294 total_width = 2 * ground_width + 2 * cpw_gap + cpw_width
295 substrate_margin = 50.0
297 parts = [
298 {
299 "name": "signal",
300 "origin": [ground_width + cpw_gap, 0, 0],
301 "sizes": [cpw_width, conductor_thickness],
302 "material": conductor_material,
303 },
304 {
305 "name": "gnd_left",
306 "origin": [0, 0, 0],
307 "sizes": [ground_width, conductor_thickness],
308 "material": conductor_material,
309 },
310 {
311 "name": "gnd_right",
312 "origin": [ground_width + cpw_gap + cpw_width + cpw_gap, 0, 0],
313 "sizes": [ground_width, conductor_thickness],
314 "material": conductor_material,
315 },
316 {
317 "name": "substrate",
318 "origin": [-substrate_margin, -substrate_thickness, 0],
319 "sizes": [total_width + 2 * substrate_margin, substrate_thickness],
320 "material": substrate_material,
321 },
322 ]
324 objects = {
325 part["name"]: self.modeler.create_rectangle(**part) for part in parts
326 }
328 self.q2d.assign_single_conductor(
329 name="signal",
330 assignment=[objects["signal"]],
331 conductor_type="SignalLine",
332 units=units,
333 )
334 self.q2d.assign_single_conductor(
335 name="gnd",
336 assignment=[objects["gnd_left"], objects["gnd_right"]],
337 conductor_type="ReferenceGround",
338 units=units,
339 )
341 self.app.mesh.assign_length_mesh(
342 assignment=[objects["signal"], objects["gnd_left"], objects["gnd_right"]],
343 maximum_length=2.0,
344 maximum_elements=10000,
345 name="thin_trace_mesh",
346 )
348 return {str(name): obj.name for name, obj in objects.items()}