Coverage for qpdk / simulation / aedt_base.py: 50%

131 statements  

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

1"""Base AEDT simulation utilities using PyAEDT. 

2 

3This module provides shared helper functions and a base class for AEDT 

4simulations (HFSS, Q3D, Q2D) from gdsfactory components. 

5""" 

6 

7from __future__ import annotations 

8 

9import re 

10import tempfile 

11from collections.abc import Generator 

12from contextlib import contextmanager 

13from pathlib import Path 

14from typing import TYPE_CHECKING, Any 

15 

16import gdsfactory as gf 

17from gdsfactory.technology.layer_stack import LayerLevel 

18 

19from qpdk import LAYER_STACK 

20from qpdk.cells.helpers import ( 

21 add_margin_to_layer, 

22 apply_additive_metals, 

23 invert_mask_polarity, 

24 remove_metadata_layers, 

25) 

26from qpdk.tech import LAYER, material_properties 

27 

28if TYPE_CHECKING: 

29 from ansys.aedt.core import Hfss, Q2d 

30 from ansys.aedt.core.q3d import Q3d 

31 from gdsfactory.component import Component 

32 from gdsfactory.technology import LayerStack 

33 

34 

35def _get_layer_number_from_level(layer_level: LayerLevel) -> int | None: 

36 """Extract layer number from a LayerLevel's layer definition. 

37 

38 Returns: 

39 The GDS layer number if available, else None. 

40 """ 

41 if hasattr(layer_level, "derived_layer") and layer_level.derived_layer is not None: 

42 derived = layer_level.derived_layer 

43 if hasattr(derived, "layer"): 

44 inner = derived.layer 

45 if hasattr(inner, "layer"): 

46 val = inner.layer 

47 if isinstance(val, tuple) and len(val) >= 1: 

48 return int(val[0]) 

49 return int(val) 

50 if isinstance(inner, tuple) and len(inner) >= 1: 

51 return int(inner[0]) 

52 

53 layer = layer_level.layer 

54 if isinstance(layer, tuple) and len(layer) >= 1: 

55 return int(layer[0]) 

56 if hasattr(layer, "layer"): 

57 inner = layer.layer 

58 if isinstance(inner, tuple) and len(inner) >= 1: 

59 return int(inner[0]) 

60 if hasattr(inner, "layer"): 

61 val = inner.layer 

62 if isinstance(val, tuple) and len(val) >= 1: 

63 return int(val[0]) 

64 return int(val) 

65 return int(inner) 

66 return None 

67 

68 

69def layer_stack_to_gds_mapping( 

70 layer_stack: LayerStack | None = None, 

71 thickness_override: float | None = None, 

72) -> dict[int, tuple[float, float]]: 

73 """Convert a LayerStack to HFSS/Q3D GDS import mapping dictionary. 

74 

75 Returns: 

76 Dictionary mapping layer number to (thickness, elevation) tuple. 

77 """ 

78 if layer_stack is None: 

79 layer_stack = LAYER_STACK 

80 

81 mapping: dict[int, tuple[float, float]] = {} 

82 

83 for layer_level in layer_stack.layers.values(): 

84 layer_number = _get_layer_number_from_level(layer_level) 

85 if layer_number is None: 

86 continue 

87 

88 elevation = layer_level.zmin if layer_level.zmin is not None else 0.0 

89 thickness = ( 

90 thickness_override 

91 if thickness_override is not None 

92 else (layer_level.thickness or 0.0) 

93 ) 

94 mapping[layer_number] = (elevation, thickness) 

95 

96 return mapping 

97 

98 

99def prepare_component_for_aedt( 

100 component: Component, 

101 margin_draw: float = 0.0, 

102 margin_etch: float = 0.0, 

103) -> Component: 

104 """Prepare a component for AEDT simulation export. 

105 

106 Returns: 

107 A copy of the component prepared for simulation. 

108 """ 

109 c = gf.Component(name=f"{component.name}_aedt") 

110 c << component.copy() 

111 if margin_etch > 0.0: 

112 c = add_margin_to_layer( 

113 c, 

114 layer_margins=[ 

115 (LAYER.M1_ETCH, margin_etch), 

116 (LAYER.M2_ETCH, margin_etch), 

117 ], 

118 ) 

119 c = apply_additive_metals(c) 

120 c = invert_mask_polarity(c) 

121 if margin_draw > 0.0: 

