Coverage for qpdk / simulation / q3d.py: 16%

113 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-02 17:50 +0000

1"""Q3D and Q2D simulation utilities using PyAEDT.""" 

2 

3from __future__ import annotations 

4 

5import math 

6from pathlib import Path 

7from typing import TYPE_CHECKING, cast 

8 

9import gdsfactory as gf 

10import polars as pl 

11 

12from qpdk import LAYER_STACK 

13from qpdk.models.cpw import get_cpw_dimensions 

14from qpdk.simulation.aedt_base import ( 

15 AEDTBase, 

16 export_component_to_gds_temp, 

17 layer_stack_to_gds_mapping, 

18 rename_imported_objects, 

19) 

20 

21if TYPE_CHECKING: 

22 from ansys.aedt.core import Q2d 

23 from ansys.aedt.core.q3d import Q3d 

24 from gdsfactory.component import Component 

25 from gdsfactory.technology import LayerStack 

26 from gdsfactory.typings import CrossSectionSpec, Ports 

27 

28 

29class Q3D(AEDTBase): 

30 """Q3D Extractor simulation wrapper. 

31 

32 Provides methods for importing components into Q3D and performing 

33 parasitic capacitance/inductance extraction. 

34 """ 

35 

36 def __init__(self, q3d: Q3d): 

37 """Initialize the Q3D wrapper. 

38 

39 Args: 

40 q3d: The PyAEDT Q3d application instance. 

41 """ 

42 super().__init__(q3d) 

43 self.q3d = q3d 

44 

45 def import_component( 

46 self, 

47 component: Component, 

48 layer_stack: LayerStack | None = None, 

49 *, 

50 units: str = "um", 

51 gds_path: str | Path | None = None, 

52 ) -> list[str]: 

53 """Import a gdsfactory component into Q3D Extractor. 

54 

55 Imports the component's GDS geometry into a Q3D Extractor project, 

56 mapping each GDS layer to a 3D conductor at the appropriate elevation 

57 and thickness from the layer stack. 

58 

59 Args: 

60 component: The gdsfactory component to import. 

61 layer_stack: LayerStack defining thickness and elevation for each layer. 

62 If None, uses QPDK's default LAYER_STACK. 

63 units: Length units for the geometry (default: "um" for micrometers). 

64 gds_path: Optional path to write the GDS file. If None, uses a temporary file. 

65 

66 Returns: 

67 List of newly created conductor object names in Q3D. 

68 

69 Raises: 

70 RuntimeError: If GDS import fails. 

71 """ 

72 mapping_layers = layer_stack_to_gds_mapping(layer_stack) 

73 

74 with export_component_to_gds_temp( 

75 component, gds_path, prefix="qpdk_q3d_" 

76 ) as path: 

77 self.modeler.model_units = units 

78 existing_objects = set(self.modeler.object_names) 

79 

80 result = self.q3d.import_gds_3d( 

81 input_file=str(path), 

82 mapping_layers=mapping_layers, 

83 units=units, 

84 import_method=0, 

85 ) 

86 if not result: 

87 raise RuntimeError("Q3D GDS import failed") 

88 

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

90 

91 renamed_objects = rename_imported_objects( 

92 self.q3d, new_objects, layer_stack or LAYER_STACK 

93 ) 

94 

95 if renamed_objects: 

96 self.q3d.assign_material(renamed_objects, "pec") 

97 

98 return renamed_objects 

99 

100 def assign_nets_from_ports( 

101 self, 

102 ports: Ports, 

103 conductor_objects: list[str], 

104 ) -> list[str]: 

105 """Assign Q3D signal nets based on gdsfactory port locations. 

106 

107 For each gdsfactory port, finds the conductor object whose bounding-box 

108 center is nearest to the port center and assigns it as a Q3D signal net. 

109 

110 Args: 

111 ports: Collection of gdsfactory ports defining signal locations. 

112 conductor_objects: List of conductor object names created by 

113 :meth:`import_component`. 

114 

115 Returns: 

116 List of assigned signal net names (one per port). 

117 """ 

118 self.q3d.auto_identify_nets() 

119 

120 assigned_nets: list[str] = [] 

121 used_objects: set[str] = set() 

122 

123 if not ports or not conductor_objects: 

