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
« 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#
25"""Generation of a ``PEX25DFile`` from LVS connectivity and the PDK stack."""
27from __future__ import annotations
29from dataclasses import dataclass, field
30from fractions import Fraction
31from functools import cached_property
32from typing import *
33from urllib.parse import quote
35import klayout.db as kdb
37import klayout_pex_protobuf.kpex.tech.process_stack_pb2 as process_stack_pb2
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
49if TYPE_CHECKING:
50 from ..tech_info import TechInfo
53LT = process_stack_pb2.ProcessStackInfo.LayerType
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'
61class BuildError(Exception):
62 pass
65@dataclass
66class BuilderOptions:
67 """Options the CLI exposes for PEX25D generation."""
69 grid_um: str = DEFAULT_GRID_UM
70 """Coordinate grid, as a decimal string so that it stays an exact rational."""
72 with_source_refs: bool = False
73 """Populate every ``SourceRef``. Roughly doubles the size of a file."""
75 dielectric_filter: Optional[Any] = None
76 """The ``--diel`` multiple-choice pattern, as used by the FasterCap path."""
78 include_resistance: bool = True
79 """Emit ``RESISTANCE`` records for the layers the technology defines."""
81 domain_margin_um: Optional[float] = None
82 """Emit ``DOMAIN_MARGIN`` with this clearance. None leaves the domain unset."""
84 process_corner: Optional[str] = None
85 """Value for ``META process_corner``, if known."""
88class PEX25DBuilder:
89 """
90 Assembles a ``kpex.pex25d.PEX25DFile`` from a technology stack and the
91 connectivity of an LVS run.
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 """
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()
109 self._grid = Fraction(self.options.grid_um)
110 self._dbu = Fraction(str(pex_context.dbu))
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()
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)
129 # ------------------------------------------------------------ conversions
131 def to_grid(self, value_um: float, what: str) -> int:
132 """
133 Convert a µm value from the technology into grid units.
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)
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
152 # ----------------------------------------------------------------- lookup
154 def gds_pair(self, layer_name: str) -> Optional[GDSPair]:
155 return self.tech_info.gds_pair(layer_name)
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)
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}
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
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.
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
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
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]
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
224 def canonical_name(self, name: str) -> Optional[str]:
225 """
226 The canonical layer name behind a profile name.
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)
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
248 # ------------------------------------------------------------------ build
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
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
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
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
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))
292 def build_ground_plane(self, pex25d_file: Any) -> None:
293 layer = self.tech_info.process_substrate_layer
294 substrate = layer.substrate_layer
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}")
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}")
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
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
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}")
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")
349 # ------------------------------------------------------------ dielectrics
351 @cached_property
352 def included_dielectric_names(self) -> Set[str]:
353 return {lyr.name for lyr in self.tech_info.filtered_dielectric_layers}
355 def outermost_profile(self, root: str) -> str:
356 """
357 Follow the chain of films anchored on ``root`` and return its last link.
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)
379 def conformal_thicknesses(self, layer: Any) -> Tuple[int, int, int]:
380 """
381 Map a technology film onto the three PEX25D conformal thicknesses.
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")
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
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"))
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]
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
408 ground_plane_name = pex25d_file.ground_plane.name
409 previous_metal: Optional[str] = None
411 for index, layer in enumerate(layers):
412 if layer.layer_type == LT.LAYER_TYPE_METAL:
413 previous_metal = layer.name
414 continue
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
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
427 above = next_metal_after(index)
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
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))
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)
461 case LT.LAYER_TYPE_CONFORMAL_DIELECTRIC:
462 self.add_conformal_dielectric(pex25d_file, layer=layer)
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
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}")
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
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
499 over, beside, on_field = self.conformal_thicknesses(layer)
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}")
512 # ------------------------------------------------- conductors and shapes
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
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}'")
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]] = []
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))
541 if not shapes_by_layer:
542 debug(f"Net {net_name} has no interconnect geometry")
543 continue
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)
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)")
558 info(f"{len(pex25d_file.conductors)} conductor(s), {num_shapes} shape record(s)")
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.
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
575 for polygon in region.each_merged():
576 record = pex25d_file.shapes.add()
577 record.conductor = conductor
578 record.layer = layer
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
595 return count
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)
604 # -------------------------------------------------------------- terminals
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)
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)
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)")
693 # ------------------------------------------------------------- resistance
695 def build_resistance(self, pex25d_file: Any) -> None:
696 tech = self.tech_info
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)
710 for via_name, contact in self._via_contacts.items():
711 canonical = self.canonical_name(via_name) or via_name
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
723 record = pex25d_file.via_resistances.add()
724 record.via = via_name
725 record.per_cut = tech.milliohm_to_ohm(resistance.resistance)
727 info(f"{len(pex25d_file.metal_resistances)} metal and "
728 f"{len(pex25d_file.via_resistances)} via resistance record(s)")
730 # ----------------------------------------------------------------- domain
732 def build_domain(self, pex25d_file: Any) -> None:
733 """
734 Emit a computational domain only when one was asked for.
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
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")
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()