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

444 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-17 19:08 +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 dataclasses import dataclass, field 

30from fractions import Fraction 

31from functools import cached_property 

32from typing import * 

33from urllib.parse import quote 

34 

35import klayout.db as kdb 

36 

37import klayout_pex_protobuf.kpex.tech.process_stack_pb2 as process_stack_pb2 

38 

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

40from ..pex25d.format_version import ( 

41 FORMAT_VERSION_MAJOR, 

42 FORMAT_VERSION_MINOR, 

43 FORMAT_VERSION_SUFFIX, 

44) 

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

46from ..version import __version__ 

47from .lvsdb_extractor import GDSPair, KLayoutExtractionContext 

48 

49if TYPE_CHECKING: 

50 from ..tech_info import TechInfo 

51 

52 

53LT = process_stack_pb2.ProcessStackInfo.LayerType 

54 

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

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

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

58DEFAULT_GRID_UM = '0.0001' 

59 

60 

61class BuildError(Exception): 

62 pass 

63 

64 

65@dataclass 

66class BuilderOptions: 

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

68 

69 grid_um: str = DEFAULT_GRID_UM 

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

71 

72 with_source_refs: bool = False 

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

74 

75 dielectric_filter: Optional[Any] = None 

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

77 

78 include_resistance: bool = True 

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

80 

81 domain_margin_um: Optional[float] = None 

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

83 

84 process_corner: Optional[str] = None 

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

86 

87 

88class PEX25DBuilder: 

89 """ 

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

91 connectivity of an LVS run. 

92 

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

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

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

96 remaining interconnect. 

97 """ 

98 

99 def __init__(self, 

100 pex_context: KLayoutExtractionContext, 

101 tech_info: 'TechInfo', 

102 cell_name: str, 

103 options: Optional[BuilderOptions] = None): 

104 self.pex_context = pex_context 

105 self.tech_info = tech_info 

106 self.cell_name = cell_name 

107 self.options = options or BuilderOptions() 

108 

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

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

111 

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

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

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

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

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

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

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

119 

120 scale = self._dbu / self._grid 

121 if scale.denominator != 1: 

122 raise BuildError( 

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

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

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

126 ) 

127 self._dbu_scale = int(scale) 

128 

129 # ------------------------------------------------------------ conversions 

130 

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

132 """ 

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

134 

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

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

137 would reject perfectly legal values. 

138 """ 

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

140 rounded = round(quotient) 

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

142 raise BuildError( 

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

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

145 ) 

146 return int(rounded) 

147 

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

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

150 return value * self._dbu_scale 

151 

152 # ----------------------------------------------------------------- lookup 

153 

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

155 return self.tech_info.gds_pair(layer_name) 

156 

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

158 gds_pair = self.gds_pair(layer_name) 

159 if not gds_pair: 

160 return None 

161 return self.pex_context.shapes_of_net(gds_pair=gds_pair, net=net) 

162 

163 @cached_property 

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

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

166 

167 @cached_property 

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

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

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

171 for name in self.metal_layer_by_name: 

172 canonical = self.canonical_name(name) 

173 if canonical: 

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

175 return groups 

176 

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

178 """ 

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

180 

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

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

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

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

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

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

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

188 anything else is reported rather than guessed. 

189 """ 

190 if name in self.metal_layer_by_name: 

191 return name 

192 

193 canonical = self.canonical_name(name) 

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

195 if canonical else [] 

196 if not candidates: 

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

198 return None 

199 

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

201 if chosen is None: 

202 with_contact = [n for n in candidates 

203 if self.metal_layer_by_name[n].metal_layer 

204 .HasField('contact_above')] 

205 if len(with_contact) != 1: 

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

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

208 return None 

209 chosen = with_contact[0] 

210 

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

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

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

214 # A genuine difference is. 

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

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

217 for n in candidates} 

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

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

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

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

222 return chosen 

223 

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

225 """ 

226 The canonical layer name behind a profile name. 

227 

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

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

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

231 guessing at names. 

232 """ 

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

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

235 if not gds_pair: 

236 return None 

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

238 

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

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

241 if name in self._profile_names: 

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

243 f"keeping the first declaration") 

244 return False 

245 self._profile_names.add(name) 

246 return True 

247 

248 # ------------------------------------------------------------------ build 

249 

250 def build(self) -> Any: 

251 pex25d_file = pex25d_file_pb2().PEX25DFile() 

252 pex25d_file.format_version_major = FORMAT_VERSION_MAJOR 

