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

114 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-14 10:27 +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.simulation.aedt_base import ( 

14 AEDTBase, 

15 export_component_to_gds_temp, 

16 layer_stack_to_gds_mapping, 

17 rename_imported_objects, 

18) 

19 

20if TYPE_CHECKING: 

21 from ansys.aedt.core import Q2d 

22 from ansys.aedt.core.q3d import Q3d 

23 from gdsfactory.component import Component 

24 from gdsfactory.technology import LayerStack 

25 from gdsfactory.typings import CrossSectionSpec, Ports 

26 

27 

28class Q3D(AEDTBase): 

29 """Q3D Extractor simulation wrapper. 

30 

31 Provides methods for importing components into Q3D and performing 

32 parasitic capacitance/inductance extraction. 

33 """ 

34 

35 def __init__(self, q3d: Q3d): 

36 """Initialize the Q3D wrapper. 

37 

38 Args: 

39 q3d: The PyAEDT Q3d application instance. 

40 """ 

41 super().__init__(q3d) 

42 self.q3d = q3d 

43 

44 def import_component( 

45 self, 

46 component: Component, 

47 layer_stack: LayerStack | None = None, 

48 *, 

49 units: str = "um", 

50 gds_path: str | Path | None = None, 

51 ) -> list[str]: 

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

53 

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

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

56 and thickness from the layer stack. 

57 

58 Args: 

59 component: The gdsfactory component to import. 

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

61 If None, uses QPDK's default LAYER_STACK. 

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

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

64 

65 Returns: 

66 List of newly created conductor object names in Q3D. 

67 """ 

68 mapping_layers = layer_stack_to_gds_mapping(layer_stack) 

69 

70 with export_component_to_gds_temp( 

71 component, gds_path, prefix="qpdk_q3d_" 

72 ) as path: 

73 self.modeler.model_units = units 

74 existing_objects = set(self.modeler.object_names) 

75 

76 result = self.q3d.import_gds_3d( 

77 input_file=str(path), 

78 mapping_layers=mapping_layers, 

79 units=units, 

80 import_method=0, 

81 ) 

82 if not result: 

83 raise RuntimeError("Q3D GDS import failed") 

84 

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

86 from qpdk import LAYER_STACK 

87 

88 renamed_objects = rename_imported_objects( 

89 self.q3d, new_objects, layer_stack or LAYER_STACK 

90 ) 

91 

92 if renamed_objects: 

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

94 

95 return renamed_objects 

96 

97 def assign_nets_from_ports( 

98 self, 

99 ports: Ports, 

100 conductor_objects: list[str], 

101 ) -> list[str]: 

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

103 

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

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

106 

107 Args: 

108 ports: Collection of gdsfactory ports defining signal locations. 

109 conductor_objects: List of conductor object names created by 

110 :meth:`import_component`. 

111 

112 Returns: 

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

114 """ 

115 self.q3d.auto_identify_nets() 

116 

117 assigned_nets: list[str] = [] 

118 used_objects: set[str] = set() 

119 

120 if not ports or not conductor_objects: 

121 return assigned_nets 

122 

123 bboxes = {} 

124 for obj_name in conductor_objects: 

125 obj = self.modeler.get_object_from_name(obj_name) 

126 if obj: 

127 bboxes[obj_name] = obj.bounding_box 

128 

129 if not bboxes: 

130 return assigned_nets 

131 

132 first_port = next(iter(ports)) 

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

134 

135 def dist_to_bbox( 

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

137 ) -> float: 

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

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

140 return math.hypot(dx, dy) 

141 

142 scale_factor = min( 

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

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

145 ) 

146 

147 for port in ports: 

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

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

150 if not available_objs: 

151 break 

152 

153 def port_metric( 

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

155 ) -> tuple[float, float]: 

156 b = bboxes[obj_name] 

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

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

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

160 

161 best_obj = min(available_objs, key=port_metric) 

162 net_to_rename = next( 

163 ( 

164 b 

165 for b in self.q3d.boundaries 

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

167 ), 

168 None, 

169 ) 

170 

171 if net_to_rename is not None: 

172 net_to_rename.name = port.name 

173 else: 

