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

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.simulation.aedt_base import ( 

12 AEDTBase, 

13 export_component_to_gds_temp, 

14 layer_stack_to_gds_mapping, 

15 rename_imported_objects, 

16) 

17 

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 

23 

24 

25class LumpedPortConfig(TypedDict): 

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

27 

28 origin: list[float] 

29 sizes: list[float] 

30 integration_line: list[list[float]] 

31 

32 

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. 

40 

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. 

46 

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

52 

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

57 

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) 

60 

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

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

63 

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

66 

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

68 

69 

70class HFSS(AEDTBase): 

71 """HFSS simulation wrapper. 

72 

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

74 setting up simulation regions, and extracting results. 

75 """ 

76 

77 def __init__(self, hfss: Hfss): 

78 """Initialize the HFSS wrapper. 

79 

80 Args: 

81 hfss: The PyAEDT Hfss application instance. 

82 """ 

83 super().__init__(hfss) 

84 self.hfss = hfss 

85 

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. 

96 

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. 

107 

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 ) 

115 

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) 

121 

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 ) 

128 

129 if result: 

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

131 from qpdk import LAYER_STACK 

132 

133 renamed_objects = rename_imported_objects( 

134 self.hfss, new_objects, layer_stack or LAYER_STACK 

135 ) 

136 

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

144 

145 return result 

146 

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. 

149 

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 ) 

168 

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. 

178 

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. 

185 

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 

192 

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 

200 

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 

207 

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

209 """Extract eigenmode simulation results. 

210 

211 Args: 

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

213 

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 ) 

226 

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

228 

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) 

236 

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) 

244 

245 return results 

246 

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. 

251 

252 Args: 

253 setup_name: Name of the setup. 

254 sweep_name: Name of the frequency sweep. 

255 

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

262 

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) 

271 

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 

276 

277 return pl.DataFrame(data)