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

1"""HFSS simulation utilities using PyAEDT.""" 

2 

3from __future__ import annotations 

4 

5from pathlib import Path 

6from typing import TYPE_CHECKING, TypedDict 

7 

8import numpy as np 

9import polars as pl 

10 

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) 

18 

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 

24 

25 

26class LumpedPortConfig(TypedDict): 

27 """Configuration for defining a lumped port rectangle in HFSS.""" 

28 

29 origin: list[float] 

30 sizes: list[float] 

31 integration_line: list[list[float]] 

32 

33 

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. 

41 

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. 

47 

48 Returns: 

49 A dictionary containing 'origin', 'sizes', and 'integration_line' for HFSS. 

50 

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}°") 

56 

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)) 

61 

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) 

64 

65 rect_cx = cx + (cpw_gap / 2) * c 

66 rect_cy = cy + (cpw_gap / 2) * s 

67 

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]] 

70 

71 return {"origin": origin, "sizes": [size_x, size_y], "integration_line": int_line} 

72 

73 

74class HFSS(AEDTBase): 

75 """HFSS simulation wrapper. 

76 

77 Provides high-level methods for importing components into HFSS, 

78 setting up simulation regions, and extracting results. 

79 """ 

80 

81 def __init__(self, hfss: Hfss): 

82 """Initialize the HFSS wrapper. 

83 

84 Args: 

85 hfss: The PyAEDT Hfss application instance. 

86 """ 

87 super().__init__(hfss) 

88 self.hfss = hfss 

89 

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. 

100 

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. 

111 

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 ) 

119 

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) 

125 

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 ) 

132 

133 if result: 

134 new_objects = list(set(self.modeler.object_names) - existing_objects) 

135 

136 renamed_objects = rename_imported_objects( 

137 self.hfss, new_objects, layer_stack or LAYER_STACK 

138 ) 

139 

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") 

147 

148 return result 

149 

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. 

152 

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 ) 

171 

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. 

181 

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. 

188 

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 

195 

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 

203 

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 

210 

211 def get_eigenmode_results(self, setup_name: str = "EigenmodeSetup") -> dict: 

212 """Extract eigenmode simulation results. 

213 

214 Args: 

215 setup_name: Name of the setup to get results from. 

216 

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 ) 

229 

230 results = {"frequencies": [], "q_factors": [], "setup": setup_name} 

231 

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) 

239 

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) 

247 

248 return results 

249 

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. 

254 

255 Args: 

256 setup_name: Name of the setup. 

257 sweep_name: Name of the frequency sweep. 

258 

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 = {} 

265 

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) 

274 

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 

279 

280 return pl.DataFrame(data)