Coverage for klayout_pex/klayout/pex25d_builder.py: 92%

518 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"""Generation of a ``PEX25DFile`` from LVS connectivity and the PDK stack.""" 

26 

27from __future__ import annotations 

28 

29from collections import defaultdict 

30from dataclasses import dataclass, field 

31from fractions import Fraction 

32from functools import cached_property 

33from typing import * 

34from urllib.parse import quote 

35 

36import klayout.db as kdb 

37 

38import klayout_pex_protobuf.kpex.tech.process_stack_pb2 as process_stack_pb2 

39 

40from ..log import debug, info, warning, error 

41from ..pex25d.format_version import ( 

42 FORMAT_VERSION_MAJOR, 

43 FORMAT_VERSION_MINOR, 

44 FORMAT_VERSION_SUFFIX, 

45) 

46from ..pex25d.protobuf import pex25d_file_pb2, pex25d_dielectric_pb2, pex25d_terminal_pb2 

47from ..types import GDSPair 

48from ..version import __version__ 

49from .lvsdb_extractor import KLayoutExtractionContext, device_list 

50 

51if TYPE_CHECKING: 

52 from ..tech_info import TechInfo 

53 

54 

55LT = process_stack_pb2.ProcessStackInfo.LayerType 

56 

57# PEX25D wants every coordinate as an exact integer count of grid units. 0.0001 µm 

58# is the coarsest grid on which the sky130A z stack closes (poly sits at 0.3262), 

59# and it divides the usual 0.001 µm layout DBU exactly, as the format requires. 

60DEFAULT_GRID_UM = '0.0001' 

61 

62 

63class BuildError(Exception): 

64 pass 

65 

66 

67@dataclass 

68class BuilderOptions: 

69 """Options the CLI exposes for PEX25D generation.""" 

70 

71 grid_um: str = DEFAULT_GRID_UM 

72 """Coordinate grid, as a decimal string so that it stays an exact rational.""" 

73 

74 with_source_refs: bool = False 

75 """Populate every ``SourceRef``. Roughly doubles the size of a file.""" 

76 

77 dielectric_filter: Optional[Any] = None 

78 """The ``--diel`` multiple-choice pattern, as used by the FasterCap path.""" 

79 

80 include_resistance: bool = True 

81 """Emit ``RESISTANCE`` records for the layers the technology defines.""" 

82 

83 domain_margin_um: Optional[float] = None 

84 """Emit ``DOMAIN_MARGIN`` with this clearance. None leaves the domain unset.""" 

85 

86 process_corner: Optional[str] = None 

87 """Value for ``META process_corner``, if known.""" 

88 

89 

90class PEX25DBuilder: 

91 """ 

92 Assembles a ``kpex.pex25d.PEX25DFile`` from a technology stack and the 

93 connectivity of an LVS run. 

94 

95 Only interconnect is described: device geometry (diffusion, the gate 

96 channel) belongs to the compact model and would be double-counted, so it is 

97 excluded here and the enclosing PEX flow connects device terminals to the 

98 remaining interconnect. 

99 """ 

100 

101 def __init__(self, 

102 pex_context: KLayoutExtractionContext, 

103 tech_info: 'TechInfo', 

104 cell_name: str, 

105 options: Optional[BuilderOptions] = None): 

106 self.pex_context = pex_context 

107 self.tech_info = tech_info 

108 self.cell_name = cell_name 

109 self.options = options or BuilderOptions() 

110 

111 self._grid = Fraction(self.options.grid_um) 

112 self._dbu = Fraction(str(pex_context.dbu)) 

113 

114 # Names already placed in the profile namespace, so that a stack which 

115 # repeats a dielectric (sky130A lists nild5 and nild6 twice, once per 

116 # MiM-cap variant) does not produce a duplicate declaration. 

117 self._profile_names: Set[str] = set() 

118 self._via_contacts: Dict[str, Any] = {} 

119 self._device_contact_by_diffusion: Dict[str, str] = {} 

120 self._conductor_regions: Dict[str, Dict[str, kdb.Region]] = {} 

121 self._terminal_names: Set[str] = set() 

122 

123 scale = self._dbu / self._grid 

124 if scale.denominator != 1: 

125 raise BuildError( 

126 f"The layout DBU ({pex_context.dbu}) is not an integer multiple of the " 

127 f"PEX25D grid ({self.options.grid_um}); drawn geometry could not be " 

128 f"placed on the grid. Choose a finer grid." 

129 ) 

130 self._dbu_scale = int(scale) 

131 

132 # ------------------------------------------------------------ conversions 

133 

134 def to_grid(self, value_um: float, what: str) -> int: 

135 """ 

136 Convert a µm value from the technology into grid units. 

137 

138 Divide-round-compare, never a modulo: in binary floating point 

139 ``0.3262 % 0.0001`` is ``9.9999999999e-05``, and an exact-modulo test 

140 would reject perfectly legal values. 

141 """ 

142 quotient = Fraction(str(value_um)) / self._grid 

143 rounded = round(quotient) 

