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