Coverage for klayout_pex/fastercap/pex25d_model_builder.py: 88%

146 statements  

« prev     ^ index     » next       coverage.py v7.17.2, created at 2026-11-06 13:14 +0000

1# 

2# -------------------------------------------------------------------------------- 

3# SPDX-FileCopyrightText: 2024-2026 Martin Jan Köhler and Harald Pretl 

4# Johannes Kepler University, Institute for Integrated Circuits. 

5# 

6# This file is part of KPEX 

7# (see https://github.com/iic-jku/klayout-pex). 

8# 

9# This program is free software: you can redistribute it and/or modify 

10# it under the terms of the GNU General Public License as published by 

11# the Free Software Foundation, either version 3 of the License, or 

12# (at your option) any later version. 

13# 

14# This program is distributed in the hope that it will be useful, 

15# but WITHOUT ANY WARRANTY; without even the implied warranty of 

16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

17# GNU General Public License for more details. 

18# 

19# You should have received a copy of the GNU General Public License 

20# along with this program. If not, see <http://www.gnu.org/licenses/>. 

21# SPDX-License-Identifier: GPL-3.0-or-later 

22# -------------------------------------------------------------------------------- 

23# 

24 

25""" 

26Build FasterCap geometry from a resolved PEX25D scene. 

27 

28The occupancy rule maps onto the model generator almost for free. 

29:meth:`FasterCapModelBuilder.generate` legalizes overlapping regions by the 

30order materials were added, conductors ahead of every dielectric — which is 

31exactly the PEX25D rule once the dielectrics are added in ascending wrap depth. 

32A resolved scene already lists them in that order, so the innermost film wins 

33and nothing has to be subtracted by hand. 

34""" 

35 

36from __future__ import annotations 

37 

38from fractions import Fraction 

39from typing import * 

40 

41import klayout.db as kdb 

42 

43from ..log import debug, info, warning 

44from ..pex25d.protobuf import pex25d_dielectric_pb2, pex25d_scene_pb2 

45from ..plugin_api.v1 import ExportError 

46from .fastercap_model_generator import FasterCapModelBuilder, FasterCapModelGenerator 

47 

48if TYPE_CHECKING: 

49 from .pex25d_exporter import FasterCapModelOptions 

50 

51 from klayout_pex_protobuf.kpex.pex25d.pex25d_scene_pb2 import PEX25DScene 

52 

53 

54class PEX25DFasterCapModelBuilder: 

55 """Convert one scene and its export settings into solver geometry.""" 

56 

57 def __init__(self, scene: PEX25DScene, options: FasterCapModelOptions): 

58 self.scene = scene 

59 self.options = options 

60 

61 units = scene.units 

62 if not units.grid_denominator: 

63 raise ExportError("The scene declares no grid") 

64 # Coordinates stay in grid units and the model's DBU *is* the grid, so 

65 # nothing is rescaled and no precision is lost on the way out. 

66 self.grid = Fraction(units.grid_numerator, units.grid_denominator) 

67 self.dbu = float(self.grid) 

68 

69 self.layers = {layer.name: layer for layer in scene.layers} 

70 self.dielectrics = {d.name: d for d in scene.dielectrics} 

71 

72 # ------------------------------------------------------------- geometry 

73 

74 def um(self, grid_units: int) -> float: 

75 return float(Fraction(grid_units) * self.grid) 

76 

77 def shapes_of_layer(self, layer_name: str) -> kdb.Region: 

78 """Every conductor's geometry on one layer, unioned across nets.""" 

79 region = kdb.Region() 

80 for conductor in self.scene.conductors: 

81 for conductor_region in conductor.regions: 

82 if conductor_region.layer != layer_name: 

83 continue 

84 region += region_of(conductor_region) 

85 return region.merged() 

86 

87 @property 

88 def field(self) -> kdb.Region: 

89 """ 

90 The lateral extent of everything unbounded in XY. 

91 

92 A DOMAIN_BOX states it; otherwise the finite geometry grown by the 

93 margin, which is the same shape the FasterCap input builder has always 

94 used, just derived from the scene rather than from the layout. 

95 """ 

96 if not hasattr(self, '_field'): 

97 domain = self.scene.domain 

98 origins = pex25d_scene_pb2().ResolvedDomain 

99 if self.scene.HasField('domain') \ 

100 and domain.origin != origins.ORIGIN_UNSPECIFIED: 

101 box = domain.box 

102 else: 

103 box = domain.geometry_bounds if self.scene.HasField('domain') else None 

104 if box is None or (box.lower_left.x == 0 and box.upper_right.x == 0): 