144 if abs(quotient - rounded) > Fraction(1, 10 ** 6): 

145 raise BuildError( 

146 f"{what}: {value_um} µm is not an integer multiple of the grid " 

147 f"{self.options.grid_um} µm. Choose a finer grid." 

148 ) 

149 return int(rounded) 

150 

151 def dbu_to_grid(self, value: int) -> int: 

152 """Convert a drawn coordinate (in DBU) into grid units. Always exact.""" 

153 return value * self._dbu_scale 

154 

155 # ----------------------------------------------------------------- lookup 

156 

157 def gds_pair(self, layer_name: str) -> Optional[GDSPair]: 

158 return self.tech_info.gds_pair(layer_name) 

159 

160 def shapes_of_net(self, layer_name: str, net: kdb.Net) -> Optional[kdb.Region]: 

161 """ 

162 NOTE: layers of the process stack can share a GDS pair (e.g. a metal without and with a MIM cap above), 

163 so each of them has the shapes of its own LVS layers only 

164 """ 

165 gds_pair = self.gds_pair(layer_name) 

166 if not gds_pair: 

167 return None 

168 lvs_layer_names = self.tech_info.lvs_layer_names_by_process_layer_name.get(layer_name, []) 

169 return self.pex_context.shapes_of_net(gds_pair=gds_pair, net=net, lvs_layer_names=lvs_layer_names) 

170 

171 @cached_property 

172 def substrate_lvs_layer_names(self) -> Set[str]: 

173 return set(self.tech_info.tech.substrate.lvs_layer_names) 

174 

175 @cached_property 

176 def process_layer_name_by_lvs_layer_name(self) -> Dict[str, str]: 

177 """The profile whose shapes the shapes of an LVS layer are.""" 

178 return {lvs_layer_name: layer_name 

179 for layer_name, lvs_layer_names in self.tech_info.lvs_layer_names_by_process_layer_name.items() 

180 for lvs_layer_name in lvs_layer_names} 

181 

182 @cached_property 

183 def metal_layer_by_name(self) -> Dict[str, Any]: 

184 return {lyr.name: lyr for lyr in self.tech_info.process_metal_layers} 

185 

186 @cached_property 

187 def metal_layers_by_canonical_name(self) -> Dict[str, List[str]]: 

188 """The emitted METAL profiles, grouped by the canonical layer behind them.""" 

189 groups: Dict[str, List[str]] = {} 

190 for name in self.metal_layer_by_name: 

191 canonical = self.canonical_name(name) 

192 if canonical: 

193 groups.setdefault(canonical, []).append(name) 

194 return groups 

195 

196 def resolve_metal(self, name: str, context: str) -> Optional[str]: 

197 """ 

198 Map a name used by a contact onto an emitted METAL profile. 

199 

200 The two namespaces need not agree. sky130A splits a canonical layer per 

201 MiM-cap variant, so a contact on ``met3`` has to find ``met3_ncap``; the 

202 IHP stacks name their profiles after the drawing layer while their 

203 contacts name the LVS computed layer, so ``metal1_con`` has to find 

204 ``Metal1``. Both meet at the canonical layer, so the candidates are 

205 taken from there rather than from the spelling of the name. Where 

206 exactly one candidate carries a contact the choice is unambiguous; 

207 anything else is reported rather than guessed. 

208 """ 

209 if name in self.metal_layer_by_name: 

210 return name 

211 

212 canonical = self.canonical_name(name) 

213 candidates = self.metal_layers_by_canonical_name.get(canonical, []) \ 

214 if canonical else [] 

215 if not candidates: 

216 warning(f"{context}: no metal layer '{name}' in the process stack; skipping") 

217 return None 

218 

219 chosen = candidates[0] if len(candidates) == 1 else None 

220 if chosen is None: 

221 with_contact = [n for n in candidates 

222 if self.metal_layer_by_name[n].metal_layer 

223 .HasField('contact_above')] 

224 if len(with_contact) != 1: 

225 warning(f"{context}: '{name}' is ambiguous in the process stack " 

226 f"({', '.join(candidates)}); skipping") 

227 return None 

228 chosen = with_contact[0] 

229 

230 # Where the candidates share a z extent — sky130A splits met3 and met4 

231 # only to hang different dielectrics off them — the choice cannot change 

232 # the via extent that CONNECTS derives, so it is not worth a warning. 

233 # A genuine difference is. 

234 extents = {(self.metal_layer_by_name[n].metal_layer.z, 

235 self.metal_layer_by_name[n].metal_layer.thickness) 

236 for n in candidates} 

237 report = debug if len(extents) == 1 else warning 

238 report(f"{context}: no metal layer '{name}' in the process stack, using " 

239 f"'{chosen}' for canonical layer '{canonical}'; candidates were " 

240 f"{', '.join(candidates)}") 

241 return chosen 

242 

243 def canonical_name(self, name: str) -> Optional[str]: 

