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
« 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#
25"""Generation of a ``PEX25DFile`` from LVS connectivity and the PDK stack."""
27from __future__ import annotations
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
36import klayout.db as kdb
38import klayout_pex_protobuf.kpex.tech.process_stack_pb2 as process_stack_pb2
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
51if TYPE_CHECKING:
52 from ..tech_info import TechInfo
55LT = process_stack_pb2.ProcessStackInfo.LayerType
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'
63class BuildError(Exception):
64 pass
67@dataclass
68class BuilderOptions:
69 """Options the CLI exposes for PEX25D generation."""
71 grid_um: str = DEFAULT_GRID_UM
72 """Coordinate grid, as a decimal string so that it stays an exact rational."""
74 with_source_refs: bool = False
75 """Populate every ``SourceRef``. Roughly doubles the size of a file."""
77 dielectric_filter: Optional[Any] = None
78 """The ``--diel`` multiple-choice pattern, as used by the FasterCap path."""
80 include_resistance: bool = True
81 """Emit ``RESISTANCE`` records for the layers the technology defines."""
83 domain_margin_um: Optional[float] = None
84 """Emit ``DOMAIN_MARGIN`` with this clearance. None leaves the domain unset."""
86 process_corner: Optional[str] = None
87 """Value for ``META process_corner``, if known."""
90class PEX25DBuilder:
91 """
92 Assembles a ``kpex.pex25d.PEX25DFile`` from a technology stack and the
93 connectivity of an LVS run.
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 """
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()
111 self._grid = Fraction(self.options.grid_um)
112 self._dbu = Fraction(str(pex_context.dbu))
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()
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)
132 # ------------------------------------------------------------ conversions
134 def to_grid(self, value_um: float, what: str) -> int:
135 """
136 Convert a µm value from the technology into grid units.
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)
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
155 # ----------------------------------------------------------------- lookup
157 def gds_pair(self, layer_name: str) -> Optional[GDSPair]:
158 return self.tech_info.gds_pair(layer_name)
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)
171 @cached_property
172 def substrate_lvs_layer_names(self) -> Set[str]:
173 return set(self.tech_info.tech.substrate.lvs_layer_names)
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}
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}
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
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.
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
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
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]
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
243 def canonical_name(self, name: str) -> Optional[str]:
244 """
245 The canonical layer name behind a profile name.
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)
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
267 # ------------------------------------------------------------------ build
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
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
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
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
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))
311 def build_ground_plane(self, pex25d_file: Any) -> None:
312 layer = self.tech_info.process_substrate_layer
313 substrate = layer.substrate_layer
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}")
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}")
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
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
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}")
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
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}")
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}")
392 # ------------------------------------------------------------ dielectrics
394 @cached_property
395 def included_dielectric_names(self) -> Set[str]:
396 return {lyr.name for lyr in self.tech_info.filtered_dielectric_layers}
398 def outermost_profile(self, root: str) -> str:
399 """
400 Follow the chain of films anchored on ``root`` and return its last link.
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)
422 def conformal_thicknesses(self, layer: Any) -> Tuple[int, int, int]:
423 """
424 Map a technology film onto the three PEX25D conformal thicknesses.
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")
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
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"))
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]
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
451 ground_plane_name = pex25d_file.ground_plane.name
452 previous_metal: Optional[str] = None
454 for index, layer in enumerate(layers):
455 if layer.layer_type == LT.LAYER_TYPE_METAL:
456 previous_metal = layer.name
457 continue
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
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
470 above = next_metal_after(index)
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
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))
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)
504 case LT.LAYER_TYPE_CONFORMAL_DIELECTRIC:
505 self.add_conformal_dielectric(pex25d_file, layer=layer)
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
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}")
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
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
542 over, beside, on_field = self.conformal_thicknesses(layer)
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}")
555 # ------------------------------------------------- conductors and shapes
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
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}'")
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]] = []
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))
584 if not shapes_by_layer:
585 debug(f"Net {net_name} has no interconnect geometry")
586 continue
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)
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)")
601 info(f"{len(pex25d_file.conductors)} conductor(s), {num_shapes} shape record(s)")
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.
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
618 for polygon in region.each_merged():
619 record = pex25d_file.shapes.add()
620 record.conductor = conductor
621 record.layer = layer
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
638 return count
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)
647 # -------------------------------------------------------------- terminals
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)
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)
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)
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)")
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)
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])
807 # ------------------------------------------------------------- resistance
809 def build_resistance(self, pex25d_file: Any) -> None:
810 tech = self.tech_info
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)
824 for via_name, contact in self._via_contacts.items():
825 canonical = self.canonical_name(via_name) or via_name
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
837 record = pex25d_file.via_resistances.add()
838 record.via = via_name
839 record.per_cut = tech.milliohm_to_ohm(resistance.resistance)
841 info(f"{len(pex25d_file.metal_resistances)} metal and "
842 f"{len(pex25d_file.via_resistances)} via resistance record(s)")
844 # ----------------------------------------------------------------- domain
846 def build_domain(self, pex25d_file: Any) -> None:
847 """
848 Emit a computational domain only when one was asked for.
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
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")
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()