105 raise ExportError( 

106 "The scene has neither a domain nor finite geometry, so " 

107 "there is nothing to bound the unbounded materials with") 

108 margin = int(round(self.options.field_margin_um / float(self.grid))) 

109 box = grow(box, margin) 

110 self._field = kdb.Region(kdb.Box(box.lower_left.x, box.lower_left.y, 

111 box.upper_right.x, box.upper_right.y)) 

112 return self._field 

113 

114 # ---------------------------------------------------------------- build 

115 

116 def build(self) -> FasterCapModelGenerator: 

117 background = self.scene.background 

118 if not self.scene.HasField('background'): 

119 raise ExportError("The scene declares no background dielectric") 

120 

121 builder = FasterCapModelBuilder(dbu=self.dbu, 

122 k_void=background.permittivity, 

123 delaunay_amax=self.options.delaunay_amax, 

124 delaunay_b=self.options.delaunay_b) 

125 

126 # Ascending wrap depth, which the resolver already established, so that 

127 # the generator's own precedence matches the occupancy rule. 

128 for dielectric in self.scene.dielectrics: 

129 builder.add_material(name=dielectric.name, k=dielectric.permittivity) 

130 

131 self.add_ground_plane(builder) 

132 self.add_conductors(builder) 

133 self.add_dielectrics(builder) 

134 

135 generator = builder.generate() 

136 if generator is None: 

137 raise ExportError("The scene produced no geometry") 

138 return generator 

139 

140 def add_ground_plane(self, builder: FasterCapModelBuilder): 

141 if not self.scene.HasField('ground_plane'): 

142 warning("The scene declares no ground plane") 

143 return 

144 ground_plane = self.scene.ground_plane 

145 info(f"Ground plane {ground_plane.name}: " 

146 f"z={self.um(ground_plane.zlow)}, " 

147 f"height={self.um(ground_plane.zhigh - ground_plane.zlow)}") 

148 builder.add_conductor(net_name=ground_plane.name, 

149 layer=self.field, 

150 z=self.um(ground_plane.zlow), 

151 height=self.um(ground_plane.zhigh - ground_plane.zlow)) 

152 

153 def add_conductors(self, builder: FasterCapModelBuilder): 

154 for conductor in self.scene.conductors: 

155 # A FLOATING conductor belongs to no net and is solved under its own 

156 # shortname; everything else is reported per net. 

157 name = conductor.name if conductor.floating else conductor.net 

158 for conductor_region in conductor.regions: 

159 layer = self.layers.get(conductor_region.layer) 

160 if layer is None: 

161 raise ExportError(f"Conductor '{conductor.name}' has geometry on " 

162 f"'{conductor_region.layer}', which the scene " 

163 f"does not declare") 

164 debug(f"Conductor {name}, layer {conductor_region.layer}: " 

165 f"z={self.um(layer.zlow)}, " 

166 f"height={self.um(layer.zhigh - layer.zlow)}") 

167 builder.add_conductor(net_name=name, 

168 layer=region_of(conductor_region), 

169 z=self.um(layer.zlow), 

170 height=self.um(layer.zhigh - layer.zlow)) 

171 

172 def add_dielectrics(self, builder: FasterCapModelBuilder): 

173 kinds = pex25d_dielectric_pb2() 

174 

175 for dielectric in self.scene.dielectrics: 

176 if dielectric.kind == kinds.DIELECTRIC_KIND_SIMPLE: 

177 # A band spans the whole plane. The films it encloses sit at a 

178 # smaller wrap depth and were added first, so the generator 

179 # carves them out of it. 

180 height = dielectric.zhigh - dielectric.zlow 

181 if height <= 0: 

182 warning(f"Simple dielectric '{dielectric.name}' has no height; " 

183 f"skipping") 

184 continue 

185 info(f"Simple dielectric {dielectric.name}: " 

186 f"z={self.um(dielectric.zlow)}, height={self.um(height)}") 

187 builder.add_dielectric(material_name=dielectric.name, 

188 layer=self.field, 

189 z=self.um(dielectric.zlow), 

190 height=self.um(height)) 

191 elif dielectric.kind == kinds.DIELECTRIC_KIND_CONFORMAL: 

192 self.add_conformal(builder, dielectric) 

193 else: 

194 raise ExportError(f"Dielectric '{dielectric.name}' has no kind") 

195 

196 def add_conformal(self, builder: FasterCapModelBuilder, dielectric: Any): 

197 """ 

198 A film has up to two solids: the one grown around what it wraps, and — 

199 when it covers the field — a slab over everything the root is absent 

200 from. Both are measured from the bottom face of the root, which is what 

201 makes a chain of films compose. 

202 """ 