244 """ 

245 The canonical layer name behind a profile name. 

246 

247 The process stack names profiles (`met3_ncap`, `via2_con`) while the 

248 parasitics tables are keyed on canonical layers (`met3`, `via2`). The two 

249 namespaces meet at the GDS pair, so the mapping is exact and needs no 

250 guessing at names. 

251 """ 

252 gds_pair = self.tech_info.gds_pair_for_computed_layer_name.get(name) \ 

253 or self.tech_info.gds_pair_for_layer_name.get(name) 

254 if not gds_pair: 

255 return None 

256 return self.tech_info.canonical_layer_name_by_gds_pair.get(gds_pair) 

257 

258 def claim_name(self, name: str, what: str) -> bool: 

259 """Reserve a profile name, reporting a repeat rather than emitting it twice.""" 

260 if name in self._profile_names: 

261 debug(f"{what} '{name}' is declared more than once in the process stack, " 

262 f"keeping the first declaration") 

263 return False 

264 self._profile_names.add(name) 

265 return True 

266 

267 # ------------------------------------------------------------------ build 

268 

269 def build(self) -> Any: 

270 pex25d_file = pex25d_file_pb2().PEX25DFile() 

271 pex25d_file.format_version_major = FORMAT_VERSION_MAJOR 

272 pex25d_file.format_version_minor = FORMAT_VERSION_MINOR 

273 pex25d_file.format_version_suffix = FORMAT_VERSION_SUFFIX 

274 

275 self.build_units(pex25d_file) 

276 self.build_meta(pex25d_file) 

277 self.build_ground_plane(pex25d_file) 

278 self.build_layers(pex25d_file) 

279 self.build_dielectrics(pex25d_file) 

280 self.build_conductors(pex25d_file) 

281 self.build_terminals(pex25d_file) 

282 if self.options.include_resistance: 

283 self.build_resistance(pex25d_file) 

284 self.build_domain(pex25d_file) 

285 return pex25d_file 

286 

287 def build_units(self, pex25d_file: Any) -> None: 

288 units = pex25d_file.units 

289 units.length = units.LENGTH_UNIT_UM 

290 units.grid_numerator = self._grid.numerator 

291 units.grid_denominator = self._grid.denominator 

292 units.source_dbu_numerator = self._dbu.numerator 

293 units.source_dbu_denominator = self._dbu.denominator 

294 

295 def build_meta(self, pex25d_file: Any) -> None: 

296 def add(key: str, value: str): 

297 meta = pex25d_file.metadata.add() 

298 meta.key = key 

299 meta.value = value 

300 

301 add('technology', self.tech_info.tech.name) 

302 if self.options.process_corner: 

303 add('process_corner', self.options.process_corner) 

304 add('source_cell', self.cell_name) 

305 add('generator', f"kpex {__version__}") 

306 # GRID and the layout DBU are different quantities and routinely different 

307 # values, so a consumer handing geometry back to a layout tool would 

308 # otherwise have to guess this one. 

309 add('source_dbu', str(self.pex_context.dbu)) 

310 

311 def build_ground_plane(self, pex25d_file: Any) -> None: 

312 layer = self.tech_info.process_substrate_layer 

313 substrate = layer.substrate_layer 

314 

315 ground_plane = pex25d_file.ground_plane 

316 ground_plane.name = layer.name 

317 # `height` is the distance of the substrate top below z=0. 

318 ground_plane.zhigh = self.to_grid(-substrate.height, f"{layer.name} top") 

319 ground_plane.zlow = self.to_grid(-(substrate.height + substrate.thickness), 

320 f"{layer.name} bottom") 

321 self.claim_name(layer.name, 'Ground plane') 

322 info(f"GROUND_PLANE {layer.name}: z {ground_plane.zlow} … {ground_plane.zhigh}") 

323 

324 def build_layers(self, pex25d_file: Any) -> None: 

325 for layer in self.tech_info.process_metal_layers: 

326 metal_layer = layer.metal_layer 

327 if not self.claim_name(layer.name, 'Metal'): 

328 continue 

329 metal = pex25d_file.metals.add() 

330 metal.name = layer.name 

331 metal.zlow = self.to_grid(metal_layer.z, f"{layer.name} bottom") 

332 metal.zhigh = self.to_grid(metal_layer.z + metal_layer.thickness, 

333 f"{layer.name} top") 

334 debug(f"METAL {metal.name}: z {metal.zlow} … {metal.zhigh}") 

335 

336 # A via is positioned by what it connects, never by its own z, so a change 

337 # in metal position or thickness propagates without any other edit. 

338 for layer in self.tech_info.process_metal_layers: 

339 metal_layer = layer.metal_layer 

340 if not metal_layer.HasField('contact_above'): 

341 continue 

342 contact = metal_layer.contact_above 

343 if not contact.name: 

344 continue 

345 

346 context = f"Contact '{contact.name}'" 

347 below = self.resolve_metal(contact.layer_below or layer.name, context) 

348 above = self.resolve_metal(contact.metal_above, context) 

349 if not below or not above: 

350 continue 

351 if not self.claim_name(contact.name, 'Via'): 