253 pex25d_file.format_version_minor = FORMAT_VERSION_MINOR 

254 pex25d_file.format_version_suffix = FORMAT_VERSION_SUFFIX 

255 

256 self.build_units(pex25d_file) 

257 self.build_meta(pex25d_file) 

258 self.build_ground_plane(pex25d_file) 

259 self.build_layers(pex25d_file) 

260 self.build_dielectrics(pex25d_file) 

261 self.build_conductors(pex25d_file) 

262 self.build_terminals(pex25d_file) 

263 if self.options.include_resistance: 

264 self.build_resistance(pex25d_file) 

265 self.build_domain(pex25d_file) 

266 return pex25d_file 

267 

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

269 units = pex25d_file.units 

270 units.length = units.LENGTH_UNIT_UM 

271 units.grid_numerator = self._grid.numerator 

272 units.grid_denominator = self._grid.denominator 

273 units.source_dbu_numerator = self._dbu.numerator 

274 units.source_dbu_denominator = self._dbu.denominator 

275 

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

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

278 meta = pex25d_file.meta.add() 

279 meta.key = key 

280 meta.value = value 

281 

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

283 if self.options.process_corner: 

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

285 add('source_cell', self.cell_name) 

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

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

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

289 # otherwise have to guess this one. 

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

291 

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

293 layer = self.tech_info.process_substrate_layer 

294 substrate = layer.substrate_layer 

295 

296 ground_plane = pex25d_file.ground_plane 

297 ground_plane.name = layer.name 

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

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

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

301 f"{layer.name} bottom") 

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

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

304 

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

306 for layer in self.tech_info.process_metal_layers: 

307 metal_layer = layer.metal_layer 

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

309 continue 

310 metal = pex25d_file.metals.add() 

311 metal.name = layer.name 

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

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

314 f"{layer.name} top") 

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

316 

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

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

319 for layer in self.tech_info.process_metal_layers: 

320 metal_layer = layer.metal_layer 

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

322 continue 

323 contact = metal_layer.contact_above 

324 if not contact.name: 

325 continue 

326 

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

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

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

330 if not below or not above: 

331 continue 

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

333 continue 

334 

335 self._via_contacts[contact.name] = contact 

336 via = pex25d_file.vias.add() 

337 via.name = contact.name 

338 via.connects_below = below 

339 via.connects_above = above 

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

341 

342 # Device contacts (diffusion, nwell) are deliberately absent: their lower 

343 # endpoint is device geometry, which PEX25D does not describe. 

344 for name, contact in self.tech_info.contact_by_device_lvs_layer_name.items(): 

345 if contact.name: 

346 debug(f"Skipping device contact '{contact.name}' on '{name}': " 

347 f"device geometry is out of scope for PEX25D") 

348 

349 # ------------------------------------------------------------ dielectrics 

350 

351 @cached_property 

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

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

354 

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

356 """ 

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

358 

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

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

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

362 rejects. 

363 """ 

364 name = root 

365 seen = {root} 

366 while True: 

367 try: 

368 film = self.tech_info.conformal_dielectric_wrapping(name) 

369 except Exception as e: 

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

371 return name 

372 if not film or film.name in seen: 

373 return name 

374 if film.name not in self.included_dielectric_names: 

375 return name 

376 name = film.name 

377 seen.add(name) 

378 

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

380 """ 

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

382 

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

384 which is what makes a chain of films composable. 

385 """ 

386 if layer.layer_type != LT.LAYER_TYPE_CONFORMAL_DIELECTRIC: 

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

388 

389 conformal = layer.conformal_dielectric_layer 

390 over_um = conformal.thickness_over_metal 

391 beside_um = conformal.thickness_sidewall 

392 on_field_um = conformal.thickness_where_no_metal 

393 

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

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

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

397 

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

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

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

401 

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

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

404 if lyr.layer_type == LT.LAYER_TYPE_METAL: 

405 return lyr.name 

406 return None 

407 

408 ground_plane_name = pex25d_file.ground_plane.name 

409 previous_metal: Optional[str] = None 

410 

411 for index, layer in enumerate(layers): 

412 if layer.layer_type == LT.LAYER_TYPE_METAL: 

413 previous_metal = layer.name 

414 continue 

415 

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

417 LT.LAYER_TYPE_SIMPLE_DIELECTRIC, 

418 LT.LAYER_TYPE_CONFORMAL_DIELECTRIC) 

