Coverage for qpdk / simulation / hfss.py: 37%
90 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"""HFSS simulation utilities using PyAEDT."""
3from __future__ import annotations
5from pathlib import Path
6from typing import TYPE_CHECKING, TypedDict
8import numpy as np
9import polars as pl
11from qpdk import LAYER_STACK
12from qpdk.simulation.aedt_base import (
13 AEDTBase,
14 export_component_to_gds_temp,
15 layer_stack_to_gds_mapping,
16 rename_imported_objects,
17)
19if TYPE_CHECKING:
20 from ansys.aedt.core import Hfss
21 from gdsfactory.component import Component
22 from gdsfactory.technology import LayerStack
23 from gdsfactory.typings import Ports
26class LumpedPortConfig(TypedDict):
27 """Configuration for defining a lumped port rectangle in HFSS."""
29 origin: list[float]
30 sizes: list[float]
31 integration_line: list[list[float]]
34def lumped_port_rectangle_from_cpw(
35 center: tuple[float, float, float],
36 orientation: float,
37 cpw_gap: float,
38 cpw_width: float,
39) -> LumpedPortConfig:
40 """Calculates parameters for a lumped port based on its orientation.
42 Args:
43 center: [x, y, z] coordinates of the port face center.
44 orientation: Angle in degrees (must be a multiple of 90).
45 cpw_gap: The length of the port along the axis of propagation.
46 cpw_width: The width of the port perpendicular to propagation.
48 Returns:
49 A dictionary containing 'origin', 'sizes', and 'integration_line' for HFSS.
51 Raises:
52 ValueError: If port orientation is not a multiple of 90 degrees.
53 """
54 if orientation % 90 != 0:
55 raise ValueError(f"Unsupported port orientation: {orientation}°")
57 cx, cy = center[0], center[1]
58 theta = np.deg2rad(orientation)
59 c = np.round(np.cos(theta))
60 s = np.round(np.sin(theta))
62 size_x = cpw_gap * np.abs(c) + cpw_width * np.abs(s)
63 size_y = cpw_width * np.abs(c) + cpw_gap * np.abs(s)
65 rect_cx = cx + (cpw_gap / 2) * c
66 rect_cy = cy + (cpw_gap / 2) * s
68 origin = [rect_cx - size_x / 2, rect_cy - size_y / 2, 0]
69 int_line = [[cx + cpw_gap * c, cy + cpw_gap * s, 0], [cx, cy, 0]]
71 return {"origin": origin, "sizes": [size_x, size_y], "integration_line": int_line}
74class HFSS(AEDTBase):
75 """HFSS simulation wrapper.
77 Provides high-level methods for importing components into HFSS,
78 setting up simulation regions, and extracting results.
79 """
81 def __init__(self, hfss: Hfss):
82 """Initialize the HFSS wrapper.
84 Args:
85 hfss: The PyAEDT Hfss application instance.
86 """
87 super().__init__(hfss)
88 self.hfss = hfss
90 def import_component(
91 self,
92 component: Component,
93 layer_stack: LayerStack | None = None,
94 *,
95 import_as_sheets: bool = False,
96 units: str = "um",
97 gds_path: str | Path | None = None,
98 ) -> bool:
99 """Import a gdsfactory component into HFSS.
101 Args:
102 component: The gdsfactory component to import.
103 layer_stack: LayerStack defining thickness and elevation for each layer.
104 If None, uses QPDK's default LAYER_STACK.
105 import_as_sheets: If True, imports metals as 2D sheets (zero thickness)
106 and assigns PerfectE boundary to them. If False, imports as 3D
107 objects with thickness from layer_stack and assigns PerfectE
108 boundary to their surfaces.
109 units: Length units for the geometry (default: "um" for micrometers).
110 gds_path: Optional path to write the GDS file. If None, uses a temporary file.
112 Returns:
113 True if import was successful, False otherwise.
114 """
115 thickness_override = 0.0 if import_as_sheets else None
116 mapping_layers = layer_stack_to_gds_mapping(
117 layer_stack, thickness_override=thickness_override
118 )
120 with export_component_to_gds_temp(
121 component, gds_path, prefix="qpdk_hfss_"
122 ) as path:
123 self.modeler.model_units = units
124 existing_objects = set(self.modeler.object_names)
126 result = self.hfss.import_gds_3d(
127 input_file=str(path),
128 mapping_layers=mapping_layers,
129 units=units,
130 import_method=0,
131 )
133 if result:
134 new_objects = list(set(self.modeler.object_names) - existing_objects)
136 renamed_objects = rename_imported_objects(
137 self.hfss, new_objects, layer_stack or LAYER_STACK
138 )
140 if renamed_objects:
141 if import_as_sheets:
142 self.hfss.assign_perfecte_to_sheets(
143 renamed_objects, name="PEC_Sheets"
144 )
145 else:
146 self.hfss.assign_perfect_e(renamed_objects, name="PEC_3D")
148 return result
150 def add_lumped_ports(self, ports: Ports, cpw_gap: float, cpw_width: float) -> None:
151 """Add lumped ports to HFSS at given port locations.
153 Args:
154 ports: Collection of gdsfactory ports defining signal locations.
155 cpw_gap: The length of the port along the axis of propagation.
156 cpw_width: The width of the port perpendicular to propagation.
157 """
158 for port in ports:
159 params = lumped_port_rectangle_from_cpw(
160 port.center, port.orientation, cpw_gap, cpw_width
161 )
162 port_rect = self.modeler.create_rectangle(
163 orientation="XY", name=f"{port.name}_face", **params
164 )
165 self.hfss.lumped_port(
166 assignment=port_rect.name,
167 name=port.name,
168 create_port_sheet=False,
169 integration_line=params["integration_line"],
170 )
172 def add_air_region(
173 self,
174 component: Component,
175 height: float = 500.0,
176 substrate_thickness: float = 500.0,
177 pec_boundary: bool = False,
178 name: str = "AirRegion",
179 ) -> str:
180 """Add an air region (vacuum box) around the component.
182 Args:
183 component: The component to create air region around.
184 height: Height above the component in micrometers.
185 substrate_thickness: Depth below surface for the region.
186 pec_boundary: If True, assign PerfectE boundary conditions to outer faces.
187 name: Name of the created region object.
189 Returns:
190 Name of the created region object.
191 """
192 bounds = component.bbox()
193 x_min, y_min = bounds.p1.x, bounds.p1.y
194 dx, dy = bounds.p2.x - x_min, bounds.p2.y - y_min
196 region = self.modeler.create_box(
197 origin=[x_min, y_min, -substrate_thickness],
198 sizes=[dx, dy, height + substrate_thickness],
199 name=name,
200 material="vacuum",
201 )
202 region.mesh_order = 99
204 if pec_boundary:
205 self.hfss.assign_perfect_e(
206 assignment=[face.id for face in region.faces],
207 name="PEC_Boundary",
208 )
209 return region.name
211 def get_eigenmode_results(self, setup_name: str = "EigenmodeSetup") -> dict:
212 """Extract eigenmode simulation results.
214 Args:
215 setup_name: Name of the setup to get results from.
217 Returns:
218 Dictionary containing:
219 - frequencies: List of eigenmode frequencies in GHz
220 - q_factors: List of Q factors for each mode
221 """
222 # Get frequency values
223 freq_names = self.hfss.post.available_report_quantities(
224 quantities_category="Eigen Modes"
225 )
226 q_names = self.hfss.post.available_report_quantities(
227 quantities_category="Eigen Q"
228 )
230 results = {"frequencies": [], "q_factors": [], "setup": setup_name}
232 for f_name in freq_names:
233 solution = self.hfss.post.get_solution_data(
234 expressions=f_name, report_category="Eigenmode"
235 )
236 if solution:
237 freq_hz = float(solution.data_real()[0])
238 results["frequencies"].append(freq_hz / 1e9)
240 for q_name in q_names:
241 solution = self.hfss.post.get_solution_data(
242 expressions=q_name, report_category="Eigenmode"
243 )
244 if solution:
245 q = float(solution.data_real()[0])
246 results["q_factors"].append(q)
248 return results
250 def get_sparameter_results(
251 self, setup_name: str = "DrivenSetup", sweep_name: str = "FrequencySweep"
252 ) -> pl.DataFrame:
253 """Extract S-parameter results from a driven simulation.
255 Args:
256 setup_name: Name of the setup.
257 sweep_name: Name of the frequency sweep.
259 Returns:
260 DataFrame containing a 'frequency_ghz' column and a column
261 for each S-parameter trace (e.g., "S(1,1)") containing complex values.
262 """
263 traces = self.hfss.get_traces_for_plot()
264 data = {}
266 for trace in traces:
267 solution = self.hfss.post.get_solution_data(
268 expressions=trace,
269 setup_sweep_name=f"{setup_name} : {sweep_name}",
270 )
271 if solution:
272 if "frequency_ghz" not in data:
273 data["frequency_ghz"] = np.array(solution.primary_sweep_values)
275 # Use get_expression_data to get real and imaginary parts
276 _, real_data = solution.get_expression_data(formula="real")
277 _, imag_data = solution.get_expression_data(formula="imag")
278 data[trace] = real_data + 1j * imag_data
280 return pl.DataFrame(data)