352 continue 

353 

354 self._via_contacts[contact.name] = contact 

355 via = pex25d_file.vias.add() 

356 via.name = contact.name 

357 via.connects_below = below 

358 via.connects_above = above 

359 debug(f"VIA {via.name}: connects {below} → {above}") 

360 

361 # A device contact (e.g. on a source/drain diffusion) lands on device geometry, which PEX25D 

362 # leaves out: the diffusion is an empty profile, an anchor at the lower end of its contacts 

363 # (see Metal), as high as the tech info's contact is thick below the metal above, 

364 # and a terminal on the device is a node at that lower face (see TERMINAL_KIND_DEVICE_TERMINAL). 

365 # Taps have no contact in the process stack, so they stay out. 

366 for layer in self.tech_info.process_diffusion_layers: 

367 contact = layer.diffusion_layer.contact_above 

368 if not contact.name: 

369 continue 

370 context = f"Contact '{contact.name}'" 

371 above = self.resolve_metal(contact.metal_above, context) 

372 if not above or not self.claim_name(layer.name, 'Diffusion'): 

373 continue 

374 if not self.claim_name(contact.name, 'Via'): 

375 continue 

376 

377 metal_above = self.metal_layer_by_name[above].metal_layer 

378 anchor = pex25d_file.metals.add() 

379 anchor.name = layer.name 

380 anchor.zhigh = self.to_grid(metal_above.z - contact.thickness, f"{contact.name} bottom") 

381 anchor.zlow = anchor.zhigh - 1 

382 debug(f"METAL {anchor.name}: z {anchor.zlow} … {anchor.zhigh}, anchor of {contact.name}") 

383 

384 self._via_contacts[contact.name] = contact 

385 self._device_contact_by_diffusion[layer.name] = contact.name 

386 via = pex25d_file.vias.add() 

387 via.name = contact.name 

388 via.connects_below = layer.name 

389 via.connects_above = above 

390 debug(f"VIA {via.name}: connects {layer.name} → {above}") 

391 

392 # ------------------------------------------------------------ dielectrics 

393 

394 @cached_property 

395 def included_dielectric_names(self) -> Set[str]: 

396 return {lyr.name for lyr in self.tech_info.filtered_dielectric_layers} 

397 

398 def outermost_profile(self, root: str) -> str: 

399 """ 

400 Follow the chain of films anchored on ``root`` and return its last link. 

401 

402 A simple dielectric has to wrap the outermost profile anchored on the 

403 object below it: wrapping an inner link would give the fill the same 

404 depth as a film it contains, which is exactly the tie a validator 

405 rejects. 

406 """ 

407 name = root 

408 seen = {root} 

409 while True: 

410 try: 

411 film = self.tech_info.conformal_dielectric_wrapping(name) 

412 except Exception as e: 

413 warning(f"Can't follow the dielectric chain above '{name}': {e}") 

414 return name 

415 if not film or film.name in seen: 

416 return name 

417 if film.name not in self.included_dielectric_names: 

418 return name 

419 name = film.name 

420 seen.add(name) 

421 

422 def conformal_thicknesses(self, layer: Any) -> Tuple[int, int, int]: 

423 """ 

424 Map a technology film onto the three PEX25D conformal thicknesses. 

425 

426 All three are measured from the surface of the profile that is wrapped, 

427 which is what makes a chain of films composable. 

428 """ 

429 if layer.layer_type != LT.LAYER_TYPE_CONFORMAL_DIELECTRIC: 

430 raise BuildError(f"'{layer.name}' is not a film") 

431 

432 conformal = layer.conformal_dielectric_layer 

433 over_um = conformal.thickness_over_metal 

434 beside_um = conformal.thickness_sidewall 

435 on_field_um = conformal.thickness_where_no_metal 

436 

437 return (self.to_grid(over_um, f"{layer.name} thickness over wrapped"), 

438 self.to_grid(beside_um, f"{layer.name} thickness beside wrapped"), 

439 self.to_grid(on_field_um, f"{layer.name} thickness on field")) 

440 

441 def build_dielectrics(self, pex25d_file: Any) -> None: 

442 layers = list(self.tech_info.tech.process_stack.layers) 

443 metal_names = [lyr.name for lyr in layers if lyr.layer_type == LT.LAYER_TYPE_METAL] 

444 

445 def next_metal_after(index: int) -> Optional[str]: 

446 for lyr in layers[index + 1:]: 

447 if lyr.layer_type == LT.LAYER_TYPE_METAL: 

448 return lyr.name 

449 return None 

450 

451 ground_plane_name = pex25d_file.ground_plane.name 

452 previous_metal: Optional[str] = None 

453 

454 for index, layer in enumerate(layers): 

455 if layer.layer_type == LT.LAYER_TYPE_METAL: 

456 previous_metal = layer.name 

457 continue 

458 

459 is_dielectric = layer.layer_type in (LT.LAYER_TYPE_FIELD_OXIDE, 

460 LT.LAYER_TYPE_SIMPLE_DIELECTRIC, 

461 LT.LAYER_TYPE_CONFORMAL_DIELECTRIC) 