419 if not is_dielectric: 

420 continue 

421 

422 if layer.layer_type != LT.LAYER_TYPE_FIELD_OXIDE \ 

423 and layer.name not in self.included_dielectric_names: 

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

425 continue 

426 

427 above = next_metal_after(index) 

428 

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

430 # that fills whatever nothing else claims. 

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

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

433 continue 

434 background = pex25d_file.background 

435 background.name = layer.name 

436 background.permittivity = layer.simple_dielectric_layer.dielectric_k 

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

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

439 continue 

440 

441 match layer.layer_type: 

442 case LT.LAYER_TYPE_FIELD_OXIDE: 

443 self.add_simple_dielectric( 

444 pex25d_file, 

445 layer=layer, 

446 permittivity=layer.field_oxide_layer.dielectric_k, 

447 wraps=ground_plane_name, 

448 below=ground_plane_name, 

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

450 

451 case LT.LAYER_TYPE_SIMPLE_DIELECTRIC: 

452 below = previous_metal or ground_plane_name 

453 self.add_simple_dielectric( 

454 pex25d_file, 

455 layer=layer, 

456 permittivity=layer.simple_dielectric_layer.dielectric_k, 

457 wraps=self.outermost_profile(below), 

458 below=below, 

459 above=above) 

460 

461 case LT.LAYER_TYPE_CONFORMAL_DIELECTRIC: 

462 self.add_conformal_dielectric(pex25d_file, layer=layer) 

463 

464 def add_simple_dielectric(self, 

465 pex25d_file: Any, 

466 layer: Any, 

467 permittivity: float, 

468 wraps: str, 

469 below: Optional[str], 

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

471 if not below or not above: 

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

473 f"in the process stack; skipping") 

474 return 

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

476 return 

477 

478 dielectric = pex25d_file.dielectrics.add() 

479 dielectric.name = layer.name 

480 dielectric.kind = pex25d_dielectric_pb2().DIELECTRIC_KIND_SIMPLE 

481 dielectric.permittivity = permittivity 

482 dielectric.wraps = wraps 

483 dielectric.simple.between_below = below 

484 dielectric.simple.between_above = above 

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

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

487 

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

489 wraps = layer.conformal_dielectric_layer.reference 

490 permittivity = layer.conformal_dielectric_layer.dielectric_k 

491 

492 if wraps not in self._profile_names: 

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

494 f"declared profile; skipping") 

495 return 

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

497 return 

498 

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

500 

501 dielectric = pex25d_file.dielectrics.add() 

502 dielectric.name = layer.name 

503 dielectric.kind = pex25d_dielectric_pb2().DIELECTRIC_KIND_CONFORMAL 

504 dielectric.permittivity = permittivity 

505 dielectric.wraps = wraps 

506 dielectric.conformal.thickness_over_wrapped = over 

507 dielectric.conformal.thickness_beside_wrapped = beside 

508 dielectric.conformal.thickness_on_field = on_field 

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

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

511 

512 # ------------------------------------------------- conductors and shapes 

513 

514 @cached_property 

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

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

517 names: List[str] = [] 

518 for layer in self.tech_info.process_metal_layers: 

519 if layer.name in self._profile_names: 

520 names.append(layer.name) 

521 contact = layer.metal_layer.contact_above 

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

523 names.append(contact.name) 

524 return names 

525 

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

527 circuit = self.pex_context.top_circuit 

528 if circuit is None: 

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

530 

531 num_shapes = 0 

532 for net in circuit.each_net(): 

533 net_name = net.expanded_name() 

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

535 

536 for layer_name in self.shape_layers: 

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

538 if region and not region.is_empty(): 

539 shapes_by_layer.append((layer_name, region)) 

540 

541 if not shapes_by_layer: 

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

543 continue 

544 

545 conductor = pex25d_file.conductors.add() 

546 conductor.name = net_name 

547 conductor.net = net_name 

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

549 

550 for layer_name, region in shapes_by_layer: 

551 count = self.add_shapes(pex25d_file, 

552 conductor=net_name, 

553 layer=layer_name, 

554 region=region) 

555 num_shapes += count 

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

557 

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

559 

560 def add_shapes(self, 

561 pex25d_file: Any, 

562 conductor: str, 

563 layer: str, 

564 region: kdb.Region) -> int: 

565 """ 

566 Emit one record per polygon. 

567 

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

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

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

571 """ 

572 kinds = pex25d_file_pb2().ShapeRecord 