174 self.q3d.assign_net( 

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

176 ) 

177 

178 assigned_nets.append(port.name) 

179 used_objects.add(best_obj) 

180 

181 return assigned_nets 

182 

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

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

185 

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

187 from the solved Q3D setup. 

188 

189 Args: 

190 setup_name: Name of the analysis setup. 

191 

192 Returns: 

193 DataFrame with one column per capacitance expression containing 

194 the extracted values in Farads. 

195 """ 

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

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

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

199 

200 for expr in expressions: 

201 solution = self.q3d.post.get_solution_data( 

202 expressions=expr, 

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

204 ) 

205 if solution: 

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

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

208 multiplier = { 

209 "fF": 1e-15, 

210 "pF": 1e-12, 

211 "nF": 1e-9, 

212 "uF": 1e-6, 

213 "mF": 1e-3, 

214 "F": 1.0, 

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

216 data[expr] = [val * multiplier] 

217 

218 return pl.DataFrame(data) 

219 

220 

221class Q2D(AEDTBase): 

222 """Q2D simulation wrapper. 

223 

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

225 """ 

226 

227 def __init__(self, q2d: Q2d): 

228 """Initialize the Q2D wrapper. 

229 

230 Args: 

231 q2d: The PyAEDT Q2d application instance. 

232 """ 

233 super().__init__(q2d) 

234 self.q2d = q2d 

235 

236 def create_2d_from_cross_section( 

237 self, 

238 cross_section: CrossSectionSpec, 

239 layer_stack: LayerStack | None = None, 

240 *, 

241 ground_width: float | None = None, 

242 units: str = "um", 

243 ) -> dict[str, str]: 

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

245 

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

247 (2D Extractor). 

248 

249 Args: 

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

251 geometry (width and gap). 

252 layer_stack: LayerStack defining substrate and conductor properties. 

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

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

255 defaults to 10× the CPW gap. 

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

257 

258 Returns: 

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

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

261 """ 

262 from qpdk.models.cpw import get_cpw_dimensions 

263 

264 if layer_stack is None: 

265 layer_stack = LAYER_STACK 

266 

267 if units != "um": 

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

269 

270 cpw_width, cpw_gap = get_cpw_dimensions(cross_section) 

271 substrate_level = layer_stack.layers["Substrate"] 

272 substrate_thickness = float(substrate_level.thickness) 

273 substrate_material = cast(str, substrate_level.material) 

274 

275 conductor_level = layer_stack.layers["M1"] 

276 conductor_thickness = float(conductor_level.thickness) 

277 if conductor_thickness < 2.0: 

278 gf.logger.warning( 

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

280 ) 

281 conductor_thickness = 2.0 

282 conductor_material = cast(str, conductor_level.material) 

283 

284 if ground_width is None: 

285 ground_width = 10.0 * cpw_gap 

286 

287 self.add_materials() 

288 self.modeler.model_units = units 

289 

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

291 substrate_margin = 50.0 

292 

293 parts = [ 

294 { 

295 "name": "signal", 

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

297 "sizes": [cpw_width, conductor_thickness], 

298 "material": conductor_material, 

299 }, 

300 { 

301 "name": "gnd_left", 

302 "origin": [0, 0, 0], 

303 "sizes": [ground_width, conductor_thickness], 

304 "material": conductor_material, 

305 }, 

306 { 

307 "name": "gnd_right", 

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

309 "sizes": [ground_width, conductor_thickness], 

310 "material": conductor_material, 

311 }, 

312 { 

313 "name": "substrate", 

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

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

316 "material": substrate_material, 

317 }, 

318 ] 

319 

320 objects = { 

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

322 } 

323 

324 self.q2d.assign_single_conductor( 

325 name="signal", 

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

327 conductor_type="SignalLine", 

328 units=units, 

329 ) 

330 self.q2d.assign_single_conductor( 

331 name="gnd", 

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

333 conductor_type="ReferenceGround", 

334 units=units, 

335 ) 

336 

337 self.app.mesh.assign_length_mesh( 

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

339 maximum_length=2.0, 

340 maximum_elements=10000, 

341 name="thin_trace_mesh", 

342 ) 

343 

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