122 c = add_margin_to_layer( 

123 c, 

124 layer_margins=[ 

125 (LAYER.M1_DRAW, margin_draw), 

126 (LAYER.M2_DRAW, margin_draw), 

127 ], 

128 ) 

129 c = c.remove_layers(layer for layer in LAYER if str(layer).endswith("_ETCH")) 

130 c = remove_metadata_layers(c) 

131 c.add_ports(component.ports) 

132 return c 

133 

134 

135@contextmanager 

136def export_component_to_gds_temp( 

137 component: gf.Component, 

138 gds_path: str | Path | None = None, 

139 prefix: str = "qpdk_aedt_", 

140) -> Generator[Path, None, None]: 

141 """Context manager for exporting a component to a temporary GDS file. 

142 

143 Yields: 

144 Path to the exported GDS file. 

145 """ 

146 if gds_path is not None: 

147 path = Path(gds_path) 

148 component.write_gds(str(path)) 

149 yield path 

150 else: 

151 with tempfile.TemporaryDirectory(prefix=prefix) as temp_dir: 

152 path = Path(temp_dir) / "component.gds" 

153 component.write_gds(str(path)) 

154 yield path 

155 

156 

157def rename_imported_objects( 

158 app: Any, new_objects: list[str], layer_stack: LayerStack 

159) -> list[str]: 

160 """Rename imported GDS objects based on the layer stack. 

161 

162 Returns: 

163 List of renamed object names. 

164 """ 

165 num_to_name = {} 

166 for name, level in layer_stack.layers.items(): 

167 layer_num = _get_layer_number_from_level(level) 

168 if layer_num is not None and layer_num not in num_to_name: 

169 num_to_name[layer_num] = name 

170 

171 renamed_objects = [] 

172 for obj_name in new_objects: 

173 match = re.match(r"^signal(\d+)(_.*)?$", obj_name) 

174 new_name = obj_name 

175 if match: 

176 layer_num = int(match.group(1)) 

177 suffix = match.group(2) or "" 

178 if layer_num in num_to_name: 

179 layer_name = num_to_name[layer_num] 

180 new_name = f"{layer_name}{suffix}" 

181 try: 

182 app.modeler[obj_name].name = new_name 

183 except Exception: 

184 new_name = obj_name 

185 renamed_objects.append(new_name) 

186 

187 return renamed_objects 

188 

189 

190def add_materials_to_aedt(app: Hfss | Q2d | Q3d) -> None: 

191 """Add QPDK materials to the PyAEDT application.""" 

192 for name, props in material_properties.items(): 

193 if app.materials.exists_material(name): 

194 continue 

195 

196 mat = app.materials.add_material(name) 

197 

198 for prop_name, prop_value in props.items(): 

199 if prop_value == float("inf"): 

200 if prop_name == "relative_permittivity": 

201 mat.conductivity = 1e30 

202 continue 

203 

204 if prop_name == "relative_permittivity": 

205 mat.permittivity = prop_value 

206 elif prop_name == "conductivity": 

207 mat.conductivity = prop_value 

208 

209 

210class AEDTBase: 

211 """Base class for AEDT simulations.""" 

212 

213 def __init__(self, app: Hfss | Q2d | Q3d): 

214 """Initialize the AEDT base class. 

215 

216 Args: 

217 app: The PyAEDT application instance. 

218 """ 

219 self.app = app 

220 

221 @property 

222 def modeler(self): 

223 """Return the AEDT modeler instance.""" 

224 return self.app.modeler 

225 

226 def add_materials(self) -> None: 

227 """Add QPDK materials to the AEDT project.""" 

228 add_materials_to_aedt(self.app) 

229 

230 def add_substrate( 

231 self, 

232 component: Component, 

233 thickness: float = 500.0, 

234 material: str = "silicon", 

235 name: str = "Substrate", 

236 ) -> str: 

237 """Add a substrate box below the component geometry. 

238 

239 Returns: 

240 Name of the created substrate object. 

241 """ 

242 bounds = component.bbox() 

243 x_min, y_min = bounds.p1.x, bounds.p1.y 

244 dx, dy = bounds.p2.x - x_min, bounds.p2.y - y_min 

245 

246 substrate = self.modeler.create_box( 

247 origin=[x_min, y_min, -thickness], 

248 sizes=[dx, dy, thickness], 

249 name=name, 

250 material=material, 

251 ) 

252 substrate.mesh_order = 4 

253 return substrate.name 

254 

255 def save(self) -> None: 

256 """Save the AEDT project.""" 

257 self.app.save_project()