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