124 return assigned_nets 

125 

126 bboxes = {} 

127 for obj_name in conductor_objects: 

128 obj = self.modeler.get_object_from_name(obj_name) 

129 if obj: 

130 bboxes[obj_name] = obj.bounding_box 

131 

132 if not bboxes: 

133 return assigned_nets 

134 

135 first_port = next(iter(ports)) 

136 px0, py0 = float(first_port.center[0]), float(first_port.center[1]) 

137 

138 def dist_to_bbox( 

139 px: float, py: float, bbox: list[float], s: float = 1.0 

140 ) -> float: 

141 dx = max(bbox[0] * s - px, 0, px - bbox[3] * s) 

142 dy = max(bbox[1] * s - py, 0, py - bbox[4] * s) 

143 return math.hypot(dx, dy) 

144 

145 scale_factor = min( 

146 (10**p for p in range(-3, 5)), 

147 key=lambda s: min(dist_to_bbox(px0, py0, b, s) for b in bboxes.values()), 

148 ) 

149 

150 for port in ports: 

151 px, py = float(port.center[0]), float(port.center[1]) 

152 available_objs = [obj for obj in bboxes if obj not in used_objects] 

153 if not available_objs: 

154 break 

155 

156 def port_metric( 

157 obj_name: str, px: float = px, py: float = py 

158 ) -> tuple[float, float]: 

159 b = bboxes[obj_name] 

160 dist = dist_to_bbox(px, py, b, scale_factor) 

161 area = (b[3] - b[0]) * (b[4] - b[1]) * scale_factor**2 

162 return max(0.0, dist - 1.0), area 

163 

164 best_obj = min(available_objs, key=port_metric) 

165 net_to_rename = next( 

166 ( 

167 b 

168 for b in self.q3d.boundaries 

169 if b.type == "SignalNet" and best_obj in b.props.get("Objects", []) 

170 ), 

171 None, 

172 ) 

173 

174 if net_to_rename is not None: 

175 net_to_rename.name = port.name 

176 else: 

177 self.q3d.assign_net( 

178 assignment=[best_obj], net_name=port.name, net_type="Signal" 

179 ) 

180 

181 assigned_nets.append(port.name) 

182 used_objects.add(best_obj) 

183 

184 return assigned_nets 

185 

186 def get_capacitance_matrix(self, setup_name: str = "Q3DSetup") -> pl.DataFrame: 

187 """Extract the capacitance matrix from a Q3D Extractor simulation. 

188 

189 Retrieves all capacitance matrix entries (e.g. ``C(o1,o1)``, ``C(o1,o2)``) 

190 from the solved Q3D setup. 

191 

192 Args: 

193 setup_name: Name of the analysis setup. 

194 

195 Returns: 

196 DataFrame with one column per capacitance expression containing 

197 the extracted values in Farads. 

198 """ 

199 nets = [b.name for b in self.q3d.boundaries if b.type == "SignalNet"] 

200 expressions = [f"C({n1},{n2})" for i, n1 in enumerate(nets) for n2 in nets[i:]] 

201 data: dict[str, list[float]] = {} 

202 

203 for expr in expressions: 

204 solution = self.q3d.post.get_solution_data( 

205 expressions=expr, 

206 setup_sweep_name=f"{setup_name} : LastAdaptive", 

207 ) 

208 if solution: 

209 val = float(solution.data_real()[0]) 

210 unit = solution.units_data.get(expr, "pF") 

211 multiplier = { 

212 "fF": 1e-15, 

213 "pF": 1e-12, 

214 "nF": 1e-9, 

215 "uF": 1e-6, 

216 "mF": 1e-3, 

217 "F": 1.0, 

218 }.get(str(unit), 1e-12) 

219 data[expr] = [val * multiplier] 

220 

221 return pl.DataFrame(data) 

222 

223 

224class Q2D(AEDTBase): 

225 """Q2D simulation wrapper. 

226 

227 Provides methods for 2D cross-sectional impedance extraction. 

228 """ 

229 

230 def __init__(self, q2d: Q2d): 

231 """Initialize the Q2D wrapper. 

232 

233 Args: 

234 q2d: The PyAEDT Q2d application instance. 

235 """ 

236 super().__init__(q2d) 

237 self.q2d = q2d 