573 count = 0 

574 

575 for polygon in region.each_merged(): 

576 record = pex25d_file.shapes.add() 

577 record.conductor = conductor 

578 record.layer = layer 

579 

580 if polygon.is_box(): 

581 box = polygon.bbox() 

582 record.kind = kinds.SHAPE_KIND_BOX 

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

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

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

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

587 else: 

588 record.kind = kinds.SHAPE_KIND_POLYGON 

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

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

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

592 polygon.each_point_hole(hole_index)) 

593 count += 1 

594 

595 return count 

596 

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

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

599 for point in points: 

600 vertex = ring.points.add() 

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

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

603 

604 # -------------------------------------------------------------- terminals 

605 

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

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

608 base = name 

609 suffix = 2 

610 while name in self._terminal_names: 

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

612 suffix += 1 

613 self._terminal_names.add(name) 

614 terminal = pex25d_file.terminals.add() 

615 terminal.name, terminal.conductor, terminal.layer = name, conductor, layer 

616 terminal.kind = kind 

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

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

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

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

621 

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

623 kinds = pex25d_terminal_pb2() 

624 for metal in pex25d_file.metals: 

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

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

627 if gds_pair not in self.tech_info.layer_info_by_gds_pair: 

628 continue 

629 pins = self.pex_context.pins_of_layer(gds_pair) 

630 if pins.is_empty(): 

631 continue 

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

633 seen = set() 

634 for label in labels: 

635 point = label.position() 

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

637 if key in seen: 

638 continue 

639 seen.add(key) 

640 candidates = [] 

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

642 region = regions.get(metal.name) 

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

644 candidates.append(net_name) 

645 if len(candidates) != 1: 

646 raise BuildError(f"Pin '{label.string}' on '{metal.name}' must select one " 

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

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

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

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

651 candidates[0], metal.name, kinds.TERMINAL_KIND_PIN, box) 

652 

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

654 net_name = net.expanded_name() 

655 regions = self._conductor_regions.get(net_name) 

656 if not regions: 

657 continue 

658 for ref in net.each_terminal(): 

659 device = ref.device() 

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

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

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

663 selected = {} 

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

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

666 pair = self.gds_pair(source_name) 

667 if pair is None: 

668 continue 

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

670 if self.gds_pair(layer) != pair: 

671 continue 

672 intersection = geometry & marker 

673 if intersection.is_empty(): 

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

675 intersection = geometry & marker.sized(1) 

676 if not intersection.is_empty(): 

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

678 if not selected: 

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

680 continue 

681 if len(selected) != 1: 

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

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

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

685 box = intersection.bbox() 

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

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

688 f"without shorting additional interconnect") 

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

690 kinds.TERMINAL_KIND_DEVICE_TERMINAL, box) 

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

692 

693 # ------------------------------------------------------------- resistance 

694 

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

696 tech = self.tech_info 

697 

698 for layer in tech.process_metal_layers: 

699 if layer.name not in self._profile_names: 

700 continue 

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

702 layer_resistance = tech.layer_resistance_by_layer_name.get(canonical) 

703 if layer_resistance is None or not layer_resistance.resistance: 

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

705 continue 

706 record = pex25d_file.metal_resistances.add() 

707 record.metal = layer.name 

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

709 

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

711 canonical = self.canonical_name(via_name) or via_name 

712 

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

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

715 resistance = tech.via_resistance_by_layer_name.get(canonical) 

716 if resistance is None: 

717 resistance = tech.contact_resistance_by_device_layer_name.get( 

718 contact.layer_below) 

719 if resistance is None or not resistance.resistance: 

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

721 continue 

722 

723 record = pex25d_file.via_resistances.add() 

724 record.via = via_name 

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

726 

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

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

729 

730 # ----------------------------------------------------------------- domain 

731 

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

733 """ 

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

735 

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

737 is the solver adapter's business. 

738 """ 

739 if self.options.domain_margin_um is None: 

740 return 

741 

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

743 pex25d_file.domain_margin.x = margin 

744 pex25d_file.domain_margin.y = margin 

745 pex25d_file.domain_margin.z = margin 

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

747 

748 

749def build_pex25d_file(pex_context: KLayoutExtractionContext, 

750 tech_info: 'TechInfo', 

751 cell_name: str, 

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

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

754 builder = PEX25DBuilder(pex_context=pex_context, 

755 tech_info=tech_info, 

756 cell_name=cell_name, 

757 options=options) 

758 return builder.build()