203 root = self.root_of(dielectric) 

204 if root is None: 

205 warning(f"Dielectric '{dielectric.name}' is rooted on " 

206 f"'{dielectric.root}', which the scene does not declare; skipping") 

207 return 

208 

209 footprint, zlow, zhigh = root 

210 lateral, top = self.chain_extent(dielectric, zhigh) 

211 

212 if not footprint.is_empty(): 

213 # Like every unbounded material, a film on the ground plane ends at 

214 # the field; a film on a conductor lies within it anyway. 

215 grown = (footprint.sized(lateral) & self.field) if lateral else footprint 

216 height = top - zlow 

217 info(f"Conformal dielectric {dielectric.name}: " 

218 f"z={self.um(zlow)}, height={self.um(height)}") 

219 builder.add_dielectric(material_name=dielectric.name, 

220 layer=grown, 

221 z=self.um(zlow), 

222 height=self.um(height)) 

223 else: 

224 grown = kdb.Region() 

225 

226 if dielectric.thickness_on_field > 0: 

227 info(f"Conformal dielectric {dielectric.name} (on the field): " 

228 f"z={self.um(zlow)}, " 

229 f"height={self.um(dielectric.thickness_on_field)}") 

230 builder.add_dielectric(material_name=dielectric.name, 

231 layer=self.field - grown, 

232 z=self.um(zlow), 

233 height=self.um(dielectric.thickness_on_field)) 

234 

235 def root_of(self, dielectric: Any) -> Optional[Tuple[kdb.Region, int, int]]: 

236 """ 

237 The footprint and z extent of the conductor layer or ground plane at the 

238 root of a film's WRAPS chain. The ground plane covers the whole field. 

239 """ 

240 layer = self.layers.get(dielectric.root) 

241 if layer is not None: 

242 return self.shapes_of_layer(layer.name), layer.zlow, layer.zhigh 

243 

244 ground_plane = self.scene.ground_plane 

245 if self.scene.HasField('ground_plane') and dielectric.root == ground_plane.name: 

246 return self.field, ground_plane.zlow, ground_plane.zhigh 

247 return None 

248 

249 def chain_extent(self, dielectric: Any, root_zhigh: int) -> Tuple[int, int]: 

250 """ 

251 Walk a film's WRAPS chain down to its root, accumulating the offsets. 

252 

253 Each film is measured from the surface of the one it wraps, so the total 

254 lateral offset from the conductor is the sum along the chain, and the 

255 top is the root's top plus the sum of the over-thicknesses. 

256 """ 

257 kinds = pex25d_dielectric_pb2() 

258 lateral = 0 

259 over = 0 

260 name: Optional[str] = dielectric.name 

261 seen: Set[str] = set() 

262 

263 while name is not None and name in self.dielectrics: 

264 if name in seen: 

265 raise ExportError(f"WRAPS chain through '{name}' is cyclic") 

266 seen.add(name) 

267 film = self.dielectrics[name] 

268 if film.kind == kinds.DIELECTRIC_KIND_CONFORMAL: 

269 lateral += film.thickness_beside_wrapped 

270 over += film.thickness_over_wrapped 

271 name = film.wraps 

272 

273 return lateral, root_zhigh + over 

274 

275 

276def region_of(conductor_region: Any) -> kdb.Region: 

277 """Build a region from one conductor's shapes on one layer, in grid units.""" 

278 region = kdb.Region() 

279 for box in conductor_region.boxes: 

280 region.insert(kdb.Box(box.lower_left.x, box.lower_left.y, 

281 box.upper_right.x, box.upper_right.y)) 

282 for polygon in conductor_region.polygons: 

283 shape = kdb.Polygon([kdb.Point(p.x, p.y) for p in polygon.outer.points]) 

284 for hole in polygon.holes: 

285 shape.insert_hole([kdb.Point(p.x, p.y) for p in hole.points]) 

286 region.insert(shape) 

287 return region 

288 

289 

290def grow(box: Any, margin: int) -> Any: 

291 from ..pex25d.protobuf import pex25d_geometry_pb2 

292 grown = pex25d_geometry_pb2().Box3D() 

293 grown.lower_left.x = box.lower_left.x - margin 

294 grown.lower_left.y = box.lower_left.y - margin 

295 grown.lower_left.z = box.lower_left.z 

296 grown.upper_right.x = box.upper_right.x + margin 

297 grown.upper_right.y = box.upper_right.y + margin 

298 grown.upper_right.z = box.upper_right.z 

299 return grown