462 if not is_dielectric: 

463 continue 

464 

465 if layer.layer_type != LT.LAYER_TYPE_FIELD_OXIDE \ 

466 and layer.name not in self.included_dielectric_names: 

467 debug(f"Dielectric '{layer.name}' excluded by --diel") 

468 continue 

469 

470 above = next_metal_after(index) 

471 

472 # The last simple dielectric with no metal above it is the material 

473 # that fills whatever nothing else claims. 

474 if layer.layer_type == LT.LAYER_TYPE_SIMPLE_DIELECTRIC and above is None: 

475 if not self.claim_name(layer.name, 'Background'): 

476 continue 

477 background = pex25d_file.background 

478 background.name = layer.name 

479 background.permittivity = layer.simple_dielectric_layer.dielectric_k 

480 info(f"DIELECTRIC_BACKGROUND {layer.name}: " 

481 f"k={background.permittivity}") 

482 continue 

483 

484 match layer.layer_type: 

485 case LT.LAYER_TYPE_FIELD_OXIDE: 

486 self.add_simple_dielectric( 

487 pex25d_file, 

488 layer=layer, 

489 permittivity=layer.field_oxide_layer.dielectric_k, 

490 wraps=ground_plane_name, 

491 below=ground_plane_name, 

492 above=above or (metal_names[0] if metal_names else None)) 

493 

494 case LT.LAYER_TYPE_SIMPLE_DIELECTRIC: 

495 below = previous_metal or ground_plane_name 

496 self.add_simple_dielectric( 

497 pex25d_file, 

498 layer=layer, 

499 permittivity=layer.simple_dielectric_layer.dielectric_k, 

500 wraps=self.outermost_profile(below), 

501 below=below, 

502 above=above) 

503 

504 case LT.LAYER_TYPE_CONFORMAL_DIELECTRIC: 

505 self.add_conformal_dielectric(pex25d_file, layer=layer) 

506 

507 def add_simple_dielectric(self, 

508 pex25d_file: Any, 

509 layer: Any, 

510 permittivity: float, 

511 wraps: str, 

512 below: Optional[str], 

513 above: Optional[str]) -> None: 

514 if not below or not above: 

515 warning(f"Simple dielectric '{layer.name}' has no metal below or above it " 

516 f"in the process stack; skipping") 

517 return 

518 if not self.claim_name(layer.name, 'Simple dielectric'): 

519 return 

520 

521 dielectric = pex25d_file.dielectrics.add() 

522 dielectric.name = layer.name 

523 dielectric.kind = pex25d_dielectric_pb2().DIELECTRIC_KIND_SIMPLE 

524 dielectric.permittivity = permittivity 

525 dielectric.wraps = wraps 

526 dielectric.simple.between_below = below 

527 dielectric.simple.between_above = above 

528 debug(f"DIELECTRIC_SIMPLE {layer.name}: k={permittivity} " 

529 f"wraps {wraps} between {below} {above}") 

530 

531 def add_conformal_dielectric(self, pex25d_file: Any, layer: Any) -> None: 

532 wraps = layer.conformal_dielectric_layer.reference 

533 permittivity = layer.conformal_dielectric_layer.dielectric_k 

534 

535 if wraps not in self._profile_names: 

536 warning(f"Dielectric '{layer.name}' wraps '{wraps}', which is not a " 

537 f"declared profile; skipping") 

538 return 

539 if not self.claim_name(layer.name, 'Conformal dielectric'): 

540 return 

541 

542 over, beside, on_field = self.conformal_thicknesses(layer) 

543 

544 dielectric = pex25d_file.dielectrics.add() 

545 dielectric.name = layer.name 

546 dielectric.kind = pex25d_dielectric_pb2().DIELECTRIC_KIND_CONFORMAL 

547 dielectric.permittivity = permittivity 

548 dielectric.wraps = wraps 

549 dielectric.conformal.thickness_over_wrapped = over 

550 dielectric.conformal.thickness_beside_wrapped = beside 

551 dielectric.conformal.thickness_on_field = on_field 

552 debug(f"DIELECTRIC_CONFORMAL {layer.name}: k={permittivity} wraps {wraps} " 

553 f"over={over} beside={beside} on_field={on_field}") 

554 

555 # ------------------------------------------------- conductors and shapes 

556 

557 @cached_property 

558 def shape_layers(self) -> List[str]: 

559 """Profiles that may carry drawn geometry: metals and vias, in stack order.""" 

560 names: List[str] = list(self._device_contact_by_diffusion.values()) 

561 for layer in self.tech_info.process_metal_layers: 

562 if layer.name in self._profile_names: 

563 names.append(layer.name) 

564 contact = layer.metal_layer.contact_above 

565 if contact.name and contact.name in self._profile_names: 

566 names.append(contact.name) 

567 return names 

568 

569 def build_conductors(self, pex25d_file: Any) -> None: 

570 circuit = self.pex_context.top_circuit 