238 

239 def create_2d_from_cross_section( 

240 self, 

241 cross_section: CrossSectionSpec, 

242 layer_stack: LayerStack | None = None, 

243 *, 

244 ground_width: float | None = None, 

245 units: str = "um", 

246 ) -> dict[str, str]: 

247 """Create a 2D model from a CPW cross-section for impedance extraction. 

248 

249 Builds the cross-sectional geometry of a coplanar waveguide in Ansys Q2D 

250 (2D Extractor). 

251 

252 Args: 

253 cross_section: A gdsfactory cross-section specification describing the CPW 

254 geometry (width and gap). 

255 layer_stack: LayerStack defining substrate and conductor properties. 

256 If None, uses QPDK's default ``LAYER_STACK``. 

257 ground_width: Width of each coplanar ground plane in µm. If None, 

258 defaults to 10× the CPW gap. 

259 units: Length units for the Q2D geometry (default ``"um"``). 

260 

261 Returns: 

262 Dictionary with keys ``"signal"``, ``"gnd_left"``, ``"gnd_right"``, 

263 ``"substrate"`` mapping to the created Q2D object names. 

264 

265 Raises: 

266 ValueError: If cross-section mapping fails or dimensions are invalid. 

267 """ 

268 if layer_stack is None: 

269 layer_stack = LAYER_STACK 

270 

271 if units != "um": 

272 raise ValueError("Q2D cross-section expects units='um'") 

273 

274 cpw_width, cpw_gap = get_cpw_dimensions(cross_section) 

275 substrate_level = layer_stack.layers["Substrate"] 

276 substrate_thickness = float(substrate_level.thickness) 

277 substrate_material = cast(str, substrate_level.material) 

278 

279 conductor_level = layer_stack.layers["M1"] 

280 conductor_thickness = float(conductor_level.thickness) 

281 if conductor_thickness < 2.0: 

282 gf.logger.warning( 

283 "Setting conductor_thickness to 2.0 um for Q2D stability." 

284 ) 

285 conductor_thickness = 2.0 

286 conductor_material = cast(str, conductor_level.material) 

287 

288 if ground_width is None: 

289 ground_width = 10.0 * cpw_gap 

290 

291 self.add_materials() 

292 self.modeler.model_units = units 

293 

294 total_width = 2 * ground_width + 2 * cpw_gap + cpw_width 

295 substrate_margin = 50.0 

296 

297 parts = [ 

298 { 

299 "name": "signal", 

300 "origin": [ground_width + cpw_gap, 0, 0], 

301 "sizes": [cpw_width, conductor_thickness], 

302 "material": conductor_material, 

303 }, 

304 { 

305 "name": "gnd_left", 

306 "origin": [0, 0, 0], 

307 "sizes": [ground_width, conductor_thickness], 

308 "material": conductor_material, 

309 }, 

310 { 

311 "name": "gnd_right", 

312 "origin": [ground_width + cpw_gap + cpw_width + cpw_gap, 0, 0], 

313 "sizes": [ground_width, conductor_thickness], 

314 "material": conductor_material, 

315 }, 

316 { 

317 "name": "substrate", 

318 "origin": [-substrate_margin, -substrate_thickness, 0], 

319 "sizes": [total_width + 2 * substrate_margin, substrate_thickness], 

320 "material": substrate_material, 

321 }, 

322 ] 

323 

324 objects = { 

325 part["name"]: self.modeler.create_rectangle(**part) for part in parts 

326 } 

327 

328 self.q2d.assign_single_conductor( 

329 name="signal", 

330 assignment=[objects["signal"]], 

331 conductor_type="SignalLine", 

332 units=units, 

333 ) 

334 self.q2d.assign_single_conductor( 

335 name="gnd", 

336 assignment=[objects["gnd_left"], objects["gnd_right"]], 

337 conductor_type="ReferenceGround", 

338 units=units, 

339 ) 

340 

341 self.app.mesh.assign_length_mesh( 

342 assignment=[objects["signal"], objects["gnd_left"], objects["gnd_right"]], 

343 maximum_length=2.0, 

344 maximum_elements=10000, 

345 name="thin_trace_mesh", 

346 ) 

347 

348 return {str(name): obj.name for name, obj in objects.items()}