571 if circuit is None: 

572 raise BuildError(f"No extracted circuit for cell '{self.cell_name}'") 

573 

574 num_shapes = 0 

575 for net in circuit.each_net(): 

576 net_name = net.expanded_name() 

577 shapes_by_layer: List[Tuple[str, kdb.Region]] = [] 

578 

579 for layer_name in self.shape_layers: 

580 region = self.shapes_of_net(layer_name=layer_name, net=net) 

581 if region and not region.is_empty(): 

582 shapes_by_layer.append((layer_name, region)) 

583 

584 if not shapes_by_layer: 

585 debug(f"Net {net_name} has no interconnect geometry") 

586 continue 

587 

588 conductor = pex25d_file.conductors.add() 

589 conductor.name = net_name 

590 conductor.net = net_name 

591 self._conductor_regions[net_name] = dict(shapes_by_layer) 

592 

593 for layer_name, region in shapes_by_layer: 

594 count = self.add_shapes(pex25d_file, 

595 conductor=net_name, 

596 layer=layer_name, 

597 region=region) 

598 num_shapes += count 

599 debug(f"Conductor {net_name}, layer {layer_name}: {count} shape(s)") 

600 

601 info(f"{len(pex25d_file.conductors)} conductor(s), {num_shapes} shape record(s)") 

602 

603 def add_shapes(self, 

604 pex25d_file: Any, 

605 conductor: str, 

606 layer: str, 

607 region: kdb.Region) -> int: 

608 """ 

609 Emit one record per polygon. 

610 

611 Every via cut becomes its own record: PEX25D has no via-array construct, 

612 so that the dielectric between the cuts is present in the scene. Merging 

613 keeps disjoint cuts separate, which is what makes that work. 

614 """ 

615 kinds = pex25d_file_pb2().ShapeRecord 

616 count = 0 

617 

618 for polygon in region.each_merged(): 

619 record = pex25d_file.shapes.add() 

620 record.conductor = conductor 

621 record.layer = layer 

622 

623 if polygon.is_box(): 

624 box = polygon.bbox() 

625 record.kind = kinds.SHAPE_KIND_BOX 

626 record.box.lower_left.x = self.dbu_to_grid(box.left) 

627 record.box.lower_left.y = self.dbu_to_grid(box.bottom) 

628 record.box.upper_right.x = self.dbu_to_grid(box.right) 

629 record.box.upper_right.y = self.dbu_to_grid(box.top) 

630 else: 

631 record.kind = kinds.SHAPE_KIND_POLYGON 

632 self.fill_ring(record.polygon.outer, polygon.each_point_hull()) 

633 for hole_index in range(polygon.holes()): 

634 self.fill_ring(record.polygon.holes.add(), 

635 polygon.each_point_hole(hole_index)) 

636 count += 1 

637 

638 return count 

639 

640 def fill_ring(self, ring: Any, points: Iterable[kdb.Point]) -> None: 

641 """Rings are implicitly closed, so the first vertex is not repeated.""" 

642 for point in points: 

643 vertex = ring.points.add() 

644 vertex.x = self.dbu_to_grid(point.x) 

645 vertex.y = self.dbu_to_grid(point.y) 

646 

647 # -------------------------------------------------------------- terminals 

648 

649 def add_terminal(self, pex25d_file: Any, name: str, conductor: str, 

650 layer: str, kind: int, box: kdb.Box) -> None: 

651 base = name 

652 suffix = 2 

653 while name in self._terminal_names: 

654 name = f'{base}.{suffix}' 

655 suffix += 1 

656 self._terminal_names.add(name) 

657 terminal = pex25d_file.terminals.add() 

658 terminal.name, terminal.conductor, terminal.layer = name, conductor, layer 

659 terminal.kind = kind 

660 terminal.region.lower_left.x = self.dbu_to_grid(box.left) 

661 terminal.region.lower_left.y = self.dbu_to_grid(box.bottom) 

662 terminal.region.upper_right.x = self.dbu_to_grid(box.right) 

663 terminal.region.upper_right.y = self.dbu_to_grid(box.top) 

664 

665 def build_terminals(self, pex25d_file: Any) -> None: 

666 kinds = pex25d_terminal_pb2() 

667 # Metals can share their drawn layer (e.g. sky130A met3_ncap and met3_cap, without and with 

668 # a MiM cap above), whose pins are then on the one under each label. 

669 metal_names_by_gds_pair: Dict[GDSPair, List[str]] = {} 

670 for metal in pex25d_file.metals: 

671 canonical = self.canonical_name(metal.name) or metal.name 

672 gds_pair = self.tech_info.gds_pair_for_layer_name.get(canonical) 

673 if gds_pair not in self.tech_info.layer_info_by_gds_pair: 

674 continue 

675 metal_names_by_gds_pair.setdefault(gds_pair, []).append(metal.name) 

676 

677 for gds_pair, metal_names in metal_names_by_gds_pair.items(): 

678 pins = self.pex_context.pins_of_layer(gds_pair) 

679 if pins.is_empty(): 

680 continue 

681 labels = self.pex_context.labels_of_layer(gds_pair) & pins 

682 seen = set() 

683 for label in labels: 

684 point = label.position() 

685 key = (label.string, point.x, point.y) 

686 if key in seen: 

687 continue 

688 seen.add(key) 

689 candidates: List[Tuple[str, str]] = [] # conductor and metal at the label 

690 for net_name, regions in self._conductor_regions.items(): 

691 for metal_name in metal_names: 

692 region = regions.get(metal_name) 

693 if region is not None and any(p.inside(point) for p in region.each_merged()): 

694 candidates.append((net_name, metal_name)) 

695 break 

696 if len(candidates) != 1: 

697 raise BuildError(f"Pin '{label.string}' on '{'/'.join(metal_names)}' must select one " 

698 f"conductor at its label; found {len(candidates)}") 

699 net_name, metal_name = candidates[0] 

700 # A pin marker may cover the entire wire; only the label is the port. 

701 box = kdb.Box(point.x - 1, point.y - 1, point.x + 1, point.y + 1) 

702 self.add_terminal(pex25d_file, f'pin:{quote(label.string, safe="._-$[]")}', 

703 net_name, metal_name, kinds.TERMINAL_KIND_PIN, box) 

704 

705 devices_by_terminal_without_node: Dict[Tuple[str, str, str], List[str]] = defaultdict(list) 

706 for net in self.pex_context.top_circuit.each_net(): 

707 net_name = net.expanded_name() 

708 regions = self._conductor_regions.get(net_name) 

709 if not regions: 

710 continue 

711 for ref in net.each_terminal(): 

712 device = ref.device() 

713 definition = device.device_class().terminal_definition(ref.terminal_id()) 

714 name = (f'device:{quote(device.expanded_name(), safe="._-$[]")}:' 

715 f'{quote(definition.name, safe="._-$[]")}') 

716 selected = {} 

717 unknown_markers = kdb.Region() 

718 unknown_lvs_layer_names: List[str] = [] 

719 for index, marker in self.pex_context.lvsdb.shapes_of_terminal(ref).items(): 

720 source_name = self.pex_context.lvsdb.layer_name(index) 

721 if source_name in self.substrate_lvs_layer_names: 

722 # NOTE: the substrate is the ground plane, a terminal on it needs no node 

723 # (see TERMINAL_KIND_DEVICE_TERMINAL), e.g. the terminal of a cap to the substrate 

724 continue 

725 if source_name not in self.tech_info.gds_pair_for_computed_layer_name \ 

726 and source_name not in self.tech_info.gds_pair_for_layer_name: 

727 unknown_markers += marker 

728 unknown_lvs_layer_names.append(source_name) 

729 continue 

730 pair = self.gds_pair(source_name) 

731 # Metals can share a GDS pair (e.g. sky130A met3_ncap and met3_cap, whose MiM cap 

732 # bottom plates abut the rest), the terminal is on the one of its LVS layer. 

733 owner = self.process_layer_name_by_lvs_layer_name.get(source_name) 

734 contact = self._device_contact_by_diffusion.get(owner) 

735 if contact is not None: 

736 # NOTE: device geometry (e.g. a source/drain), the terminal is on the cuts of its contacts, 

737 # whole, as a terminal may not clip a cut 

738 cuts = regions.get(contact, kdb.Region()).overlapping(marker) 

739 if not cuts.is_empty(): 

740 selected.setdefault(contact, kdb.Region()).insert(cuts) 

741 continue 

742 for layer, geometry in regions.items(): 

743 if self.gds_pair(layer) != pair or owner not in (None, layer): 

744 continue 

745 intersection = geometry & marker 

746 if intersection.is_empty(): 

747 # Abutting device geometry needs a narrow strip inside the interconnect. 

748 intersection = geometry & marker.sized(1) 

749 if not intersection.is_empty(): 

750 selected.setdefault(layer, kdb.Region()).insert(intersection) 

751 if not selected and unknown_lvs_layer_names: 

752 found = self.terminal_on_overlapped_interconnect(regions, unknown_markers) 

753 if found is None: 

754 key = (device.device_class().name, definition.name, ', '.join(unknown_lvs_layer_names)) 

755 devices_by_terminal_without_node[key].append(device.expanded_name()) 

756 continue 

757 selected[found[0]] = found[1] 

758 if not selected: 

759 debug(f"Device terminal '{name}' has no emitted interconnect boundary") 

760 continue 

761 if len(selected) != 1: 

762 raise BuildError(f"Device terminal '{name}' spans multiple profiles " 

763 f"({', '.join(selected)}); PEX25D requires one layer per node") 

764 layer, intersection = next(iter(selected.items())) 

765 box = intersection.bbox() 

766 if not ((regions[layer] & kdb.Region(box)) - intersection).is_empty(): 

767 raise BuildError(f"Device terminal '{name}' cannot use one box on '{layer}' " 

768 f"without shorting additional interconnect") 

769 self.add_terminal(pex25d_file, name, net_name, layer, 

770 kinds.TERMINAL_KIND_DEVICE_TERMINAL, box) 

771 if devices_by_terminal_without_node: 

772 warning("PEX25D has no nodes for these device terminals, as the tech info has no layer " 

773 "for their LVS layer, and they overlap no interconnect of their net " 

774 "(e.g. a terminal on a well):\n" + 

775 '\n'.join(f" - {device_class} terminal {terminal} on LVS layer {lvs_layers}: " 

776 f"{device_list(device_names)}" 

777 for (device_class, terminal, lvs_layers), device_names 

778 in sorted(devices_by_terminal_without_node.items()))) 

779 info(f"{len(pex25d_file.terminals)} terminal record(s)") 

780 

781 def terminal_on_overlapped_interconnect(self, 

782 regions: Dict[str, kdb.Region], 

783 marker: kdb.Region) -> Optional[Tuple[str, kdb.Region]]: 

784 """ 

785 Where a device terminal on LVS layers the tech info has no layer for (e.g. the ports of a MOM cap) 

786 is on the interconnect of its net, as in the resistance extraction (#237) 

787 

788 :return: the profile and its part: the cuts of device contacts it overlaps, whole, as of a source/drain 

789 (they stand in for the diffusion), else the part of the metal it overlaps most, the lowest of equals; 

790 None if it overlaps none of them (e.g. a terminal on a well) 

791 """ 

792 for contact in self._device_contact_by_diffusion.values(): 

793 cuts = regions.get(contact, kdb.Region()).overlapping(marker) 

794 if not cuts.is_empty(): 

795 return contact, cuts 

796 best: Optional[Tuple[float, str, kdb.Region]] = None 

797 for layer in self.shape_layers: 

798 geometry = regions.get(layer) 

799 if geometry is None or layer in self._via_contacts: 

800 continue 

801 overlap = geometry & marker 

802 area = overlap.area() 

803 if area > 0 and (best is None or area > best[0]): 

804 best = (area, layer, overlap) 

805 return None if best is None else (best[1], best[2]) 

806 

807 # ------------------------------------------------------------- resistance 

808 

809 def build_resistance(self, pex25d_file: Any) -> None: 

810 tech = self.tech_info 

811 

812 for layer in tech.process_metal_layers: 

813 if layer.name not in self._profile_names: 

814 continue 

815 canonical = self.canonical_name(layer.name) or layer.name 

816 layer_resistance = tech.layer_resistance_by_layer_name.get(canonical) 

817 if layer_resistance is None or not layer_resistance.resistance: 

818 debug(f"No sheet resistance for '{layer.name}' (canonical '{canonical}')") 

819 continue 

820 record = pex25d_file.metal_resistances.add() 

821 record.metal = layer.name 

822 record.sheet = tech.milliohm_to_ohm(layer_resistance.resistance) 

823 

824 for via_name, contact in self._via_contacts.items(): 

825 canonical = self.canonical_name(via_name) or via_name 

826 

827 # A via's resistance is given per cut, either in the via table or — 

828 # for the contacts that land on a device layer — in the contact table. 

829 resistance = tech.via_resistance_by_layer_name.get(canonical) 

830 if resistance is None: 

831 resistance = tech.contact_resistance_by_device_layer_name.get( 

832 contact.layer_below) 

833 if resistance is None or not resistance.resistance: 

834 debug(f"No via resistance for '{via_name}' (canonical '{canonical}')") 

835 continue 

836 

837 record = pex25d_file.via_resistances.add() 

838 record.via = via_name 

839 record.per_cut = tech.milliohm_to_ohm(resistance.resistance) 

840 

841 info(f"{len(pex25d_file.metal_resistances)} metal and " 

842 f"{len(pex25d_file.via_resistances)} via resistance record(s)") 

843 

844 # ----------------------------------------------------------------- domain 

845 

846 def build_domain(self, pex25d_file: Any) -> None: 

847 """ 

848 Emit a computational domain only when one was asked for. 

849 

850 With neither record present the domain is simply unset, and choosing one 

851 is the solver adapter's business. 

852 """ 

853 if self.options.domain_margin_um is None: 

854 return 

855 

856 margin = self.to_grid(self.options.domain_margin_um, 'domain margin') 

857 pex25d_file.domain_margin.x = margin 

858 pex25d_file.domain_margin.y = margin 

859 pex25d_file.domain_margin.z = margin 

860 info(f"DOMAIN_MARGIN {margin} grid units in x, y and z") 

861 

862 

863def build_pex25d_file(pex_context: KLayoutExtractionContext, 

864 tech_info: 'TechInfo', 

865 cell_name: str, 

866 options: Optional[BuilderOptions] = None) -> Any: 

867 """Assemble a ``kpex.pex25d.PEX25DFile`` for ``cell_name``.""" 

868 builder = PEX25DBuilder(pex_context=pex_context, 

869 tech_info=tech_info, 

870 cell_name=cell_name, 

871 options=options) 

872 return builder.build()